updated blender scripts for glb export
This commit is contained in:
parent
ef7fea6252
commit
dc331b5d1c
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Contributor and Agent Guide
|
||||||
|
|
||||||
|
This repository contains Blender extensions and standalone asset-pipeline tools.
|
||||||
|
For Blender work, start with [the extension authoring guide](docs/blender-extension-guide.md),
|
||||||
|
then use [the API notes](docs/blender-api-notes.md) and
|
||||||
|
[the testing and release guide](docs/testing-and-releasing.md) as needed.
|
||||||
|
|
||||||
|
## Repository map
|
||||||
|
|
||||||
|
- `blender/<extension_id>/`: one installable Blender extension per directory.
|
||||||
|
Each extension root contains `blender_manifest.toml` and `__init__.py`.
|
||||||
|
- `blender/tests/`: headless Blender smoke tests. These run inside Blender's
|
||||||
|
bundled Python, not ordinary system Python.
|
||||||
|
- `blender/scripts/`: focused helpers for the current extension.
|
||||||
|
- `tools/`: standalone tools that do not need to run inside Blender.
|
||||||
|
- `dist/`: generated extension ZIP packages. Do not hand-edit them.
|
||||||
|
- `build-all.sh`: discover and package every manifest under `blender/`.
|
||||||
|
- `install-all.sh` / `install-all.bat`: build, install, and enable every
|
||||||
|
extension for the current Blender user.
|
||||||
|
|
||||||
|
## Rules for Blender extensions
|
||||||
|
|
||||||
|
1. Use the Blender Extensions format introduced in Blender 4.2. Do not add a
|
||||||
|
legacy `bl_info` dictionary. Metadata belongs in `blender_manifest.toml`.
|
||||||
|
2. Keep each extension self-contained. Use relative imports inside its package.
|
||||||
|
Bundle third-party dependencies as wheels and list them in the manifest;
|
||||||
|
never run `pip` from an enabled extension.
|
||||||
|
3. Treat the installed extension directory as read-only. Persistent extension
|
||||||
|
data belongs under `bpy.utils.extension_path_user(__package__, ...)`.
|
||||||
|
4. Declare `files`, `network`, `clipboard`, `camera`, or `microphone`
|
||||||
|
permissions in the manifest whenever the extension uses them. Network code
|
||||||
|
must also respect `bpy.app.online_access`.
|
||||||
|
5. Register Blender classes in dependency order and unregister them in reverse.
|
||||||
|
Delete properties added to `bpy.types` during `unregister()` and remove every
|
||||||
|
handler, timer, menu callback, preview collection, and keymap item created by
|
||||||
|
`register()`.
|
||||||
|
6. Operators must have a useful `poll()`, use their passed `context`, report
|
||||||
|
actionable failures, and return `{'FINISHED'}` or `{'CANCELLED'}` correctly.
|
||||||
|
7. Prefer Blender's data API over `bpy.ops`. When an operator is necessary,
|
||||||
|
make its context, selection, active object, object mode, and render engine
|
||||||
|
requirements explicit.
|
||||||
|
8. Preserve user state. Snapshot and restore temporary selection, active object,
|
||||||
|
mode, render settings, material assignments, and temporary data in `finally`.
|
||||||
|
Do not rely on Undo to reverse file writes or external side effects.
|
||||||
|
9. Never mutate shared mesh or material data merely to simplify processing.
|
||||||
|
Copy it, operate on the copy, and clean it up unless a persistent result is
|
||||||
|
an explicit feature.
|
||||||
|
10. Avoid hard-coded repository namespaces such as `bl_ext.user_default`.
|
||||||
|
An extension may be installed from another repository; use `__package__`
|
||||||
|
and relative imports.
|
||||||
|
|
||||||
|
## Adding an extension
|
||||||
|
|
||||||
|
1. Create `blender/<extension_id>/blender_manifest.toml` and `__init__.py`.
|
||||||
|
2. Use a lowercase snake-case manifest `id`; keep operator IDs and registered
|
||||||
|
class names uniquely prefixed.
|
||||||
|
3. Set a truthful `blender_version_min`, semantic `version`, SPDX license, and
|
||||||
|
only the permissions actually required.
|
||||||
|
4. Add a deterministic smoke test under `blender/tests/`. It must create its own
|
||||||
|
scene data, exercise the public operator or API, assert the result, and check
|
||||||
|
that temporary data and global state are restored.
|
||||||
|
5. Add the test to `blender/scripts/test.sh` or replace that helper with a test
|
||||||
|
dispatcher when a second extension needs its own Blender process.
|
||||||
|
6. Document the user workflow in `README.md` or a focused file under `docs/`.
|
||||||
|
7. Run the completion checks below.
|
||||||
|
|
||||||
|
## Required completion checks
|
||||||
|
|
||||||
|
For any changed Blender extension, run checks proportional to the change. The
|
||||||
|
minimum for code changes is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./blender/scripts/test.sh
|
||||||
|
blender --factory-startup --command extension validate blender/<extension_id>
|
||||||
|
./build-all.sh
|
||||||
|
blender --factory-startup --command extension validate dist/<id>-<version>.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
For registration, packaging, dependency, or manifest changes, also test the
|
||||||
|
built package in an isolated Blender user profile and confirm the enabled
|
||||||
|
extension is loaded. See [Testing and releasing](docs/testing-and-releasing.md).
|
||||||
|
|
||||||
|
Before declaring success:
|
||||||
|
|
||||||
|
- Test both the successful path and at least one important validation/failure path.
|
||||||
|
- Confirm the original scene state is preserved unless the feature documents a
|
||||||
|
persistent change.
|
||||||
|
- Confirm no temporary objects, meshes, materials, images, handlers, or files
|
||||||
|
survive unexpectedly.
|
||||||
|
- Rebuild the ZIP after the final source edit; a previously built package is not
|
||||||
|
evidence for the current source.
|
||||||
|
- Bump the manifest version for a user-visible release.
|
||||||
|
|
||||||
|
## Standalone tools
|
||||||
|
|
||||||
|
Code under `tools/` runs in ordinary Python. Keep its dependencies in
|
||||||
|
`tools/requirements.txt`, provide a helpful error when a dependency is absent,
|
||||||
|
and test with `./tools/test.sh`. Standalone tools must not import `bpy`.
|
||||||
|
|
||||||
14
README.md
14
README.md
|
|
@ -2,11 +2,20 @@
|
||||||
|
|
||||||
Small tools for moving textured assets between Blender and Substance Painter.
|
Small tools for moving textured assets between Blender and Substance Painter.
|
||||||
|
|
||||||
|
Contributor documentation starts at [AGENTS.md](AGENTS.md) and the
|
||||||
|
[documentation index](docs/README.md). The full guides are:
|
||||||
|
|
||||||
|
- [Building Blender extensions](docs/blender-extension-guide.md)
|
||||||
|
- [Blender Python API notes](docs/blender-api-notes.md)
|
||||||
|
- [Testing and releasing extensions](docs/testing-and-releasing.md)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
AGENTS.md Contributor rules and documentation map
|
||||||
blender/
|
blender/
|
||||||
material_id_baker/ Blender 5.x extension source
|
material_id_baker/ Blender 5.x extension source
|
||||||
scripts/ Build, install/update, and test helpers
|
scripts/ Build, install/update, and test helpers
|
||||||
tests/ Headless Blender smoke test
|
tests/ Headless Blender smoke test
|
||||||
|
docs/ Extension authoring and API references
|
||||||
tools/
|
tools/
|
||||||
combine_substance_textures.py
|
combine_substance_textures.py
|
||||||
requirements.txt
|
requirements.txt
|
||||||
|
|
@ -48,7 +57,7 @@ install-all.bat
|
||||||
|
|
||||||
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.
|
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. 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.
|
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. Its dependent **Quick Export GLB** option immediately writes that one-material copy to a chosen `.glb` path for Substance Painter.
|
||||||
|
|
||||||
### Install or update
|
### Install or update
|
||||||
|
|
||||||
|
|
@ -69,7 +78,8 @@ You can instead run `./blender/scripts/build.sh` and install the resulting ZIP t
|
||||||
3. Open the 3D Viewport sidebar and choose the **Material ID** tab.
|
3. Open the 3D Viewport sidebar and choose the **Material ID** tab.
|
||||||
4. Select the resolution, margin, and color mode.
|
4. Select the resolution, margin, and color mode.
|
||||||
5. Optionally enable **Create Export Copy** to create a one-material duplicate for export.
|
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**.
|
6. Optionally enable **Quick Export GLB** and choose a `.glb` path for immediate Substance Painter export.
|
||||||
|
7. 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.
|
Overlapping UV islands with different materials are ambiguous. Use a non-overlapping UV layout for recovery/compositing.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ def _create_export_copy(
|
||||||
baked_mesh: bpy.types.Mesh,
|
baked_mesh: bpy.types.Mesh,
|
||||||
image: bpy.types.Image,
|
image: bpy.types.Image,
|
||||||
apply_modifiers: bool,
|
apply_modifiers: bool,
|
||||||
scene: bpy.types.Scene,
|
target_collection: bpy.types.Collection,
|
||||||
) -> tuple[bpy.types.Object, bpy.types.Mesh, bpy.types.Material]:
|
) -> tuple[bpy.types.Object, bpy.types.Mesh, bpy.types.Material]:
|
||||||
"""Create a persistent one-material copy suitable for later export."""
|
"""Create a persistent one-material copy suitable for later export."""
|
||||||
if apply_modifiers:
|
if apply_modifiers:
|
||||||
|
|
@ -124,10 +124,10 @@ def _create_export_copy(
|
||||||
export_object.data = export_mesh
|
export_object.data = export_mesh
|
||||||
export_object.name = f"{source.name}_Baked_IDs"
|
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)
|
target_collection.objects.link(export_object)
|
||||||
|
export_object.hide_set(False)
|
||||||
|
export_object.hide_viewport = False
|
||||||
|
export_object.hide_render = False
|
||||||
|
|
||||||
material = _create_baked_ids_material(image)
|
material = _create_baked_ids_material(image)
|
||||||
export_mesh.materials.clear()
|
export_mesh.materials.clear()
|
||||||
|
|
@ -145,6 +145,41 @@ def _normalise_png_path(filepath: str) -> Path:
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_glb_path(filepath: str) -> Path:
|
||||||
|
path_text = filepath.strip()
|
||||||
|
if not path_text:
|
||||||
|
raise RuntimeError("Choose a GLB export path")
|
||||||
|
path = Path(bpy.path.abspath(path_text)).expanduser()
|
||||||
|
if path.suffix.lower() != ".glb":
|
||||||
|
path = path.with_suffix(".glb")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _quick_export_glb(
|
||||||
|
context: bpy.types.Context,
|
||||||
|
export_object: bpy.types.Object,
|
||||||
|
filepath: str,
|
||||||
|
) -> Path:
|
||||||
|
output_path = _normalise_glb_path(filepath)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for obj in list(context.selected_objects):
|
||||||
|
obj.select_set(False)
|
||||||
|
export_object.select_set(True)
|
||||||
|
context.view_layer.objects.active = export_object
|
||||||
|
|
||||||
|
result = bpy.ops.export_scene.gltf(
|
||||||
|
filepath=str(output_path),
|
||||||
|
export_format="GLB",
|
||||||
|
use_selection=True,
|
||||||
|
export_materials="EXPORT",
|
||||||
|
export_animations=False,
|
||||||
|
)
|
||||||
|
if "FINISHED" not in result:
|
||||||
|
raise RuntimeError("Blender did not finish the GLB export")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_bake_settings(scene: bpy.types.Scene) -> dict[str, Any]:
|
def _snapshot_bake_settings(scene: bpy.types.Scene) -> dict[str, Any]:
|
||||||
bake = scene.render.bake
|
bake = scene.render.bake
|
||||||
return {
|
return {
|
||||||
|
|
@ -153,6 +188,7 @@ def _snapshot_bake_settings(scene: bpy.types.Scene) -> dict[str, Any]:
|
||||||
"target": bake.target,
|
"target": bake.target,
|
||||||
"use_clear": bake.use_clear,
|
"use_clear": bake.use_clear,
|
||||||
"use_selected_to_active": bake.use_selected_to_active,
|
"use_selected_to_active": bake.use_selected_to_active,
|
||||||
|
"cycles_samples": scene.cycles.samples,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -163,6 +199,7 @@ def _restore_bake_settings(scene: bpy.types.Scene, state: dict[str, Any]) -> Non
|
||||||
bake.target = state["target"]
|
bake.target = state["target"]
|
||||||
bake.use_clear = state["use_clear"]
|
bake.use_clear = state["use_clear"]
|
||||||
bake.use_selected_to_active = state["use_selected_to_active"]
|
bake.use_selected_to_active = state["use_selected_to_active"]
|
||||||
|
scene.cycles.samples = state["cycles_samples"]
|
||||||
|
|
||||||
|
|
||||||
class MIDB_PaletteEntry(PropertyGroup):
|
class MIDB_PaletteEntry(PropertyGroup):
|
||||||
|
|
@ -231,6 +268,17 @@ class MIDB_Settings(PropertyGroup):
|
||||||
),
|
),
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
quick_export_glb: BoolProperty(
|
||||||
|
name="Quick Export GLB",
|
||||||
|
description="Immediately export the generated one-material copy as a GLB",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
glb_filepath: StringProperty(
|
||||||
|
name="GLB Path",
|
||||||
|
description="Path for the Substance Painter-ready GLB export",
|
||||||
|
subtype="FILE_PATH",
|
||||||
|
default="//baked_ids.glb",
|
||||||
|
)
|
||||||
image_name: StringProperty(
|
image_name: StringProperty(
|
||||||
name="Image Name",
|
name="Image Name",
|
||||||
default="Material_ID",
|
default="Material_ID",
|
||||||
|
|
@ -388,6 +436,7 @@ class MIDB_OT_Bake(Operator):
|
||||||
# Cycles owns Blender's image-bake pipeline even though this flat
|
# Cycles owns Blender's image-bake pipeline even though this flat
|
||||||
# emission bake needs only one deterministic sample.
|
# emission bake needs only one deterministic sample.
|
||||||
scene.render.engine = "CYCLES"
|
scene.render.engine = "CYCLES"
|
||||||
|
scene.cycles.samples = 1
|
||||||
scene.render.bake.margin = settings.margin
|
scene.render.bake.margin = settings.margin
|
||||||
scene.render.bake.target = "IMAGE_TEXTURES"
|
scene.render.bake.target = "IMAGE_TEXTURES"
|
||||||
scene.render.bake.use_clear = True
|
scene.render.bake.use_clear = True
|
||||||
|
|
@ -448,7 +497,15 @@ class MIDB_OT_Bake(Operator):
|
||||||
temp_mesh,
|
temp_mesh,
|
||||||
image,
|
image,
|
||||||
settings.apply_modifiers,
|
settings.apply_modifiers,
|
||||||
scene,
|
context.collection,
|
||||||
|
)
|
||||||
|
|
||||||
|
exported_glb_path: Path | None = None
|
||||||
|
if export_object is not None and settings.quick_export_glb:
|
||||||
|
exported_glb_path = _quick_export_glb(
|
||||||
|
context,
|
||||||
|
export_object,
|
||||||
|
settings.glb_filepath,
|
||||||
)
|
)
|
||||||
|
|
||||||
succeeded = True
|
succeeded = True
|
||||||
|
|
@ -458,6 +515,8 @@ class MIDB_OT_Bake(Operator):
|
||||||
message = f"Baked material IDs to image '{image.name}'"
|
message = f"Baked material IDs to image '{image.name}'"
|
||||||
if export_object is not None:
|
if export_object is not None:
|
||||||
message += f" and created '{export_object.name}'"
|
message += f" and created '{export_object.name}'"
|
||||||
|
if exported_glb_path is not None:
|
||||||
|
message += f"; exported GLB to {exported_glb_path}"
|
||||||
self.report({"INFO"}, message)
|
self.report({"INFO"}, message)
|
||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
@ -531,7 +590,14 @@ class MIDB_PT_Panel(Panel):
|
||||||
column.prop(settings, "margin")
|
column.prop(settings, "margin")
|
||||||
column.prop(settings, "palette_mode")
|
column.prop(settings, "palette_mode")
|
||||||
column.prop(settings, "apply_modifiers")
|
column.prop(settings, "apply_modifiers")
|
||||||
column.prop(settings, "create_export_copy")
|
|
||||||
|
export_copy = layout.box()
|
||||||
|
export_copy.prop(settings, "create_export_copy")
|
||||||
|
quick_export = export_copy.column(align=True)
|
||||||
|
quick_export.enabled = settings.create_export_copy
|
||||||
|
quick_export.prop(settings, "quick_export_glb")
|
||||||
|
if settings.quick_export_glb:
|
||||||
|
quick_export.prop(settings, "glb_filepath")
|
||||||
|
|
||||||
output = layout.box()
|
output = layout.box()
|
||||||
output.label(text="Output")
|
output.label(text="Output")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
schema_version = "1.0.0"
|
schema_version = "1.0.0"
|
||||||
|
|
||||||
id = "material_id_baker"
|
id = "material_id_baker"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
name = "Material ID Baker"
|
name = "Material ID Baker"
|
||||||
tagline = "Bake mesh material assignments to a color ID texture"
|
tagline = "Bake mesh material assignments to a color ID texture"
|
||||||
maintainer = "Sear"
|
maintainer = "Sear"
|
||||||
|
|
@ -14,4 +14,4 @@ license = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[permissions]
|
[permissions]
|
||||||
files = "Save baked ID textures and JSON palette legends"
|
files = "Save baked textures, JSON legends, and GLB export copies"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import struct
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -73,6 +74,8 @@ def main() -> None:
|
||||||
material_id_baker.register()
|
material_id_baker.register()
|
||||||
source = make_test_object()
|
source = make_test_object()
|
||||||
original_engine = bpy.context.scene.render.engine
|
original_engine = bpy.context.scene.render.engine
|
||||||
|
bpy.context.scene.cycles.samples = 37
|
||||||
|
original_cycles_samples = bpy.context.scene.cycles.samples
|
||||||
|
|
||||||
settings = bpy.context.scene.material_id_baker
|
settings = bpy.context.scene.material_id_baker
|
||||||
settings.resolution = "256"
|
settings.resolution = "256"
|
||||||
|
|
@ -80,6 +83,8 @@ def main() -> None:
|
||||||
settings.palette_mode = "DISTINCT"
|
settings.palette_mode = "DISTINCT"
|
||||||
settings.apply_modifiers = True
|
settings.apply_modifiers = True
|
||||||
settings.create_export_copy = True
|
settings.create_export_copy = True
|
||||||
|
settings.quick_export_glb = True
|
||||||
|
settings.glb_filepath = "/tmp/material_id_baker_smoke_export"
|
||||||
settings.image_name = "Material_ID_Smoke"
|
settings.image_name = "Material_ID_Smoke"
|
||||||
settings.save_to_disk = True
|
settings.save_to_disk = True
|
||||||
settings.filepath = "/tmp/material_id_baker_smoke.png"
|
settings.filepath = "/tmp/material_id_baker_smoke.png"
|
||||||
|
|
@ -88,6 +93,7 @@ def main() -> None:
|
||||||
result = bpy.ops.object.bake_material_id()
|
result = bpy.ops.object.bake_material_id()
|
||||||
assert result == {"FINISHED"}, result
|
assert result == {"FINISHED"}, result
|
||||||
assert bpy.context.scene.render.engine == original_engine
|
assert bpy.context.scene.render.engine == original_engine
|
||||||
|
assert bpy.context.scene.cycles.samples == original_cycles_samples
|
||||||
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.objects)
|
||||||
assert not any(item.name.startswith("__MID_BAKER_") for item in bpy.data.materials)
|
assert not any(item.name.startswith("__MID_BAKER_") for item in bpy.data.materials)
|
||||||
|
|
||||||
|
|
@ -123,6 +129,23 @@ def main() -> None:
|
||||||
assert Path("/tmp/material_id_baker_smoke.png").is_file()
|
assert Path("/tmp/material_id_baker_smoke.png").is_file()
|
||||||
assert Path("/tmp/material_id_baker_smoke.json").is_file()
|
assert Path("/tmp/material_id_baker_smoke.json").is_file()
|
||||||
|
|
||||||
|
glb_path = Path("/tmp/material_id_baker_smoke_export.glb")
|
||||||
|
assert glb_path.is_file()
|
||||||
|
glb_data = glb_path.read_bytes()
|
||||||
|
magic, version, total_length = struct.unpack_from("<4sII", glb_data, 0)
|
||||||
|
assert magic == b"glTF"
|
||||||
|
assert version == 2
|
||||||
|
assert total_length == len(glb_data)
|
||||||
|
json_length, json_type = struct.unpack_from("<I4s", glb_data, 12)
|
||||||
|
assert json_type == b"JSON"
|
||||||
|
glb_json = json.loads(glb_data[20 : 20 + json_length].decode("utf-8"))
|
||||||
|
assert len(glb_json["materials"]) == 1
|
||||||
|
assert glb_json["materials"][0]["name"] == "baked ids"
|
||||||
|
assert len(glb_json["meshes"]) == 1
|
||||||
|
assert len(glb_json["nodes"]) == 1
|
||||||
|
assert len(glb_json["images"]) == 1
|
||||||
|
assert "animations" not in glb_json
|
||||||
|
|
||||||
print("MATERIAL_ID_BAKER_SMOKE_TEST_OK")
|
print("MATERIAL_ID_BAKER_SMOKE_TEST_OK")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Documentation index
|
||||||
|
|
||||||
|
- [Building Blender extensions](blender-extension-guide.md): package layout,
|
||||||
|
manifests, permissions, registration, operators, UI, safe data handling,
|
||||||
|
storage, dependencies, builds, and installation.
|
||||||
|
- [Blender Python API notes](blender-api-notes.md): context and mode pitfalls,
|
||||||
|
data-block ownership, lifecycle cleanup, evaluated meshes, materials, images,
|
||||||
|
baking, file effects, and compatibility probes.
|
||||||
|
- [Testing and releasing](testing-and-releasing.md): headless smoke tests,
|
||||||
|
manifest/package validation, isolated installs, manual coverage, semantic
|
||||||
|
versions, release checks, and system deployment.
|
||||||
|
|
||||||
|
These guides target modern add-on extensions in Blender 5.x. Repository-wide
|
||||||
|
contributor requirements live in [AGENTS.md](../AGENTS.md).
|
||||||
|
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
# Blender Python API notes for extension authors
|
||||||
|
|
||||||
|
These notes collect the failure modes most likely to matter in this repository.
|
||||||
|
They are not a replacement for the [current Blender Python API](https://docs.blender.org/api/current/).
|
||||||
|
|
||||||
|
## Context, modes, and operators
|
||||||
|
|
||||||
|
`bpy.context` is global ambient state. Operator and panel callbacks receive a
|
||||||
|
`context` argument that may be overridden, so use that argument inside the
|
||||||
|
callback.
|
||||||
|
|
||||||
|
An operator that works when clicked can still fail from a script or in
|
||||||
|
background mode. Common implicit requirements include:
|
||||||
|
|
||||||
|
- active object and selected objects;
|
||||||
|
- Object/Edit/Sculpt mode;
|
||||||
|
- active view layer and collection visibility;
|
||||||
|
- active image texture node in every material;
|
||||||
|
- current editor area/region;
|
||||||
|
- render engine and bake settings.
|
||||||
|
|
||||||
|
Put cheap requirements in `poll()` and validate again in `execute()` when a
|
||||||
|
specific error message helps. Prefer direct data manipulation over synthesizing
|
||||||
|
UI context overrides.
|
||||||
|
|
||||||
|
## Data-block ownership
|
||||||
|
|
||||||
|
Objects reference meshes; material slots live primarily on meshes but can be
|
||||||
|
overridden per object; nodes reference images. Copy the level whose ownership
|
||||||
|
must become independent.
|
||||||
|
|
||||||
|
```python
|
||||||
|
object_copy = source.copy() # independent object, shared mesh
|
||||||
|
object_copy.data = source.data.copy() # independent object and mesh
|
||||||
|
```
|
||||||
|
|
||||||
|
`collection.clear()` and similar operations may normalize dependent indices.
|
||||||
|
For example, clearing mesh material slots can reset polygon material indices.
|
||||||
|
Snapshot indices before clearing and restore them after rebuilding the slots.
|
||||||
|
|
||||||
|
Blender data collections do not behave like ordinary Python ownership. Clean up
|
||||||
|
temporary data explicitly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
bpy.data.objects.remove(temp_object, do_unlink=True)
|
||||||
|
if temp_mesh.users == 0:
|
||||||
|
bpy.data.meshes.remove(temp_mesh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not remove a data-block with active users merely to avoid an orphan.
|
||||||
|
|
||||||
|
## Registration lifecycle
|
||||||
|
|
||||||
|
Keep a deterministic tuple of registered classes. Dependencies go first:
|
||||||
|
|
||||||
|
1. `PropertyGroup` and `AddonPreferences`;
|
||||||
|
2. operators;
|
||||||
|
3. panels, menus, UI lists, and gizmos.
|
||||||
|
|
||||||
|
Unregister in reverse. Any side effect outside class registration needs its own
|
||||||
|
inverse operation:
|
||||||
|
|
||||||
|
- `bpy.types.SomeMenu.append()` → `.remove()`;
|
||||||
|
- `bpy.app.handlers.*.append()` → `.remove()`;
|
||||||
|
- `bpy.app.timers.register()` → unregister or make the callback stop;
|
||||||
|
- created keymaps → remove their keymap items;
|
||||||
|
- preview collections → close/remove them;
|
||||||
|
- `bpy.types.Scene.some_property = ...` → `del bpy.types.Scene.some_property`.
|
||||||
|
|
||||||
|
Reloading exposes incomplete cleanup quickly. A package that only works after
|
||||||
|
restarting Blender has a lifecycle bug.
|
||||||
|
|
||||||
|
## Properties and persistence
|
||||||
|
|
||||||
|
Blender properties are RNA definitions, not normal instance attributes. Declare
|
||||||
|
them on registered Blender classes with `bpy.props` annotations. Register a
|
||||||
|
`PropertyGroup` before using it as a `PointerProperty` or `CollectionProperty`.
|
||||||
|
|
||||||
|
Choose storage based on intended lifetime:
|
||||||
|
|
||||||
|
- operator property: one invocation and redo panel;
|
||||||
|
- `Scene`/`Object` property: saved in the `.blend`;
|
||||||
|
- `AddonPreferences`: current Blender user profile;
|
||||||
|
- module global: only the current enabled Python module lifetime;
|
||||||
|
- `extension_path_user()`: persistent files owned by the extension.
|
||||||
|
|
||||||
|
Property update callbacks can run in surprising contexts. Keep them small,
|
||||||
|
avoid expensive operators, and guard against recursion.
|
||||||
|
|
||||||
|
## Meshes, modifiers, and UVs
|
||||||
|
|
||||||
|
`source.data.copy()` copies the base mesh. It does not apply modifiers.
|
||||||
|
`bpy.data.meshes.new_from_object(source.evaluated_get(depsgraph), ...)` captures
|
||||||
|
evaluated geometry. Decide explicitly which topology the result should use.
|
||||||
|
|
||||||
|
Evaluated geometry can add/remove UV layers or material slots. After evaluation:
|
||||||
|
|
||||||
|
- confirm a UV layer still exists;
|
||||||
|
- choose and set the intended active/render UV layer;
|
||||||
|
- compute the required material-slot count from both slots and polygon indices;
|
||||||
|
- do not assume source and evaluated polygon counts match.
|
||||||
|
|
||||||
|
## Materials, nodes, and images
|
||||||
|
|
||||||
|
For materials used by common exporters, a Principled BSDF with an Image Texture
|
||||||
|
connected to Base Color is more portable than a custom shader tree. For a bake
|
||||||
|
target, every material involved in the bake needs an active Image Texture node
|
||||||
|
that points at the destination image.
|
||||||
|
|
||||||
|
Use `Non-Color` for ID maps, masks, normal maps, roughness, metallic, and packed
|
||||||
|
data textures. Color-space configuration affects both values returned to Python
|
||||||
|
and values saved to disk.
|
||||||
|
|
||||||
|
Byte images quantize floats. A value recorded as `0.1` in metadata may become
|
||||||
|
byte 25 or 26 depending on the conversion path. If another tool consumes an ID
|
||||||
|
map, prefer exact byte matching with a narrowly bounded fallback, and reject
|
||||||
|
overlapping color masks.
|
||||||
|
|
||||||
|
Generated images are Blender data-blocks. Decide whether they should be saved,
|
||||||
|
packed, or intentionally remain generated data before the user closes the file.
|
||||||
|
|
||||||
|
## Baking
|
||||||
|
|
||||||
|
Image baking is context-sensitive. A robust bake typically controls and restores:
|
||||||
|
|
||||||
|
- active/selected bake object;
|
||||||
|
- Object mode;
|
||||||
|
- `scene.render.engine` (Cycles owns the image-bake pipeline);
|
||||||
|
- Cycles samples (one sample is sufficient for a deterministic flat emission bake);
|
||||||
|
- `scene.render.bake.target`, margin, clear behavior, and selected-to-active;
|
||||||
|
- active target Image Texture nodes;
|
||||||
|
- temporary materials and material indices.
|
||||||
|
|
||||||
|
Use a flat emission shader for material-ID colors so lights, normals, and
|
||||||
|
sampling do not alter the values. Always restore the previous render engine and
|
||||||
|
bake settings in `finally`.
|
||||||
|
|
||||||
|
## Files, paths, and external effects
|
||||||
|
|
||||||
|
Blender accepts `//` paths relative to the current `.blend`. Resolve them with
|
||||||
|
`bpy.path.abspath()`. Do not assume the `.blend` has been saved, and do not
|
||||||
|
silently write into the extension package directory.
|
||||||
|
|
||||||
|
`UNDO` only covers Blender's undo-aware data changes. It does not remove a PNG,
|
||||||
|
undo a JSON/GLB write, retract a network request, or restore another program's
|
||||||
|
state. Validate first and order external effects late.
|
||||||
|
|
||||||
|
## Compatibility checks
|
||||||
|
|
||||||
|
Blender's Python API changes between releases. Avoid relying on memory for enum
|
||||||
|
names, node socket names, or RNA properties. Query the target build when needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender --factory-startup --background --python-expr \
|
||||||
|
"import bpy; print(bpy.app.version_string); print(bpy.types.BakeSettings.bl_rna.properties.keys())"
|
||||||
|
```
|
||||||
|
|
||||||
|
Feature-detect optional properties with `hasattr` or guarded assignment, but do
|
||||||
|
not use broad exception handling to hide genuine failures. When support differs
|
||||||
|
materially by version, set an honest manifest minimum or branch on
|
||||||
|
`bpy.app.version` and test both branches.
|
||||||
|
|
||||||
|
Useful API entry points:
|
||||||
|
|
||||||
|
- [`bpy.types.Operator`](https://docs.blender.org/api/current/bpy.types.Operator.html)
|
||||||
|
- [`bpy.props`](https://docs.blender.org/api/current/bpy.props.html)
|
||||||
|
- [`bpy.utils`](https://docs.blender.org/api/current/bpy.utils.html)
|
||||||
|
- [`bpy.types.Image`](https://docs.blender.org/api/current/bpy.types.Image.html)
|
||||||
|
- [`bpy.types.Mesh`](https://docs.blender.org/api/current/bpy.types.Mesh.html)
|
||||||
|
- [`bpy.types.Depsgraph`](https://docs.blender.org/api/current/bpy.types.Depsgraph.html)
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
# Building Blender extensions in this repository
|
||||||
|
|
||||||
|
This guide targets Blender 5.x, with Blender 5.2 as the current user target.
|
||||||
|
Blender calls installable Python plug-ins **add-on extensions**. Use that format
|
||||||
|
instead of the legacy single-file add-on format.
|
||||||
|
|
||||||
|
## 1. Understand the package boundary
|
||||||
|
|
||||||
|
Every extension is an independent directory under `blender/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
blender/
|
||||||
|
example_tool/
|
||||||
|
blender_manifest.toml
|
||||||
|
__init__.py
|
||||||
|
operators.py # optional
|
||||||
|
properties.py # optional
|
||||||
|
ui.py # optional
|
||||||
|
wheels/ # optional bundled dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
The build command turns the contents of `example_tool/` into a ZIP whose root
|
||||||
|
contains the manifest and `__init__.py`. The directory containing all extensions
|
||||||
|
is not itself a Python package.
|
||||||
|
|
||||||
|
Start with one `__init__.py` while a tool is small. Split it when operators,
|
||||||
|
panels, and data helpers become difficult to navigate. Multi-file extensions
|
||||||
|
must use relative imports:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from . import operators, properties, ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not import the extension by a hard-coded installed name. Blender namespaces
|
||||||
|
extensions by repository, for example `bl_ext.user_default.example_tool`, and
|
||||||
|
that prefix changes when the package is installed elsewhere.
|
||||||
|
|
||||||
|
## 2. Write the manifest
|
||||||
|
|
||||||
|
A minimal repository-compatible manifest looks like this:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
schema_version = "1.0.0"
|
||||||
|
|
||||||
|
id = "example_tool"
|
||||||
|
version = "0.1.0"
|
||||||
|
name = "Example Tool"
|
||||||
|
tagline = "Perform one useful Blender workflow"
|
||||||
|
maintainer = "Your Name"
|
||||||
|
type = "add-on"
|
||||||
|
|
||||||
|
blender_version_min = "5.0.0"
|
||||||
|
|
||||||
|
license = [
|
||||||
|
"SPDX:GPL-3.0-or-later",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Manifest rules worth catching early:
|
||||||
|
|
||||||
|
- `id` is the stable package identity. Do not change it merely to rename the UI.
|
||||||
|
- `version` follows semantic versioning. Bump it when distributing an update.
|
||||||
|
- `tagline` is short, has no trailing punctuation, and describes an action.
|
||||||
|
- `blender_version_min` is the oldest version actually supported, not simply the
|
||||||
|
developer's newest installed version.
|
||||||
|
- Omit unused optional fields rather than setting them to empty strings/lists.
|
||||||
|
- Use SPDX-prefixed license identifiers.
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
Declare capabilities that reach outside normal Blender data:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[permissions]
|
||||||
|
files = "Export processed textures selected by the user"
|
||||||
|
network = "Synchronize assets with the configured server"
|
||||||
|
```
|
||||||
|
|
||||||
|
Only declare what the extension uses. Permission explanations must be short and
|
||||||
|
must not end in punctuation. Network permission does not override Blender's
|
||||||
|
online-access preference; check it before connecting:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if not bpy.app.online_access:
|
||||||
|
self.report({"ERROR"}, "Enable Online Access in Blender preferences")
|
||||||
|
return {"CANCELLED"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Implement registration, an operator, and UI
|
||||||
|
|
||||||
|
This is a small but complete extension entry point:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import bpy
|
||||||
|
from bpy.props import BoolProperty, PointerProperty
|
||||||
|
from bpy.types import Operator, Panel, PropertyGroup
|
||||||
|
|
||||||
|
|
||||||
|
class EXAMPLE_PG_Settings(PropertyGroup):
|
||||||
|
affect_selected: BoolProperty(
|
||||||
|
name="Affect Selected",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EXAMPLE_OT_Run(Operator):
|
||||||
|
bl_idname = "object.example_run"
|
||||||
|
bl_label = "Run Example"
|
||||||
|
bl_description = "Perform the example operation on the active mesh"
|
||||||
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
return bool(
|
||||||
|
context.mode == "OBJECT"
|
||||||
|
and context.active_object
|
||||||
|
and context.active_object.type == "MESH"
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
obj = context.active_object
|
||||||
|
if obj is None:
|
||||||
|
self.report({"ERROR"}, "Select a mesh object")
|
||||||
|
return {"CANCELLED"}
|
||||||
|
|
||||||
|
# Prefer direct data API changes here. Use context, not bpy.context.
|
||||||
|
obj["example_was_run"] = True
|
||||||
|
self.report({"INFO"}, f"Processed {obj.name}")
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class EXAMPLE_PT_Panel(Panel):
|
||||||
|
bl_label = "Example Tool"
|
||||||
|
bl_idname = "EXAMPLE_PT_main"
|
||||||
|
bl_space_type = "VIEW_3D"
|
||||||
|
bl_region_type = "UI"
|
||||||
|
bl_category = "Example"
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
layout = self.layout
|
||||||
|
settings = context.scene.example_tool
|
||||||
|
layout.prop(settings, "affect_selected")
|
||||||
|
layout.operator("object.example_run")
|
||||||
|
|
||||||
|
|
||||||
|
CLASSES = (
|
||||||
|
EXAMPLE_PG_Settings,
|
||||||
|
EXAMPLE_OT_Run,
|
||||||
|
EXAMPLE_PT_Panel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
for cls in CLASSES:
|
||||||
|
bpy.utils.register_class(cls)
|
||||||
|
bpy.types.Scene.example_tool = PointerProperty(type=EXAMPLE_PG_Settings)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
del bpy.types.Scene.example_tool
|
||||||
|
for cls in reversed(CLASSES):
|
||||||
|
bpy.utils.unregister_class(cls)
|
||||||
|
```
|
||||||
|
|
||||||
|
Important details:
|
||||||
|
|
||||||
|
- Register `PropertyGroup` classes before creating pointer/collection properties
|
||||||
|
that reference them.
|
||||||
|
- Unregister in exact reverse dependency order.
|
||||||
|
- Prefix class names and Blender identifiers to avoid collisions.
|
||||||
|
- A panel's `draw()` executes frequently. It should be cheap and should not
|
||||||
|
mutate scene data.
|
||||||
|
- Use `poll()` both to disable invalid UI actions and to document required context.
|
||||||
|
- `bl_options = {"REGISTER", "UNDO"}` is appropriate for scene-data changes,
|
||||||
|
but Undo does not reverse files, HTTP calls, or other external side effects.
|
||||||
|
|
||||||
|
## 4. Design safe Blender operations
|
||||||
|
|
||||||
|
Blender data often has multiple users. Editing `obj.data` can change every
|
||||||
|
object sharing that mesh, and editing a material can change every object using
|
||||||
|
it. When the feature should be non-destructive, copy first:
|
||||||
|
|
||||||
|
```python
|
||||||
|
result_object = source.copy()
|
||||||
|
result_object.data = source.data.copy()
|
||||||
|
source.users_collection[0].objects.link(result_object)
|
||||||
|
```
|
||||||
|
|
||||||
|
When modifiers must be applied without touching the source:
|
||||||
|
|
||||||
|
```python
|
||||||
|
depsgraph = context.evaluated_depsgraph_get()
|
||||||
|
evaluated = source.evaluated_get(depsgraph)
|
||||||
|
result_mesh = bpy.data.meshes.new_from_object(
|
||||||
|
evaluated,
|
||||||
|
preserve_all_data_layers=True,
|
||||||
|
depsgraph=depsgraph,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Stateful operations should follow a transaction-like structure:
|
||||||
|
|
||||||
|
```python
|
||||||
|
active_before = context.view_layer.objects.active
|
||||||
|
selected_before = list(context.selected_objects)
|
||||||
|
temporary_object = None
|
||||||
|
success = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create copies and perform the operation.
|
||||||
|
success = True
|
||||||
|
finally:
|
||||||
|
# Remove temporary data and restore selection/settings.
|
||||||
|
if temporary_object is not None:
|
||||||
|
bpy.data.objects.remove(temporary_object, do_unlink=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
Track every temporary data-block you create. Removing an object does not
|
||||||
|
automatically remove its orphaned mesh, material, or image. Check `users == 0`
|
||||||
|
before removing a data-block that might legitimately be shared.
|
||||||
|
|
||||||
|
Use `bpy.ops` only when Blender exposes no suitable data API. Operators depend
|
||||||
|
on context: editor area, mode, active object, selected objects, active material
|
||||||
|
node, render engine, or view layer. A headless test is the quickest way to find
|
||||||
|
hidden context assumptions.
|
||||||
|
|
||||||
|
## 5. Store files and settings correctly
|
||||||
|
|
||||||
|
An installed extension may live in a read-only system repository, and upgrades
|
||||||
|
replace its package directory. Never store user data beside `__init__.py`.
|
||||||
|
|
||||||
|
Use the per-extension storage API:
|
||||||
|
|
||||||
|
```python
|
||||||
|
storage_dir = bpy.utils.extension_path_user(
|
||||||
|
__package__,
|
||||||
|
path="cache",
|
||||||
|
create=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use Blender properties for settings that should live in a `.blend` file, and an
|
||||||
|
`AddonPreferences` subclass for user preferences that should apply across
|
||||||
|
projects. Access preferences through `__package__`, not a literal module name:
|
||||||
|
|
||||||
|
```python
|
||||||
|
preferences = bpy.context.preferences.addons[__package__].preferences
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `bpy.path.abspath()` for Blender paths such as `//textures/output.png`.
|
||||||
|
Validate empty paths and file formats before starting an expensive operation.
|
||||||
|
|
||||||
|
## 6. Bundle dependencies
|
||||||
|
|
||||||
|
Extensions must be self-contained. For a third-party Python dependency:
|
||||||
|
|
||||||
|
1. Download/build wheels for every supported OS and architecture.
|
||||||
|
2. Put them under the extension, conventionally `wheels/`.
|
||||||
|
3. List each relative wheel path in the manifest's `wheels` array.
|
||||||
|
4. Build and test the installed ZIP on every advertised platform.
|
||||||
|
|
||||||
|
Do not import from the developer's system Python, mutate Blender's bundled
|
||||||
|
Python, or invoke `pip` during registration. Pure-Python code may be vendored,
|
||||||
|
but keep its license and avoid top-level package-name collisions.
|
||||||
|
|
||||||
|
## 7. Build and install
|
||||||
|
|
||||||
|
Build all repository extensions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Build, reinstall, and enable them for the current Blender user:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./install-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows Command Prompt:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
install-all.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Select a non-default Blender executable with `BLENDER_BIN`. Close running
|
||||||
|
Blender instances before reinstalling; an open process retains already imported
|
||||||
|
Python modules.
|
||||||
|
|
||||||
|
For the full validation and release sequence, continue with
|
||||||
|
[Testing and releasing](testing-and-releasing.md).
|
||||||
|
|
||||||
|
## Official references
|
||||||
|
|
||||||
|
- [Creating Blender extensions](https://docs.blender.org/manual/en/dev/advanced/extensions/getting_started.html)
|
||||||
|
- [Extension add-ons, namespaces, storage, and online access](https://docs.blender.org/manual/en/dev/advanced/extensions/addons.html)
|
||||||
|
- [Bundling Python wheels](https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html)
|
||||||
|
- [Extension command-line arguments](https://docs.blender.org/manual/en/dev/advanced/command_line/extension_arguments.html)
|
||||||
|
- [Current Blender Python API](https://docs.blender.org/api/current/)
|
||||||
|
- [Blender extension add-on guidelines](https://developer.blender.org/docs/handbook/extensions/addon_guidelines/)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
# Testing, packaging, installing, and releasing Blender extensions
|
||||||
|
|
||||||
|
The goal is to test the same artifact a user installs, while keeping local
|
||||||
|
Blender preferences and production scenes out of the test.
|
||||||
|
|
||||||
|
## Test layers
|
||||||
|
|
||||||
|
### 1. Syntax and registration
|
||||||
|
|
||||||
|
Import/register the extension in Blender's Python, not system Python. Ordinary
|
||||||
|
Python usually has no compatible `bpy` module.
|
||||||
|
|
||||||
|
A useful smoke test should:
|
||||||
|
|
||||||
|
1. start from `--factory-startup --background`;
|
||||||
|
2. register or enable the extension;
|
||||||
|
3. build its own minimal scene and data-blocks;
|
||||||
|
4. invoke the public operator/API;
|
||||||
|
5. assert outputs and persistent results;
|
||||||
|
6. assert selection, active object, render state, and source data restoration;
|
||||||
|
7. assert temporary objects/materials/meshes/images were removed;
|
||||||
|
8. print one unmistakable success marker.
|
||||||
|
|
||||||
|
The current extension test is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./blender/scripts/test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
When more extensions are added, give each one a focused test file and make the
|
||||||
|
shared test helper run each in a fresh Blender process. Fresh processes prevent
|
||||||
|
registration and global-state leakage between tests.
|
||||||
|
|
||||||
|
### 2. Manifest validation
|
||||||
|
|
||||||
|
Validate source metadata before packaging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender --factory-startup --command extension validate \
|
||||||
|
blender/material_id_baker
|
||||||
|
```
|
||||||
|
|
||||||
|
Then validate the actual ZIP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VERSION=1.2.0
|
||||||
|
blender --factory-startup --command extension validate \
|
||||||
|
"dist/material_id_baker-${VERSION}.zip"
|
||||||
|
```
|
||||||
|
|
||||||
|
ZIP validation catches package-root and build-exclusion mistakes that source
|
||||||
|
validation cannot.
|
||||||
|
|
||||||
|
### 3. Installed-package test
|
||||||
|
|
||||||
|
Build all packages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Install into an isolated Blender profile on Linux/macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test_profile="$(mktemp -d)"
|
||||||
|
BLENDER_USER_RESOURCES="$test_profile" ./install-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then launch Blender with the same `BLENDER_USER_RESOURCES` and assert registered
|
||||||
|
operators/classes are present. Do not point install tests at the developer's real
|
||||||
|
profile unless the user explicitly wants to update it.
|
||||||
|
|
||||||
|
On Windows, set `BLENDER_USER_RESOURCES` to a temporary directory before running
|
||||||
|
`install-all.bat` from the same Command Prompt.
|
||||||
|
|
||||||
|
### 4. Manual UI test
|
||||||
|
|
||||||
|
Headless tests do not prove layout quality or interactive behavior. Before a
|
||||||
|
release, install the ZIP and check:
|
||||||
|
|
||||||
|
- panel location, labels, spacing, disabled states, and tooltips;
|
||||||
|
- behavior with no object, wrong object type, wrong mode, missing UV/material,
|
||||||
|
linked data, shared data, and unsaved `.blend` paths as relevant;
|
||||||
|
- Undo/Redo for scene changes;
|
||||||
|
- disabling and re-enabling without restarting Blender;
|
||||||
|
- saving/reopening the `.blend` when properties or generated data persist;
|
||||||
|
- output in the Image Editor, Shader Editor, Outliner, and exporter as relevant.
|
||||||
|
|
||||||
|
## Build helpers in this repository
|
||||||
|
|
||||||
|
`build-all.sh` recursively discovers `blender_manifest.toml` under `blender/`
|
||||||
|
and writes versioned packages to `dist/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BLENDER_BIN=/path/to/blender-5.2 ./build-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`install-all.sh` builds, installs, and enables only the packages produced by
|
||||||
|
that invocation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BLENDER_EXTENSION_REPO=user_default ./install-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
set "BLENDER_BIN=C:\Program Files\Blender Foundation\Blender 5.2\blender.exe"
|
||||||
|
set "BLENDER_EXTENSION_REPO=user_default"
|
||||||
|
install-all.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Close open Blender processes before replacing an extension. Reinstalling files
|
||||||
|
does not reload a module already imported by a running process.
|
||||||
|
|
||||||
|
## Versioning and release checklist
|
||||||
|
|
||||||
|
Use semantic versions in `blender_manifest.toml`:
|
||||||
|
|
||||||
|
- patch: compatible bug fix or internal improvement;
|
||||||
|
- minor: backward-compatible user-visible feature;
|
||||||
|
- major: breaking behavior/configuration/workflow change.
|
||||||
|
|
||||||
|
Release sequence:
|
||||||
|
|
||||||
|
1. Finish source and documentation changes.
|
||||||
|
2. Update/add automated tests and run them.
|
||||||
|
3. Bump the manifest version.
|
||||||
|
4. Validate the source directory.
|
||||||
|
5. Run `./build-all.sh`.
|
||||||
|
6. Validate the new versioned ZIP.
|
||||||
|
7. Install the ZIP into an isolated profile and confirm it enables.
|
||||||
|
8. Perform the relevant manual UI/export test in the target Blender version.
|
||||||
|
9. Inspect ZIP contents; source/tests/repository files must not leak into it.
|
||||||
|
10. Distribute the immutable ZIP. Do not replace a published ZIP without also
|
||||||
|
changing its version.
|
||||||
|
|
||||||
|
Inspect package contents on Unix-like systems with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unzip -l dist/<id>-<version>.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
An add-on extension ZIP should normally contain `blender_manifest.toml`,
|
||||||
|
`__init__.py`, its internal modules/assets, and declared wheels—nothing from
|
||||||
|
other extensions or repository-level tests.
|
||||||
|
|
||||||
|
## Headless installation scopes
|
||||||
|
|
||||||
|
For the current Blender user, the supported CLI operation is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender --factory-startup --command extension install-file \
|
||||||
|
-r user_default -e dist/<id>-<version>.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
`-e` enables the extension and updates that user's preferences.
|
||||||
|
|
||||||
|
For a machine-wide deployment, extract packages into the read-only system
|
||||||
|
repository layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$BLENDER_SYSTEM_EXTENSIONS/
|
||||||
|
system/
|
||||||
|
<id>/
|
||||||
|
blender_manifest.toml
|
||||||
|
__init__.py
|
||||||
|
```
|
||||||
|
|
||||||
|
System-repository availability is separate from per-user enablement. Managed
|
||||||
|
deployments can use a startup script under `BLENDER_SYSTEM_SCRIPTS/startup/` to
|
||||||
|
enable required packages. See Blender's
|
||||||
|
[production deployment guide](https://docs.blender.org/manual/en/dev/advanced/deploying_blender.html).
|
||||||
|
|
||||||
|
## Official references
|
||||||
|
|
||||||
|
- [Creating extensions](https://docs.blender.org/manual/en/dev/advanced/extensions/getting_started.html)
|
||||||
|
- [Extension CLI](https://docs.blender.org/manual/en/dev/advanced/command_line/extension_arguments.html)
|
||||||
|
- [Production/system extension deployment](https://docs.blender.org/manual/en/dev/advanced/deploying_blender.html)
|
||||||
|
- [Python wheels](https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html)
|
||||||
Loading…
Reference in New Issue