updated blender scripts for glb export

This commit is contained in:
Peterino2 2026-07-10 23:24:52 -07:00
parent ef7fea6252
commit dc331b5d1c
9 changed files with 874 additions and 10 deletions

99
AGENTS.md Normal file
View File

@ -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`.

View File

@ -2,11 +2,20 @@
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
AGENTS.md Contributor rules and documentation map
blender/
material_id_baker/ Blender 5.x extension source
scripts/ Build, install/update, and test helpers
tests/ Headless Blender smoke test
docs/ Extension authoring and API references
tools/
combine_substance_textures.py
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.
Features include 256px8K output, configurable island margin, optional evaluated modifiers, and three palette modes: deterministic distinct colors, material viewport colors, and exact slot-index encoding. It can write a PNG plus a JSON legend used by the standalone merger. 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 256px8K output, configurable island margin, optional evaluated modifiers, and three palette modes: deterministic distinct colors, material viewport colors, and exact slot-index encoding. It can write a PNG plus a JSON legend used by the standalone merger. 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
@ -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.
4. Select the resolution, margin, and color mode.
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.

View File

@ -108,7 +108,7 @@ def _create_export_copy(
baked_mesh: bpy.types.Mesh,
image: bpy.types.Image,
apply_modifiers: bool,
scene: bpy.types.Scene,
target_collection: bpy.types.Collection,
) -> tuple[bpy.types.Object, bpy.types.Mesh, bpy.types.Material]:
"""Create a persistent one-material copy suitable for later export."""
if apply_modifiers:
@ -124,10 +124,10 @@ def _create_export_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)
export_object.hide_set(False)
export_object.hide_viewport = False
export_object.hide_render = False
material = _create_baked_ids_material(image)
export_mesh.materials.clear()
@ -145,6 +145,41 @@ def _normalise_png_path(filepath: str) -> 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]:
bake = scene.render.bake
return {
@ -153,6 +188,7 @@ def _snapshot_bake_settings(scene: bpy.types.Scene) -> dict[str, Any]:
"target": bake.target,
"use_clear": bake.use_clear,
"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.use_clear = state["use_clear"]
bake.use_selected_to_active = state["use_selected_to_active"]
scene.cycles.samples = state["cycles_samples"]
class MIDB_PaletteEntry(PropertyGroup):
@ -231,6 +268,17 @@ class MIDB_Settings(PropertyGroup):
),
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(
name="Image Name",
default="Material_ID",
@ -388,6 +436,7 @@ class MIDB_OT_Bake(Operator):
# Cycles owns Blender's image-bake pipeline even though this flat
# emission bake needs only one deterministic sample.
scene.render.engine = "CYCLES"
scene.cycles.samples = 1
scene.render.bake.margin = settings.margin
scene.render.bake.target = "IMAGE_TEXTURES"
scene.render.bake.use_clear = True
@ -448,7 +497,15 @@ class MIDB_OT_Bake(Operator):
temp_mesh,
image,
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
@ -458,6 +515,8 @@ class MIDB_OT_Bake(Operator):
message = f"Baked material IDs to image '{image.name}'"
if export_object is not None:
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)
return {"FINISHED"}
@ -531,7 +590,14 @@ 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")
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.label(text="Output")

View File

@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "material_id_baker"
version = "1.1.0"
version = "1.2.0"
name = "Material ID Baker"
tagline = "Bake mesh material assignments to a color ID texture"
maintainer = "Sear"
@ -14,4 +14,4 @@ license = [
]
[permissions]
files = "Save baked ID textures and JSON palette legends"
files = "Save baked textures, JSON legends, and GLB export copies"

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import json
import struct
import sys
from pathlib import Path
@ -73,6 +74,8 @@ def main() -> None:
material_id_baker.register()
source = make_test_object()
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.resolution = "256"
@ -80,6 +83,8 @@ def main() -> None:
settings.palette_mode = "DISTINCT"
settings.apply_modifiers = 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.save_to_disk = True
settings.filepath = "/tmp/material_id_baker_smoke.png"
@ -88,6 +93,7 @@ def main() -> None:
result = bpy.ops.object.bake_material_id()
assert result == {"FINISHED"}, result
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.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.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")

15
docs/README.md Normal file
View File

@ -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).

170
docs/blender-api-notes.md Normal file
View File

@ -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)

View File

@ -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/)

View File

@ -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)