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