diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4705143 --- /dev/null +++ b/AGENTS.md @@ -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//`: 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//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/ +./build-all.sh +blender --factory-startup --command extension validate dist/-.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`. + diff --git a/README.md b/README.md index 430b840..75af3b8 100644 --- a/README.md +++ b/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. diff --git a/blender/material_id_baker/__init__.py b/blender/material_id_baker/__init__.py index 6c1d444..2ec22b2 100644 --- a/blender/material_id_baker/__init__.py +++ b/blender/material_id_baker/__init__.py @@ -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") diff --git a/blender/material_id_baker/blender_manifest.toml b/blender/material_id_baker/blender_manifest.toml index 5ed19b0..dbdbd3c 100644 --- a/blender/material_id_baker/blender_manifest.toml +++ b/blender/material_id_baker/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "material_id_baker" -version = "1.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" diff --git a/blender/tests/smoke_test.py b/blender/tests/smoke_test.py index 97495f4..b707785 100644 --- a/blender/tests/smoke_test.py +++ b/blender/tests/smoke_test.py @@ -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("-.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/-.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/ + / + 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)