2026-02-16

This commit is contained in:
2026-03-17 15:25:32 -06:00
parent d5dd373de0
commit 60100fbab2
560 changed files with 33397 additions and 20776 deletions
@@ -7,7 +7,7 @@
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.1",
"version": "2.6.3",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
@@ -24,16 +24,16 @@
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.1/BasedPlayblast.v2.6.1.zip",
"archive_size": 48471,
"archive_hash": "sha256:ce9740ad252a00643f75202b53c9ef1e9c6ee8b5a2d34cbaf751b4084e78665c"
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.3/BasedPlayblast.v2.6.3.zip",
"archive_size": 49732,
"archive_hash": "sha256:078b406105ce6f4802e75233569841e2f73d082e09cd1d954696681ebf72b627"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.14.0",
"version": "0.16.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
@@ -49,16 +49,16 @@
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.14.0/Rainys_Bulk_Scene_Tools.v0.14.0.zip",
"archive_size": 78363,
"archive_hash": "sha256:943c723511fb8d7199bf079cb94ba63c552d6477b9a4e003bfffc185c169ea4b"
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.16.0/Rainys_Bulk_Scene_Tools.v0.16.0.zip",
"archive_size": 80251,
"archive_hash": "sha256:3e6fafe11caa39e48b94288c12b2a88e521c928955a854ffdd1bd0936e6bc70a"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.1.0",
"version": "2.5.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
@@ -70,9 +70,31 @@
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.1.0/Atomic_Data_Manager.v2.1.0.zip",
"archive_size": 73646,
"archive_hash": "sha256:a10f6b7eb9d7c437574c66dc15f73d74a0ff86e793c7460804d7bf5cb7cb29cc"
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.5.0/Atomic_Data_Manager.v2.5.0.zip",
"archive_size": 114674,
"archive_hash": "sha256:4b4834ed3910a428d4cb01f1891247ad80089b6c5324fc27c6862b09e81ff1c1"
},
{
"schema_version": "1.0.0",
"id": "sheepit_project_submitter",
"name": "SheepIt Project Submitter",
"tagline": "Submit projects to SheepIt render farm",
"version": "0.0.7",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "3.0.0",
"tags": [
"render",
"farm",
"submission",
"utility"
],
"archive_url": "https://github.com/RaincloudTheDragon/sheepit_project_submitter/releases/download/v0.0.7/SheepIt_Project_Submitter.v0.0.7.zip",
"archive_size": 47250,
"archive_hash": "sha256:cb8dee48c45cc51dd8237981f4ab96d97d476b547c8c640606e9bbfd0390a055"
}
]
}
@@ -0,0 +1,100 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.3",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.3/BasedPlayblast.v2.6.3.zip",
"archive_size": 49732,
"archive_hash": "sha256:078b406105ce6f4802e75233569841e2f73d082e09cd1d954696681ebf72b627"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.16.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.16.0/Rainys_Bulk_Scene_Tools.v0.16.0.zip",
"archive_size": 80251,
"archive_hash": "sha256:3e6fafe11caa39e48b94288c12b2a88e521c928955a854ffdd1bd0936e6bc70a"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.5.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.5.0/Atomic_Data_Manager.v2.5.0.zip",
"archive_size": 114674,
"archive_hash": "sha256:4b4834ed3910a428d4cb01f1891247ad80089b6c5324fc27c6862b09e81ff1c1"
},
{
"schema_version": "1.0.0",
"id": "sheepit_project_submitter",
"name": "SheepIt Project Submitter",
"tagline": "Submit projects to SheepIt render farm",
"version": "0.0.6",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "3.0.0",
"tags": [
"render",
"farm",
"submission",
"utility"
],
"archive_url": "https://github.com/RaincloudTheDragon/sheepit_project_submitter/releases/download/v0.0.6/SheepIt_Project_Submitter.v0.0.6.zip",
"archive_size": 46535,
"archive_hash": "sha256:c465fe190fc2e487f9a0bd7e335d7fbaa8da1000bfbedb785dc821775fbc82a5"
}
]
}
@@ -0,0 +1,78 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.2",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.2/BasedPlayblast.v2.6.2.zip",
"archive_size": 48968,
"archive_hash": "sha256:c359a24fccb10b9d8df2941b0d75435eb0f7ac89db61836edb6d993b86354952"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.15.1",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.15.1/Rainys_Bulk_Scene_Tools.v0.15.1.zip",
"archive_size": 81044,
"archive_hash": "sha256:a72f7dbf7c35fda94a7b67df79ef131391e0fe2ac4f416703b07ef59afd7235b"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.4.1",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.4.1/Atomic_Data_Manager.v2.4.1.zip",
"archive_size": 108842,
"archive_hash": "sha256:4086ada3e9e8c852fd02d455f11f2f20fd19ca68acd10b101ab3aa0fae2be210"
}
]
}
@@ -0,0 +1,100 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.3",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.3/BasedPlayblast.v2.6.3.zip",
"archive_size": 49732,
"archive_hash": "sha256:078b406105ce6f4802e75233569841e2f73d082e09cd1d954696681ebf72b627"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.16.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.16.0/Rainys_Bulk_Scene_Tools.v0.16.0.zip",
"archive_size": 80251,
"archive_hash": "sha256:3e6fafe11caa39e48b94288c12b2a88e521c928955a854ffdd1bd0936e6bc70a"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.5.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.5.0/Atomic_Data_Manager.v2.5.0.zip",
"archive_size": 114674,
"archive_hash": "sha256:4b4834ed3910a428d4cb01f1891247ad80089b6c5324fc27c6862b09e81ff1c1"
},
{
"schema_version": "1.0.0",
"id": "sheepit_project_submitter",
"name": "SheepIt Project Submitter",
"tagline": "Submit projects to SheepIt render farm",
"version": "0.0.6",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "3.0.0",
"tags": [
"render",
"farm",
"submission",
"utility"
],
"archive_url": "https://github.com/RaincloudTheDragon/sheepit_project_submitter/releases/download/v0.0.6/SheepIt_Project_Submitter.v0.0.6.zip",
"archive_size": 46535,
"archive_hash": "sha256:c465fe190fc2e487f9a0bd7e335d7fbaa8da1000bfbedb785dc821775fbc82a5"
}
]
}
@@ -0,0 +1,78 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.2",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.2/BasedPlayblast.v2.6.2.zip",
"archive_size": 48968,
"archive_hash": "sha256:c359a24fccb10b9d8df2941b0d75435eb0f7ac89db61836edb6d993b86354952"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.15.1",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.15.1/Rainys_Bulk_Scene_Tools.v0.15.1.zip",
"archive_size": 81044,
"archive_hash": "sha256:a72f7dbf7c35fda94a7b67df79ef131391e0fe2ac4f416703b07ef59afd7235b"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.4.1",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.4.1/Atomic_Data_Manager.v2.4.1.zip",
"archive_size": 108842,
"archive_hash": "sha256:4086ada3e9e8c852fd02d455f11f2f20fd19ca68acd10b101ab3aa0fae2be210"
}
]
}
@@ -0,0 +1,78 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.2",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.2/BasedPlayblast.v2.6.2.zip",
"archive_size": 48968,
"archive_hash": "sha256:c359a24fccb10b9d8df2941b0d75435eb0f7ac89db61836edb6d993b86354952"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.14.1",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.14.1/Rainys_Bulk_Scene_Tools.v0.14.1.zip",
"archive_size": 79521,
"archive_hash": "sha256:0ae09f57cf81a971406f05f50dc0d9a25c8dfbbedfabb1a9d72655194c1a9250"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.3.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.3.0/Atomic_Data_Manager.v2.3.0.zip",
"archive_size": 92609,
"archive_hash": "sha256:be0304820428e461c3ecda4ab652d5c84d3df9c0548292870350ca86a9ba828c"
}
]
}
@@ -0,0 +1,78 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.2",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.2/BasedPlayblast.v2.6.2.zip",
"archive_size": 48968,
"archive_hash": "sha256:c359a24fccb10b9d8df2941b0d75435eb0f7ac89db61836edb6d993b86354952"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.15.1",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.15.1/Rainys_Bulk_Scene_Tools.v0.15.1.zip",
"archive_size": 81044,
"archive_hash": "sha256:a72f7dbf7c35fda94a7b67df79ef131391e0fe2ac4f416703b07ef59afd7235b"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.4.1",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.4.1/Atomic_Data_Manager.v2.4.1.zip",
"archive_size": 108842,
"archive_hash": "sha256:4086ada3e9e8c852fd02d455f11f2f20fd19ca68acd10b101ab3aa0fae2be210"
}
]
}
@@ -1,3 +1,102 @@
## [v2.5.0] - 2026-01-28
### Features
- Missing file tools: add “Relink All” and improve replacement workflow.
### Fixes
- Missing file UI: fix text field paste + layout/truncation issues; center the detect-missing popup; refine replacement path handling (better dir vs file behavior).
- RNA analysis: expand datablock coverage and refine dependency tracking to reduce false “unused” results.
### Internal
- Maintenance: remove deprecated recovery option; improve ignore rules for hidden dot-directories.
## [v2.4.1] - 2026-01-14
### Fixes
- Fixed RNA analysis crashes when opening new blend files by rebuilding data-block type references dynamically
- Fixed indentation errors that prevented RNA dump from processing most data-blocks
- Fixed compositing nodetree detection by adding scenes as root nodes in dependency graph
- RNA dump JSON file now always generated regardless of debug print settings
- Refactored repetitive snapshotting code into `_safe_snapshot()` helper function
## [v2.4.0] - 2026-01-13
### Features
- **Major Architecture Change: RNA-Based Analysis System**
- Replaced multi-process worker system with faster, more robust RNA-based dependency analysis
- All data types now use unified RNA introspection for dependency tracking
- Eliminated worker processes, job indexing, and subprocess overhead
- RNA data can be optionally dumped to JSON for debugging
- Improved Clean dialog layout
- Increased dialog width to 1000px for better visibility
- Items now display in 4-column grid layout to reduce vertical scrolling
### Fixes
- Fixed node groups used by objects via Geometry Nodes modifiers not being detected as used
- Fixed RigidBodyWorld and other scene-linked data-blocks incorrectly flagged as cleanable
- Fixed area lights and other object data-blocks in scene collections not being marked as used
- Added safety checks to prevent crashes during RNA extraction (recursion limits, data-block validation)
- Fixed RNA extraction handling for objects' modifier node groups
### Performance
- Significantly faster scanning across all categories using RNA analysis
- Single-pass dependency graph building shared across all category scans
## [v2.3.1] - 2026-01-13
### Fixes
- Integrate proper UDIM detection
## [v2.3.0] - 2026-01-06
### Features
- Added "Enable Debug Prints" preference to control debug console output
- Debug messages now only print when this preference is enabled (default: off)
- All debug print statements use centralized `config.debug_print()` helper
### Fixes
- Fixed preferences not displaying in Blender 5.0 extensions
- Preferences now correctly match the full module path (`bl_ext.vscode_development.atomic_data_manager`)
- Added safe property setter to handle read-only context errors during file loading
- Fixed node groups used only by unused materials/objects not being detected as unused (#5)
- Node groups now recursively check if parent node groups are unused
- Fixed compositor node tree detection to use reference comparison instead of name
- Fixed missing import error in node_group_compositors()
- Made Clean execute deletion synchronous for faster performance
- Fixed callback-initiated scan state not being preserved, causing scans to fail
- Fixed instanced collection usage detection
## [v2.2.0] - 2026-01-05
### Features
- Add loading bars; non-blocking timer-based UI (#10)
- Operations no longer freeze the UI during scanning
- Real-time progress updates with cancel support at any time
- Descriptive status messages showing current operation details
- Unified Smart Select and Clean scanning logic
- Eliminated code duplication between operations
- Clean now only scans selected categories (more efficient)
- Both operations use consistent incremental scanning for images and worlds
- Added manual cache clear operator for testing and debugging
### Performance
- Optimized deep scan functions with caching and fast-path checks
- Image scanning now uses cached results to avoid redundant scene scans
- Early exit for clearly unused images using Blender's built-in user count
- Incremental processing for large datasets
- Images processed in batches (5 per callback) to maintain UI responsiveness
- Worlds processed one at a time incrementally
### Fixes
- Fixed images used only by unused objects being incorrectly flagged as unused (#5)
- Fixed material detection in brushes and node groups (#6, #7)
- Fixed Clean operator not showing dialog when invoked programmatically (#8)
- Improved material detection in inspection tools (brushes, node groups)
### Internal
- Refactored scanning architecture for maintainability
- Added comprehensive debug output for troubleshooting
## [v2.1.0] - 2025-12-18
### Features
@@ -224,6 +224,23 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
# search field for the inspect replace operator
replace_field: bpy.props.StringProperty()
# progress tracking properties for timer-based operations
is_operation_running: bpy.props.BoolProperty(default=False)
operation_progress: bpy.props.FloatProperty(
default=0.0,
min=0.0,
max=100.0,
subtype='PERCENTAGE' # This makes it display as percentage
)
operation_status: bpy.props.StringProperty(default="")
cancel_operation: bpy.props.BoolProperty(default=False)
def _on_undo_pre(scene):
"""Handler called before undo - invalidate cache."""
from .ops import main_ops
main_ops._invalidate_cache()
def register():
register_class(ATOMIC_PG_main)
@@ -233,6 +250,9 @@ def register():
ui.register()
ops.register()
# Register undo handler to invalidate cache
bpy.app.handlers.undo_pre.append(_on_undo_pre)
# bootstrap Rainy's Extensions repository
rainys_repo_bootstrap.register()
@@ -241,6 +261,10 @@ def unregister():
# bootstrap unregistration
rainys_repo_bootstrap.unregister()
# Remove undo handler
if _on_undo_pre in bpy.app.handlers.undo_pre:
bpy.app.handlers.undo_pre.remove(_on_undo_pre)
# atomic package unregistration
ui.unregister()
ops.unregister()
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
id = "atomic_data_manager"
name = "Atomic Data Manager"
version = "2.1.0"
version = "2.5.0"
type = "add-on"
author = "RaincloudTheDragon"
maintainer = "RaincloudTheDragon"
@@ -32,6 +32,7 @@ Blender, not in here.
enable_missing_file_warning = True
include_fake_users = False
enable_pie_menu_ui = True
enable_debug_prints = False
# hidden atomic preferences
pie_menu_type = "D"
@@ -39,4 +40,13 @@ pie_menu_alt = False
pie_menu_any = False
pie_menu_ctrl = False
pie_menu_oskey = False
pie_menu_shift = False
pie_menu_shift = False
def debug_print(*args, **kwargs):
"""
Print debug messages only if enable_debug_prints is True.
Usage: debug_print("message") or debug_print(f"formatted {value}")
"""
if enable_debug_prints:
print(*args, **kwargs)
@@ -30,6 +30,9 @@ from ..utils import compat
from .utils import delete
from .utils import duplicate
# Module-level state for inspection delete
_inspect_delete_state = None
def _check_library_or_override(datablock):
"""Check if datablock is library-linked or override, return error message if so."""
@@ -488,115 +491,181 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
bl_label = "Delete Data-Block"
def execute(self, context):
atom = bpy.context.scene.atomic
atom = context.scene.atomic
inspection = atom.active_inspection
if inspection == 'COLLECTIONS':
key = atom.collections_field
collections = bpy.data.collections
if key in collections.keys():
collection = collections[key]
error = _check_library_or_override(collection)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
delete.collection(key)
atom.collections_field = ""
elif inspection == 'IMAGES':
key = atom.images_field
images = bpy.data.images
if key in images.keys():
image = images[key]
error = _check_library_or_override(image)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
delete.image(key)
atom.images_field = ""
elif inspection == 'LIGHTS':
key = atom.lights_field
lights = bpy.data.lights
if key in lights.keys():
light = lights[key]
error = _check_library_or_override(light)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
delete.light(key)
atom.lights_field = ""
elif inspection == 'MATERIALS':
key = atom.materials_field
materials = bpy.data.materials
if key in materials.keys():
material = materials[key]
error = _check_library_or_override(material)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
delete.material(key)
atom.materials_field = ""
elif inspection == 'NODE_GROUPS':
key = atom.node_groups_field
node_groups = bpy.data.node_groups
if key in node_groups.keys():
node_group = node_groups[key]
error = _check_library_or_override(node_group)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
delete.node_group(key)
atom.node_groups_field = ""
elif inspection == 'PARTICLES':
key = atom.particles_field
particles = bpy.data.particles
if key in particles.keys():
particle = particles[key]
error = _check_library_or_override(particle)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
delete.particle(key)
atom.particles_field = ""
elif inspection == 'TEXTURES':
key = atom.textures_field
textures = bpy.data.textures
if key in textures.keys():
texture = textures[key]
error = _check_library_or_override(texture)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
delete.texture(key)
atom.textures_field = ""
elif inspection == 'WORLDS':
key = atom.worlds_field
worlds = bpy.data.worlds
if key in worlds.keys():
world = worlds[key]
error = _check_library_or_override(world)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
delete.world(key)
atom.worlds_field = ""
# Initialize progress tracking
atom.is_operation_running = True
atom.operation_progress = 0.0
atom.operation_status = f"Deleting {inspection.lower()}..."
atom.cancel_operation = False
# Store state in module-level variable for timer processing
global _inspect_delete_state
_inspect_delete_state = {
'inspection': inspection
}
# Start timer for processing (even though it's quick, keep UI responsive)
bpy.app.timers.register(_process_inspect_delete_step)
return {'FINISHED'}
def _process_inspect_delete_step():
"""Process inspection delete in steps to avoid blocking the UI"""
atom = bpy.context.scene.atomic
global _inspect_delete_state
if _inspect_delete_state is None:
return None
inspection = _inspect_delete_state['inspection']
# Check for cancellation
if atom.cancel_operation:
atom.is_operation_running = False
atom.operation_progress = 0.0
atom.operation_status = "Operation cancelled"
atom.cancel_operation = False
_inspect_delete_state = None
# Force UI update
for area in bpy.context.screen.areas:
area.tag_redraw()
return None
atom.operation_progress = 50.0
# Perform deletion
try:
if inspection == 'COLLECTIONS':
key = atom.collections_field
collections = bpy.data.collections
if key in collections.keys():
collection = collections[key]
error = _check_library_or_override(collection)
if error:
atom.is_operation_running = False
atom.operation_status = ""
return None
delete.collection(key)
atom.collections_field = ""
elif inspection == 'IMAGES':
key = atom.images_field
images = bpy.data.images
if key in images.keys():
image = images[key]
error = _check_library_or_override(image)
if error:
atom.is_operation_running = False
atom.operation_status = ""
return None
delete.image(key)
atom.images_field = ""
elif inspection == 'LIGHTS':
key = atom.lights_field
lights = bpy.data.lights
if key in lights.keys():
light = lights[key]
error = _check_library_or_override(light)
if error:
atom.is_operation_running = False
atom.operation_status = ""
return None
delete.light(key)
atom.lights_field = ""
elif inspection == 'MATERIALS':
key = atom.materials_field
materials = bpy.data.materials
if key in materials.keys():
material = materials[key]
error = _check_library_or_override(material)
if error:
atom.is_operation_running = False
atom.operation_status = ""
return None
delete.material(key)
atom.materials_field = ""
elif inspection == 'NODE_GROUPS':
key = atom.node_groups_field
node_groups = bpy.data.node_groups
if key in node_groups.keys():
node_group = node_groups[key]
error = _check_library_or_override(node_group)
if error:
atom.is_operation_running = False
atom.operation_status = ""
return None
delete.node_group(key)
atom.node_groups_field = ""
elif inspection == 'PARTICLES':
key = atom.particles_field
particles = bpy.data.particles
if key in particles.keys():
particle = particles[key]
error = _check_library_or_override(particle)
if error:
atom.is_operation_running = False
atom.operation_status = ""
return None
delete.particle(key)
atom.particles_field = ""
elif inspection == 'TEXTURES':
key = atom.textures_field
textures = bpy.data.textures
if key in textures.keys():
texture = textures[key]
error = _check_library_or_override(texture)
if error:
atom.is_operation_running = False
atom.operation_status = ""
return None
delete.texture(key)
atom.textures_field = ""
elif inspection == 'WORLDS':
key = atom.worlds_field
worlds = bpy.data.worlds
if key in worlds.keys():
world = worlds[key]
error = _check_library_or_override(world)
if error:
atom.is_operation_running = False
atom.operation_status = ""
return None
delete.world(key)
atom.worlds_field = ""
except:
pass # Handle any errors gracefully
# Operation complete
atom.is_operation_running = False
atom.operation_progress = 100.0
atom.operation_status = ""
# Clear state
_inspect_delete_state = None
# Force UI update
for area in bpy.context.screen.areas:
area.tag_redraw()
return None # Stop timer
reg_list = [
ATOMIC_OT_inspection_rename,
ATOMIC_OT_inspection_replace,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -26,61 +26,131 @@ import bpy
from ...stats import unused
def collections():
def collections(cached_list=None):
# removes all unused collections from the project
for collection_key in unused.collections_deep():
bpy.data.collections.remove(bpy.data.collections[collection_key])
# If cached_list is provided, use it instead of recalculating
if cached_list is not None:
collection_keys = cached_list
else:
collection_keys = unused.collections_deep()
for collection_key in collection_keys:
if collection_key in bpy.data.collections:
bpy.data.collections.remove(bpy.data.collections[collection_key])
def images():
def images(cached_list=None):
# removes all unused images from the project
for image_key in unused.images_deep():
bpy.data.images.remove(bpy.data.images[image_key])
# If cached_list is provided, use it instead of recalculating
if cached_list is not None:
image_keys = cached_list
else:
image_keys = unused.images_deep()
for image_key in image_keys:
if image_key in bpy.data.images:
bpy.data.images.remove(bpy.data.images[image_key])
def lights():
def lights(cached_list=None):
# removes all unused lights from the project
for light_key in unused.lights_deep():
bpy.data.lights.remove(bpy.data.lights[light_key])
# If cached_list is provided, use it instead of recalculating
if cached_list is not None:
light_keys = cached_list
else:
light_keys = unused.lights_deep()
for light_key in light_keys:
if light_key in bpy.data.lights:
bpy.data.lights.remove(bpy.data.lights[light_key])
def materials():
def materials(cached_list=None):
# removes all unused materials from the project
for light_key in unused.materials_deep():
bpy.data.materials.remove(bpy.data.materials[light_key])
# If cached_list is provided, use it instead of recalculating
if cached_list is not None:
material_keys = cached_list
else:
material_keys = unused.materials_deep()
for material_key in material_keys:
if material_key in bpy.data.materials:
bpy.data.materials.remove(bpy.data.materials[material_key])
def node_groups():
def node_groups(cached_list=None):
# removes all unused node groups from the project
for node_group_key in unused.node_groups_deep():
bpy.data.node_groups.remove(bpy.data.node_groups[node_group_key])
# If cached_list is provided, use it instead of recalculating
if cached_list is not None:
node_group_keys = cached_list
else:
node_group_keys = unused.node_groups_deep()
for node_group_key in node_group_keys:
if node_group_key in bpy.data.node_groups:
bpy.data.node_groups.remove(bpy.data.node_groups[node_group_key])
def particles():
def particles(cached_list=None):
# removes all unused particle systems from the project
for particle_key in unused.particles_deep():
bpy.data.particles.remove(bpy.data.particles[particle_key])
# If cached_list is provided, use it instead of recalculating
if cached_list is not None:
particle_keys = cached_list
else:
particle_keys = unused.particles_deep()
for particle_key in particle_keys:
if particle_key in bpy.data.particles:
bpy.data.particles.remove(bpy.data.particles[particle_key])
def textures():
def textures(cached_list=None):
# removes all unused textures from the project
for texture_key in unused.textures_deep():
bpy.data.textures.remove(bpy.data.textures[texture_key])
# If cached_list is provided, use it instead of recalculating
if cached_list is not None:
texture_keys = cached_list
else:
texture_keys = unused.textures_deep()
for texture_key in texture_keys:
if texture_key in bpy.data.textures:
bpy.data.textures.remove(bpy.data.textures[texture_key])
def worlds():
def worlds(cached_list=None):
# removes all unused worlds from the project
for world_key in unused.worlds():
bpy.data.worlds.remove(bpy.data.worlds[world_key])
# If cached_list is provided, use it instead of recalculating
if cached_list is not None:
world_keys = cached_list
else:
world_keys = unused.worlds()
for world_key in world_keys:
if world_key in bpy.data.worlds:
bpy.data.worlds.remove(bpy.data.worlds[world_key])
def objects():
def objects(cached_list=None):
# removes all unused objects from the project
for object_key in unused.objects_deep():
bpy.data.objects.remove(bpy.data.objects[object_key])
# If cached_list is provided, use it instead of recalculating
if cached_list is not None:
object_keys = cached_list
else:
object_keys = unused.objects_deep()
for object_key in object_keys:
if object_key in bpy.data.objects:
bpy.data.objects.remove(bpy.data.objects[object_key])
def armatures():
def armatures(cached_list=None):
# removes all unused armatures from the project
for armature_key in unused.armatures_deep():
bpy.data.armatures.remove(bpy.data.armatures[armature_key])
# If cached_list is provided, use it instead of recalculating
if cached_list is not None:
armature_keys = cached_list
else:
armature_keys = unused.armatures_deep()
for armature_key in armature_keys:
if armature_key in bpy.data.armatures:
bpy.data.armatures.remove(bpy.data.armatures[armature_key])
@@ -59,8 +59,23 @@ def get_missing(data):
# Blender 4.2/4.5: Both use 'packed_file' (singular)
is_packed = bool(datablock.packed_file) if hasattr(datablock, 'packed_file') else False
# Check if file exists (with special handling for UDIM images)
file_exists = False
if abspath and isinstance(datablock, bpy.types.Image) and '<UDIM>' in abspath:
# UDIM image: check if any UDIM tile files exist
# UDIM tiles are numbered 1001, 1002, etc. (standard range is 1001-1099)
# Check a reasonable range of UDIM tiles
for udim_tile in range(1001, 1100): # Check tiles 1001-1099
udim_path = abspath.replace('<UDIM>', str(udim_tile))
if os.path.isfile(udim_path):
file_exists = True
break
elif abspath:
# Regular file: check if it exists
file_exists = os.path.isfile(abspath)
# if data-block is not packed and has an invalid filepath
if not is_packed and not os.path.isfile(abspath):
if not is_packed and not file_exists:
# if data-block is not in our do not flag list
# append it to the missing data list
@@ -86,3 +101,54 @@ def images():
def libraries():
# returns a list of keys of libraries with a non-existent filepath
return get_missing(bpy.data.libraries)
def get_missing_library_info(library_key):
"""
Get information about a missing library for matching and validation.
Returns:
dict with keys:
- 'filepath': original filepath
- 'filename': basename for matching
- 'linked_data_blocks': list of data-block names linked from this library
"""
if library_key not in bpy.data.libraries:
return None
library = bpy.data.libraries[library_key]
filepath = library.filepath
filename = os.path.basename(bpy.path.abspath(filepath)) if filepath else ""
# Get linked data-block names (collections, objects, materials, etc.)
linked_data_blocks = []
try:
# Collections
for collection in bpy.data.collections:
if collection.library == library:
linked_data_blocks.append(('COLLECTION', collection.name))
# Objects
for obj in bpy.data.objects:
if obj.library == library:
linked_data_blocks.append(('OBJECT', obj.name))
# Materials
for material in bpy.data.materials:
if material.library == library:
linked_data_blocks.append(('MATERIAL', material.name))
# Meshes
for mesh in bpy.data.meshes:
if mesh.library == library:
linked_data_blocks.append(('MESH', mesh.name))
# Armatures
for armature in bpy.data.armatures:
if armature.library == library:
linked_data_blocks.append(('ARMATURE', armature.name))
except Exception:
# If we can't access library data, return what we have
pass
return {
'filepath': filepath,
'filename': filename,
'linked_data_blocks': linked_data_blocks
}
File diff suppressed because it is too large Load Diff
@@ -26,6 +26,7 @@ as determined by stats.users.py
import bpy
from .. import config
from ..utils import compat
from ..utils import version
from . import users
@@ -90,20 +91,72 @@ def images_deep():
# this list also exists in images_shallow()
do_not_flag = ["Render Result", "Viewer Node", "D-NOISE Export"]
total_images = len(bpy.data.images)
config.debug_print(f"[Atomic Debug] images_deep(): Starting, total images: {total_images}")
checked = 0
for image in bpy.data.images:
# Skip library-linked and override datablocks
if compat.is_library_or_override(image):
continue
checked += 1
config.debug_print(f"[Atomic Debug] images_deep(): Checking image {checked}/{total_images}: '{image.name}'")
# First check: standard unused detection
config.debug_print(f"[Atomic Debug] images_deep(): Calling users.image_all('{image.name}')...")
if not users.image_all(image.name):
config.debug_print(f"[Atomic Debug] images_deep(): Image '{image.name}' is unused (first check)")
# check if image has a fake user or if ignore fake users
# is enabled
if not image.use_fake_user or config.include_fake_users:
# if image is not in our do not flag list
if image.name not in do_not_flag:
unused.append(image.name)
else:
# Second check: image is used, but check if it's ONLY used by unused objects
# This fixes issue #5: images used by unused objects should be marked as unused
# Get all objects that use this image (directly or indirectly)
objects_using_image = []
# Check materials that use the image
config.debug_print(f"[Atomic Debug] images_deep(): Getting materials for '{image.name}'...")
mat_names = users.image_materials(image.name)
config.debug_print(f"[Atomic Debug] images_deep(): Found {len(mat_names)} materials using '{image.name}'")
for mat_name in mat_names:
# Get objects using this material
config.debug_print(f"[Atomic Debug] images_deep(): Getting objects for material '{mat_name}'...")
objects_using_image.extend(users.material_objects(mat_name))
# Also check Geometry Nodes usage
config.debug_print(f"[Atomic Debug] images_deep(): Getting Geometry Nodes objects for material '{mat_name}'...")
objects_using_image.extend(users.material_geometry_nodes(mat_name))
# Check Geometry Nodes directly
config.debug_print(f"[Atomic Debug] images_deep(): Getting Geometry Nodes objects for '{image.name}'...")
objects_using_image.extend(users.image_geometry_nodes(image.name))
# Remove duplicates
objects_using_image = list(set(objects_using_image))
config.debug_print(f"[Atomic Debug] images_deep(): Found {len(objects_using_image)} objects using '{image.name}'")
# If image is only used by objects, and ALL those objects are unused, mark image as unused
# Check each object individually to avoid recursion issues
if objects_using_image:
config.debug_print(f"[Atomic Debug] images_deep(): Checking if all {len(objects_using_image)} objects are unused...")
all_objects_unused = all(not users.object_all(obj_name) for obj_name in objects_using_image)
if all_objects_unused:
config.debug_print(f"[Atomic Debug] images_deep(): All objects are unused, marking '{image.name}' as unused")
# Check if image has a fake user or if ignore fake users is enabled
if not image.use_fake_user or config.include_fake_users:
# if image is not in our do not flag list
if image.name not in do_not_flag:
unused.append(image.name)
config.debug_print(f"[Atomic Debug] images_deep(): Added '{image.name}' to unused list")
else:
config.debug_print(f"[Atomic Debug] images_deep(): Some objects are used, '{image.name}' is not unused")
config.debug_print(f"[Atomic Debug] images_deep(): Finished checking '{image.name}'")
config.debug_print(f"[Atomic Debug] images_deep(): Complete, checked {checked} images, found {len(unused)} unused")
return unused
@@ -160,12 +213,36 @@ def materials_deep():
# Skip library-linked and override datablocks
if compat.is_library_or_override(material):
continue
# Check if material is used by brushes - these should always be ignored
if users.material_brushes(material.name):
continue
# First check: standard unused detection
if not users.material_all(material.name):
# check if material has a fake user or if ignore fake users
# is enabled
if not material.use_fake_user or config.include_fake_users:
unused.append(material.name)
else:
# Second check: material is used, but check if it's ONLY used by unused objects
# This fixes issue #5: materials used by unused objects should be marked as unused
# Get all objects that use this material
objects_using_material = []
objects_using_material.extend(users.material_objects(material.name))
objects_using_material.extend(users.material_geometry_nodes(material.name))
# Remove duplicates
objects_using_material = list(set(objects_using_material))
# If material is only used by objects, and ALL those objects are unused, mark material as unused
# Check each object individually to avoid recursion issues
if objects_using_material:
all_objects_unused = all(not users.object_all(obj_name) for obj_name in objects_using_material)
if all_objects_unused:
# Check if material has a fake user or if ignore fake users is enabled
if not material.use_fake_user or config.include_fake_users:
unused.append(material.name)
return unused
@@ -174,24 +251,147 @@ def materials_shallow():
# returns a list of keys of unused material that may be
# incomplete, but is significantly faster than doing a deep search
return shallow(bpy.data.materials)
unused_materials = shallow(bpy.data.materials)
# Filter out materials used by brushes - these should always be ignored
filtered = []
for key in unused_materials:
material = bpy.data.materials.get(key)
if material and not users.material_brushes(key):
filtered.append(key)
return filtered
def _is_compositor_node_tree(node_group):
"""
Check if a node group is a compositor node tree.
In Blender 5.0+, each scene has a compositing_node_tree that should be ignored.
Args:
node_group: The node group to check
Returns:
bool: True if the node group is a compositor node tree
"""
# Check if this node group is any scene's compositor node tree
# Use compat function to handle version differences properly
for scene in bpy.data.scenes:
if scene.use_nodes:
node_tree = compat.get_scene_compositor_node_tree(scene)
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: scene='{scene.name}', use_nodes={scene.use_nodes}, node_tree={node_tree}")
if node_tree:
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: node_tree.name='{node_tree.name}', checking against '{node_group.name}'")
# Check by reference, not just name (in case user renamed it)
if node_tree == node_group:
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: '{node_group.name}' is scene '{scene.name}' compositor node tree")
return True
else:
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: node_tree != node_group (reference comparison failed)")
# Also check if it's used as a node within any compositor (via node_group_compositors)
# This handles the case where the node group is used within a compositor, not just as the tree itself
comp_users = users.node_group_compositors(node_group.name)
if comp_users:
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: '{node_group.name}' is used in compositor: {comp_users}")
return True
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: '{node_group.name}' is NOT a compositor node tree")
return False
def node_groups_deep():
# returns a list of keys of unused node_groups
unused = []
# Track which node groups we've already determined are unused (to avoid infinite recursion)
_unused_node_groups_cache = set()
def _is_node_group_unused(ng_name, visited=None):
"""Recursively check if a node group is unused.
Returns True if the node group is only used by unused materials/objects/node_groups."""
if visited is None:
visited = set()
# Avoid infinite recursion
if ng_name in visited:
return False
visited.add(ng_name)
# Check cache first
if ng_name in _unused_node_groups_cache:
return True
node_group = bpy.data.node_groups.get(ng_name)
if not node_group:
return False
# Skip library-linked and override datablocks
if compat.is_library_or_override(node_group):
return False
# Skip compositor node trees
if _is_compositor_node_tree(node_group):
return False
# First check: node group has no users at all
all_users = users.node_group_all(ng_name)
config.debug_print(f"[Atomic Debug] _is_node_group_unused: '{ng_name}' - all_users = {all_users}")
if not all_users:
if not node_group.use_fake_user or config.include_fake_users:
config.debug_print(f"[Atomic Debug] _is_node_group_unused: '{ng_name}' has no users, marking as unused")
_unused_node_groups_cache.add(ng_name)
return True
# Second check: node group is used, but check if it's ONLY used by unused materials/objects/node_groups
# Get all materials and objects that use this node group
materials_using_ng = users.node_group_materials(ng_name)
objects_using_ng = users.node_group_objects(ng_name)
parent_node_groups = users.node_group_node_groups(ng_name)
# Collect all objects that use this node group (directly or via materials)
all_objects_using_ng = list(objects_using_ng) # Direct object usage via geometry nodes
# For each material using this node group, get objects using that material
for mat_name in materials_using_ng:
# Get objects using this material
objects_using_mat = users.material_objects(mat_name)
objects_using_mat.extend(users.material_geometry_nodes(mat_name))
all_objects_using_ng.extend(objects_using_mat)
# Remove duplicates
all_objects_using_ng = list(set(all_objects_using_ng))
# Check if all objects are unused
all_objects_unused = True
if all_objects_using_ng:
all_objects_unused = all(not users.object_all(obj_name) for obj_name in all_objects_using_ng)
# Check if all parent node groups are unused (recursive)
all_parent_ngs_unused = True
if parent_node_groups:
for parent_ng_name in parent_node_groups:
if not _is_node_group_unused(parent_ng_name, visited.copy()):
all_parent_ngs_unused = False
break
# If node group is only used by unused objects and unused parent node groups, mark it as unused
if all_objects_unused and all_parent_ngs_unused:
if not node_group.use_fake_user or config.include_fake_users:
_unused_node_groups_cache.add(ng_name)
return True
return False
for node_group in bpy.data.node_groups:
# Skip library-linked and override datablocks
if compat.is_library_or_override(node_group):
continue
if not users.node_group_all(node_group.name):
# check if node group has a fake user or if ignore fake users
# is enabled
if not node_group.use_fake_user or config.include_fake_users:
unused.append(node_group.name)
# Skip compositor node trees (Blender 5.0+ creates one per file)
if _is_compositor_node_tree(node_group):
continue
if _is_node_group_unused(node_group.name):
unused.append(node_group.name)
return unused
@@ -200,7 +400,16 @@ def node_groups_shallow():
# returns a list of keys of unused node groups that may be
# incomplete, but is significantly faster than doing a deep search
return shallow(bpy.data.node_groups)
unused = shallow(bpy.data.node_groups)
# Filter out compositor node trees (Blender 5.0+ creates one per file)
filtered = []
for node_group_name in unused:
node_group = bpy.data.node_groups.get(node_group_name)
if node_group and not _is_compositor_node_tree(node_group):
filtered.append(node_group_name)
return filtered
def particles_deep():
@@ -263,21 +472,27 @@ def textures_shallow():
def worlds():
# returns a full list of keys of unused worlds
config.debug_print(f"[Atomic Debug] unused.worlds(): Starting, total worlds: {len(bpy.data.worlds)}")
unused = []
checked = 0
for world in bpy.data.worlds:
# Skip library-linked and override datablocks
if compat.is_library_or_override(world):
continue
checked += 1
config.debug_print(f"[Atomic Debug] unused.worlds(): Checking '{world.name}' (users={world.users}, fake_user={world.use_fake_user})")
# if data-block has no users or if it has a fake user and
# ignore fake users is enabled
if world.users == 0 or (world.users == 1 and
world.use_fake_user and
config.include_fake_users):
config.debug_print(f"[Atomic Debug] unused.worlds(): '{world.name}' is unused, adding to list")
unused.append(world.name)
config.debug_print(f"[Atomic Debug] unused.worlds(): Complete, checked {checked} worlds, found {len(unused)} unused")
return unused
@@ -23,18 +23,35 @@ def get_all_unused_parallel():
"""
# Execute all checks sequentially but in a clean batch
# This avoids threading overhead while keeping code organized
return {
'collections': unused.collections_deep(),
'images': unused.images_deep(),
'lights': unused.lights_deep(),
'materials': unused.materials_deep(),
'node_groups': unused.node_groups_deep(),
'objects': unused.objects_deep(),
'particles': unused.particles_deep(),
'textures': unused.textures_deep(),
'armatures': unused.armatures_deep(),
'worlds': unused.worlds(),
}
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Starting, will scan {len(CATEGORIES)} categories")
result = {}
for i, category in enumerate(CATEGORIES):
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Scanning {category} ({i+1}/{len(CATEGORIES)})...")
if category == 'collections':
result[category] = unused.collections_deep()
elif category == 'images':
result[category] = unused.images_deep()
elif category == 'lights':
result[category] = unused.lights_deep()
elif category == 'materials':
result[category] = unused.materials_deep()
elif category == 'node_groups':
result[category] = unused.node_groups_deep()
elif category == 'objects':
result[category] = unused.objects_deep()
elif category == 'particles':
result[category] = unused.particles_deep()
elif category == 'textures':
result[category] = unused.textures_deep()
elif category == 'armatures':
result[category] = unused.armatures_deep()
elif category == 'worlds':
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Calling unused.worlds()...")
result[category] = unused.worlds()
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: unused.worlds() returned {len(result[category])} unused worlds")
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Finished {category}")
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Complete, returning results")
return result
def _has_any_unused_collections():
@@ -54,10 +71,35 @@ def _has_any_unused_images():
for image in bpy.data.images:
if compat.is_library_or_override(image):
continue
# First check: standard unused detection
if not users.image_all(image.name):
if not image.use_fake_user or config.include_fake_users:
if image.name not in do_not_flag:
return True
else:
# Second check: image is used, but check if it's ONLY used by unused objects
# This fixes issue #5: images used by unused objects should be marked as unused
objects_using_image = []
# Check materials that use the image
for mat_name in users.image_materials(image.name):
objects_using_image.extend(users.material_objects(mat_name))
objects_using_image.extend(users.material_geometry_nodes(mat_name))
# Check Geometry Nodes directly
objects_using_image.extend(users.image_geometry_nodes(image.name))
# Remove duplicates
objects_using_image = list(set(objects_using_image))
# If image is only used by objects, and ALL those objects are unused, mark image as unused
if objects_using_image:
all_objects_unused = all(not users.object_all(obj_name) for obj_name in objects_using_image)
if all_objects_unused:
if not image.use_fake_user or config.include_fake_users:
if image.name not in do_not_flag:
return True
return False
@@ -77,9 +119,31 @@ def _has_any_unused_materials():
for material in bpy.data.materials:
if compat.is_library_or_override(material):
continue
# Skip materials used by brushes - these should always be ignored
if users.material_brushes(material.name):
continue
# First check: standard unused detection
if not users.material_all(material.name):
if not material.use_fake_user or config.include_fake_users:
return True
else:
# Second check: material is used, but check if it's ONLY used by unused objects
# This fixes issue #5: materials used by unused objects should be marked as unused
objects_using_material = []
objects_using_material.extend(users.material_objects(material.name))
objects_using_material.extend(users.material_geometry_nodes(material.name))
# Remove duplicates
objects_using_material = list(set(objects_using_material))
# If material is only used by objects, and ALL those objects are unused, mark material as unused
if objects_using_material:
all_objects_unused = all(not users.object_all(obj_name) for obj_name in objects_using_material)
if all_objects_unused:
if not material.use_fake_user or config.include_fake_users:
return True
return False
@@ -88,6 +152,10 @@ def _has_any_unused_node_groups():
for node_group in bpy.data.node_groups:
if compat.is_library_or_override(node_group):
continue
# Skip compositor node trees (Blender 5.0+ creates one per file)
# Import the helper function from unused module
if unused._is_compositor_node_tree(node_group):
continue
if not users.node_group_all(node_group.name):
if not node_group.use_fake_user or config.include_fake_users:
return True
@@ -124,13 +192,19 @@ def _has_any_unused_textures():
def _has_any_unused_worlds():
"""Check if there are any unused worlds (short-circuits early)."""
config.debug_print(f"[Atomic Debug] _has_any_unused_worlds: Starting, total worlds: {len(bpy.data.worlds)}")
checked = 0
for world in bpy.data.worlds:
if compat.is_library_or_override(world):
continue
checked += 1
config.debug_print(f"[Atomic Debug] _has_any_unused_worlds: Checking world '{world.name}' (users={world.users}, fake_user={world.use_fake_user}, include_fake_users={config.include_fake_users})")
if world.users == 0 or (world.users == 1 and
world.use_fake_user and
config.include_fake_users):
config.debug_print(f"[Atomic Debug] _has_any_unused_worlds: Found unused world '{world.name}', returning True")
return True
config.debug_print(f"[Atomic Debug] _has_any_unused_worlds: Checked {checked} worlds, none unused, returning False")
return False
@@ -156,6 +230,10 @@ def _has_any_unused_armatures():
return False
# Category order for progress tracking
CATEGORIES = ['collections', 'images', 'lights', 'materials', 'node_groups',
'objects', 'particles', 'textures', 'armatures', 'worlds']
def get_unused_for_smart_select():
"""
Get unused data for smart select operation (returns booleans).
@@ -168,17 +246,29 @@ def get_unused_for_smart_select():
"""
# Use optimized short-circuit versions that stop as soon as
# they find ONE unused item, rather than computing the full list
return {
'collections': _has_any_unused_collections(),
'images': _has_any_unused_images(),
'lights': _has_any_unused_lights(),
'materials': _has_any_unused_materials(),
'node_groups': _has_any_unused_node_groups(),
'objects': _has_any_unused_objects(),
'particles': _has_any_unused_particles(),
'textures': _has_any_unused_textures(),
'armatures': _has_any_unused_armatures(),
'worlds': _has_any_unused_worlds(),
}
result = {}
for category in CATEGORIES:
if category == 'collections':
result[category] = _has_any_unused_collections()
elif category == 'images':
result[category] = _has_any_unused_images()
elif category == 'lights':
result[category] = _has_any_unused_lights()
elif category == 'materials':
result[category] = _has_any_unused_materials()
elif category == 'node_groups':
result[category] = _has_any_unused_node_groups()
elif category == 'objects':
result[category] = _has_any_unused_objects()
elif category == 'particles':
result[category] = _has_any_unused_particles()
elif category == 'textures':
result[category] = _has_any_unused_textures()
elif category == 'armatures':
result[category] = _has_any_unused_armatures()
elif category == 'worlds':
result[category] = _has_any_unused_worlds()
return result
@@ -31,6 +31,7 @@ a material would be searching for the image_materials() function.
"""
import bpy
from ..utils import compat
def collection_all(collection_key):
@@ -42,7 +43,8 @@ def collection_all(collection_key):
collection_meshes(collection_key) + \
collection_others(collection_key) + \
collection_rigidbody_world(collection_key) + \
collection_scenes(collection_key)
collection_scenes(collection_key) + \
collection_instances(collection_key)
def collection_cameras(collection_key):
@@ -189,6 +191,32 @@ def collection_scenes(collection_key):
return distinct(users)
def collection_instances(collection_key):
# returns a list of object keys that instance this collection
# Collection instances are objects with instance_type='COLLECTION' and
# instance_collection pointing to this collection
# Only counts objects that are actually in scene collections
users = []
collection = bpy.data.collections[collection_key]
for obj in bpy.data.objects:
# Skip library-linked and override objects
if compat.is_library_or_override(obj):
continue
# Check if object is a collection instance
if hasattr(obj, 'instance_type') and obj.instance_type == 'COLLECTION':
if hasattr(obj, 'instance_collection') and obj.instance_collection:
if obj.instance_collection.name == collection.name:
# Only count if the instance object is in a scene
# (otherwise the collection isn't really being used)
if object_all(obj.name):
users.append(obj.name)
return distinct(users)
def _scene_collection_contains(parent_collection, target_collection):
# helper that checks whether target_collection exists inside the
# parent_collection hierarchy
@@ -255,6 +283,9 @@ def image_compositors(image_key):
def image_materials(image_key):
# returns a list of material keys that use the image
# Only returns materials that are actually used (in scenes)
# This ensures images are correctly detected as unused when their
# materials are unused (fixes issue #5)
users = []
image = bpy.data.images[image_key]
@@ -263,6 +294,13 @@ def image_materials(image_key):
node_group_users = image_node_groups(image_key)
for mat in bpy.data.materials:
# Skip library-linked and override materials
from ..utils import compat
if compat.is_library_or_override(mat):
continue
# Check if this material uses the image
material_uses_image = False
# if material uses a valid node tree, check each node
if mat.use_nodes and mat.node_tree:
@@ -273,7 +311,7 @@ def image_materials(image_key):
# if the nodes image is our image
if node.image.name == image.name:
users.append(mat.name)
material_uses_image = True
# if image in node in node group in node tree
elif node.type == 'GROUP':
@@ -282,23 +320,38 @@ def image_materials(image_key):
# list of node groups that use this image
if node.node_tree and \
node.node_tree.name in node_group_users:
users.append(mat.name)
material_uses_image = True
# Only add material if it uses the image AND is actually used
if material_uses_image:
# Check if material is actually used (in scenes)
if material_all(mat.name):
users.append(mat.name)
return distinct(users)
def image_node_groups(image_key):
# returns a list of keys of node groups that use this image
# Only returns node groups that are actually used (in scenes)
# This ensures images are correctly detected as unused when their
# node groups are unused (fixes issue #5)
users = []
image = bpy.data.images[image_key]
# for each node group
for node_group in bpy.data.node_groups:
# Skip library-linked and override node groups
from ..utils import compat
if compat.is_library_or_override(node_group):
continue
# if node group contains our image
if node_group_has_image(node_group.name, image.name):
users.append(node_group.name)
# Only add node group if it is actually used (in scenes)
if node_group_all(node_group.name):
users.append(node_group.name)
return distinct(users)
@@ -349,6 +402,9 @@ def image_textures(image_key):
def image_geometry_nodes(image_key):
# returns a list of object keys that use the image through Geometry Nodes
# Only returns objects that are actually used (in scenes)
# This ensures images are correctly detected as unused when their
# objects are unused (fixes issue #5)
users = []
image = bpy.data.images[image_key]
@@ -360,6 +416,19 @@ def image_geometry_nodes(image_key):
from ..utils import compat
for obj in bpy.data.objects:
# Skip library-linked and override objects
if compat.is_library_or_override(obj):
continue
# Check if object is in any scene collection (reuse object_all logic)
# This ensures recursive checking: if the object using the image isn't in a scene,
# the image isn't considered used
obj_scenes = object_all(obj.name)
is_in_scene = bool(obj_scenes)
if not is_in_scene:
continue # Skip objects not in scene collections
# check Geometry Nodes modifiers
if hasattr(obj, 'modifiers'):
for modifier in obj.modifiers:
@@ -438,6 +507,65 @@ def material_all(material_key):
# Check node group usage (materials in node groups used elsewhere)
users.extend(material_node_groups(material_key))
# Check brush usage (materials used by brushes for stroke)
users.extend(material_brushes(material_key))
return distinct(users)
def material_brushes(material_key):
# returns a list of brush keys that use this material
# Brushes use materials for stroke rendering (Grease Pencil brushes)
users = []
try:
material = bpy.data.materials[material_key]
except (KeyError, AttributeError):
return []
if not hasattr(bpy.data, 'brushes'):
return []
for brush in bpy.data.brushes:
# Grease Pencil brushes use materials via gpencil_settings
if hasattr(brush, 'gpencil_settings'):
gp_settings = brush.gpencil_settings
if gp_settings:
# Check material property in gpencil_settings
if hasattr(gp_settings, 'material'):
gp_mat = gp_settings.material
# Compare by datablock reference to avoid matching linked materials with same name
if gp_mat and gp_mat == material:
users.append(brush.name)
# Check material_index - need to get material from Grease Pencil object
if hasattr(gp_settings, 'material_index'):
mat_idx = gp_settings.material_index
# Check all Grease Pencil objects for this material
for gp_obj in bpy.data.objects:
if gp_obj.type == 'GPENCIL' and gp_obj.data:
gp_data = gp_obj.data
if hasattr(gp_data, 'materials') and gp_data.materials:
if 0 <= mat_idx < len(gp_data.materials):
gp_mat = gp_data.materials[mat_idx]
# Compare by datablock reference to avoid matching linked materials with same name
if gp_mat and gp_mat == material:
users.append(brush.name)
# Also check for stroke_material (some brush types)
if hasattr(brush, 'stroke_material'):
stroke_mat = brush.stroke_material
# Compare by datablock reference to avoid matching linked materials with same name
if stroke_mat and stroke_mat == material:
users.append(brush.name)
# Check for material property (some brush types)
if hasattr(brush, 'material'):
mat = brush.material
# Compare by datablock reference to avoid matching linked materials with same name
if mat and mat == material:
users.append(brush.name)
return distinct(users)
@@ -446,7 +574,10 @@ def material_geometry_nodes(material_key):
# Only counts objects that are in scene collections (recursive check)
users = []
material = bpy.data.materials[material_key]
try:
material = bpy.data.materials[material_key]
except (KeyError, AttributeError):
return []
# Import compat module for version-safe geometry nodes access
from ..utils import compat
@@ -471,7 +602,8 @@ def material_geometry_nodes(material_key):
ng = compat.get_geometry_nodes_modifier_node_group(modifier)
if ng:
# Check if this node group or any nested node groups contain the material
if node_group_has_material(ng.name, material.name):
# Pass material datablock reference to ensure we match the correct material
if node_group_has_material_by_ref(ng.name, material):
users.append(obj.name)
return distinct(users)
@@ -486,14 +618,18 @@ def material_node_groups(material_key):
# Optimized to return early when usage is found
from ..utils import compat
material = bpy.data.materials[material_key]
try:
material = bpy.data.materials[material_key]
except (KeyError, AttributeError):
return []
# Check all node groups to see if they contain this material
for node_group in bpy.data.node_groups:
# Skip library-linked and override node groups
if compat.is_library_or_override(node_group):
continue
if node_group_has_material(node_group.name, material.name):
# Use the by_ref version to avoid name collision issues with linked materials
if node_group_has_material_by_ref(node_group.name, material):
# This node group contains the material, check if the node group is used
# Check usage contexts in order of likelihood, return early when found
@@ -550,11 +686,40 @@ def material_node_groups(material_key):
return [] # Material not used in any node groups
def material_node_groups_list(material_key):
# returns a list of node group names that contain this material
# This is used for inspection UI to show which node groups use the material
# Unlike material_node_groups(), this returns all node groups that contain
# the material, regardless of whether they're used
from ..utils import compat
users = []
try:
material = bpy.data.materials[material_key]
except (KeyError, AttributeError):
return []
# Check all node groups to see if they contain this material
for node_group in bpy.data.node_groups:
# Skip library-linked and override node groups
if compat.is_library_or_override(node_group):
continue
# Use the by_ref version to avoid name collision issues with linked materials
if node_group_has_material_by_ref(node_group.name, material):
users.append(node_group.name)
return distinct(users)
def material_objects(material_key):
# returns a list of object keys that use this material
users = []
material = bpy.data.materials[material_key]
try:
material = bpy.data.materials[material_key]
except (KeyError, AttributeError):
return []
for obj in bpy.data.objects:
@@ -564,10 +729,12 @@ def material_objects(material_key):
# for each material slot
for slot in obj.material_slots:
# if material slot has a valid material and it is our
# material
if slot.material and slot.material.name == material.name:
users.append(obj.name)
# if material slot has a valid material, check if it's our material
# Compare by datablock reference first to avoid matching linked materials with same name
if slot.material:
# Compare by reference (handles name collisions between local and linked materials)
if slot.material == material:
users.append(obj.name)
return distinct(users)
@@ -585,7 +752,7 @@ def node_group_all(node_group_key):
def node_group_compositors(node_group_key):
# returns a list containing "Compositor" if the node group is used in
# the scene's compositor
# any scene's compositor (either as the compositor's node tree itself, or as a node within it)
users = []
node_group = bpy.data.node_groups[node_group_key]
@@ -593,27 +760,40 @@ def node_group_compositors(node_group_key):
# a list of node groups that use our node group
node_group_users = node_group_node_groups(node_group_key)
# Import compat module for version-safe compositor access
# Import compat and version modules for version-safe compositor access
from ..utils import compat
from ..utils import version
# if our compositor uses nodes and has a valid node tree
scene = bpy.context.scene
if scene.use_nodes:
node_tree = compat.get_scene_compositor_node_tree(scene)
if node_tree:
# check each node in the compositor
for node in node_tree.nodes:
# if the node is a group and has a valid node tree
if hasattr(node, 'node_tree') and node.node_tree:
# if the node group is our node group
if node.node_tree.name == node_group.name:
users.append("Compositor")
# if the node group is in our list of node group users
if node.node_tree.name in node_group_users:
users.append("Compositor")
# Check ALL scenes, not just the current one
for scene in bpy.data.scenes:
# First check: is this node group the compositor's node tree itself?
if version.is_version_at_least(5, 0, 0):
if hasattr(scene, 'compositing_node_tree') and scene.compositing_node_tree:
# Check by reference, not just name (in case user renamed it)
if scene.compositing_node_tree == node_group:
users.append("Compositor")
continue # Already found, skip checking nodes within it
else:
if hasattr(scene, 'node_tree') and scene.node_tree:
# Check by reference, not just name (in case user renamed it)
if scene.node_tree == node_group:
users.append("Compositor")
continue # Already found, skip checking nodes within it
# Second check: is this node group used as a node within the compositor?
if scene.use_nodes:
node_tree = compat.get_scene_compositor_node_tree(scene)
if node_tree:
# check each node in the compositor
for node in node_tree.nodes:
# if the node is a group and has a valid node tree
if hasattr(node, 'node_tree') and node.node_tree:
# if the node group is our node group (check by reference)
if node.node_tree == node_group:
users.append("Compositor")
# if the node group is in our list of node group users
elif node.node_tree.name in node_group_users:
users.append("Compositor")
return distinct(users)
@@ -621,6 +801,8 @@ def node_group_compositors(node_group_key):
def node_group_materials(node_group_key):
# returns a list of material keys that use the node group in their
# node trees
# Note: Unlike image_materials(), this returns ALL materials using the node group,
# not just used ones. This allows node_groups_deep() to check if materials are unused.
users = []
node_group = bpy.data.node_groups[node_group_key]
@@ -629,6 +811,10 @@ def node_group_materials(node_group_key):
node_group_users = node_group_node_groups(node_group_key)
for material in bpy.data.materials:
# Skip library-linked and override materials
from ..utils import compat
if compat.is_library_or_override(material):
continue
# if material uses nodes and has a valid node tree, check each node
if material.use_nodes and material.node_tree:
@@ -754,6 +940,31 @@ def node_group_objects(node_group_key):
return distinct(users)
def _check_node_input_sockets_for_image(node, image_key):
"""Helper function to check if any input socket of a node contains the image.
This handles nodes like Menu Switch that have materials/images in input sockets."""
try:
image = bpy.data.images[image_key]
if not hasattr(node, 'inputs'):
return False
for input_socket in node.inputs:
try:
# Check if socket has a default_value that is an image
if hasattr(input_socket, 'default_value') and input_socket.default_value:
socket_value = input_socket.default_value
# Check if it's an image datablock
if hasattr(socket_value, 'name') and hasattr(socket_value, 'filepath'):
if socket_value.name == image.name or socket_value == image:
return True
except (AttributeError, ReferenceError, RuntimeError, TypeError, KeyError):
continue # Skip this socket if we can't access it
except (KeyError, AttributeError):
return False
return False
def node_group_has_image(node_group_key, image_key):
# recursively returns true if the node group contains this image
# directly or if it contains a node group a node group that contains
@@ -774,9 +985,14 @@ def node_group_has_image(node_group_key, image_key):
if node.image.name == image.name:
has_image = True
# Check input sockets for images (e.g., Menu Switch nodes)
# This handles nodes that have images connected via input sockets
if not has_image:
has_image = _check_node_input_sockets_for_image(node, image_key)
# recurse case
# if node is a node group and has a valid node tree
elif hasattr(node, 'node_tree') and node.node_tree:
if not has_image and hasattr(node, 'node_tree') and node.node_tree:
has_image = node_group_has_image(
node.node_tree.name, image.name)
@@ -855,15 +1071,51 @@ def node_group_has_texture(node_group_key, texture_key):
return has_texture
def node_group_has_material(node_group_key, material_key):
def _check_node_input_sockets_for_material(node, material_key):
"""Helper function to check if any input socket of a node contains the material.
This handles nodes like Menu Switch that have materials in input sockets."""
try:
material = bpy.data.materials[material_key]
return _check_node_input_sockets_for_material_by_ref(node, material)
except (KeyError, AttributeError):
return False
def _check_node_input_sockets_for_material_by_ref(node, material):
"""Helper function to check if any input socket of a node contains the material.
Takes material datablock directly to avoid name collision issues with linked materials."""
if not material or not hasattr(node, 'inputs'):
return False
for input_socket in node.inputs:
try:
# Check socket type - material sockets are typically 'MATERIAL' type
socket_type = getattr(input_socket, 'type', '')
if socket_type == 'MATERIAL' or 'material' in str(socket_type).lower():
# Check if this socket has a default_value that is a material
if hasattr(input_socket, 'default_value') and input_socket.default_value:
socket_material = input_socket.default_value
# Compare by datablock reference to avoid matching linked materials with same name
if socket_material and socket_material == material:
return True
except (AttributeError, ReferenceError, RuntimeError, TypeError, KeyError):
continue # Skip this socket if we can't access it
return False
def node_group_has_material_by_ref(node_group_key, material):
# returns true if a node group contains this material (directly or nested)
# Takes material datablock directly to avoid name collision issues with linked materials
has_material = False
try:
node_group = bpy.data.node_groups[node_group_key]
material = bpy.data.materials[material_key]
except (KeyError, AttributeError):
return False
if not material:
return False
try:
for node in node_group.nodes:
@@ -883,10 +1135,9 @@ def node_group_has_material(node_group_key, material_key):
# Check the default_value (for unlinked materials)
if hasattr(material_socket, 'default_value'):
socket_material = material_socket.default_value
if socket_material and hasattr(socket_material, 'name'):
if (socket_material.name == material.name or
socket_material == material):
has_material = True
# Compare by datablock reference to avoid matching linked materials with same name
if socket_material and socket_material == material:
has_material = True
except (KeyError, AttributeError, ReferenceError, RuntimeError, TypeError):
pass
@@ -900,11 +1151,10 @@ def node_group_has_material(node_group_key, material_key):
# Check if this socket has a default_value that is a material
if hasattr(input_socket, 'default_value') and input_socket.default_value:
socket_material = input_socket.default_value
if socket_material and hasattr(socket_material, 'name'):
if (socket_material.name == material.name or
socket_material == material):
has_material = True
break # Found it, no need to continue
# Compare by datablock reference to avoid matching linked materials with same name
if socket_material and socket_material == material:
has_material = True
break # Found it, no need to continue
except (AttributeError, ReferenceError, RuntimeError, TypeError):
continue # Skip this socket if we can't access it
@@ -912,8 +1162,8 @@ def node_group_has_material(node_group_key, material_key):
if not has_material and hasattr(node, 'material'):
try:
if node.material:
if (node.material.name == material.name or
node.material == material):
# Compare by datablock reference to avoid matching linked materials with same name
if node.material == material:
has_material = True
break # Found it, no need to continue
except (AttributeError, ReferenceError, RuntimeError):
@@ -926,14 +1176,33 @@ def node_group_has_material(node_group_key, material_key):
except (AttributeError, ReferenceError, RuntimeError):
pass # Skip if bl_idname access fails
# Check for Menu Switch nodes and other nodes with material inputs
# Menu Switch nodes can have materials in any of their input sockets
if not has_material and hasattr(node, 'bl_idname'):
try:
node_type = node.bl_idname
# Check for Menu Switch node (GeometryNodeMenuSwitch)
if node_type == 'GeometryNodeMenuSwitch' or 'MenuSwitch' in node_type:
has_material = _check_node_input_sockets_for_material_by_ref(node, material)
if has_material:
break
except (AttributeError, ReferenceError, RuntimeError):
pass # Skip if bl_idname access fails
# General check: Check all input sockets for materials (catches other node types)
# This is a fallback for any node that might have material inputs
if not has_material:
has_material = _check_node_input_sockets_for_material_by_ref(node, material)
if has_material:
break
# Fallback: Check for any node with a material property (e.g., Set Material)
# This catches other node types that might have materials
if not has_material and hasattr(node, 'material'):
try:
if node.material:
# Check both by name and by direct reference for robustness
if (node.material.name == material.name or
node.material == material):
# Compare by datablock reference to avoid matching linked materials with same name
if node.material == material:
has_material = True
break # Found it, no need to continue
except (AttributeError, ReferenceError, RuntimeError):
@@ -948,8 +1217,8 @@ def node_group_has_material(node_group_key, material_key):
if hasattr(node, 'material'):
try:
if node.material:
if (node.material.name == material.name or
node.material == material):
# Compare by datablock reference to avoid matching linked materials with same name
if node.material == material:
has_material = True
break
except (AttributeError, ReferenceError, RuntimeError):
@@ -962,8 +1231,8 @@ def node_group_has_material(node_group_key, material_key):
if not has_material and hasattr(node, 'node_tree'):
try:
if node.node_tree:
has_material = node_group_has_material(
node.node_tree.name, material.name)
has_material = node_group_has_material_by_ref(
node.node_tree.name, material)
except (KeyError, AttributeError, ReferenceError, RuntimeError):
continue # Skip invalid node groups
@@ -979,6 +1248,18 @@ def node_group_has_material(node_group_key, material_key):
return has_material
def node_group_has_material(node_group_key, material_key):
# returns true if a node group contains this material (directly or nested)
# Wrapper that converts material_key to material datablock for reference-based comparison
try:
material = bpy.data.materials[material_key]
except (KeyError, AttributeError):
return False
return node_group_has_material_by_ref(node_group_key, material)
def particle_all(particle_key):
# returns a list of keys of every data-block that uses this particle
# system
@@ -1168,22 +1449,68 @@ def texture_particles(texture_key):
return distinct(users)
def object_all(object_key):
def object_all(object_key, _visited_objects=None):
# returns a list of scene names where the object is used
# An object is "used" if it's in any collection that's part of any scene's collection hierarchy
# OR if it's in a collection that is instanced (and that instance is in a scene)
# _visited_objects is used to prevent infinite recursion when checking instanced collections
users = []
obj = bpy.data.objects[object_key]
if _visited_objects is None:
_visited_objects = set()
# Prevent infinite recursion
if object_key in _visited_objects:
return []
_visited_objects.add(object_key)
try:
users = []
obj = bpy.data.objects[object_key]
# Get all collections that contain this object
for collection in obj.users_collection:
# Check if this collection is in any scene's hierarchy
for scene in bpy.data.scenes:
if _scene_collection_contains(scene.collection, collection):
if scene.name not in users:
users.append(scene.name)
return distinct(users)
# Get all collections that contain this object
for collection in obj.users_collection:
# Check if this collection is in any scene's hierarchy
for scene in bpy.data.scenes:
if _scene_collection_contains(scene.collection, collection):
if scene.name not in users:
users.append(scene.name)
# Also check if this collection is instanced (and the instance is in a scene)
# Get all objects that instance this collection
for instance_obj in bpy.data.objects:
# Skip library-linked and override objects
if compat.is_library_or_override(instance_obj):
continue
# Check if object is a collection instance
if hasattr(instance_obj, 'instance_type') and instance_obj.instance_type == 'COLLECTION':
if hasattr(instance_obj, 'instance_collection') and instance_obj.instance_collection:
if instance_obj.instance_collection.name == collection.name:
# Check if the instance object is in a scene (using visited set to prevent recursion)
# First check if instance object is directly in a scene collection
instance_direct_scenes = []
for instance_collection in instance_obj.users_collection:
for scene in bpy.data.scenes:
if _scene_collection_contains(scene.collection, instance_collection):
if scene.name not in instance_direct_scenes:
instance_direct_scenes.append(scene.name)
# If instance object is directly in a scene, the instanced collection's objects are used
if instance_direct_scenes:
for scene_name in instance_direct_scenes:
if scene_name not in users:
users.append(scene_name)
else:
# Instance object is not directly in a scene, but might be in an instanced collection
# Recursively check (with visited set to prevent infinite loops)
instance_scenes = object_all(instance_obj.name, _visited_objects)
for scene_name in instance_scenes:
if scene_name not in users:
users.append(scene_name)
return distinct(users)
finally:
_visited_objects.remove(object_key)
def armature_all(armature_key):
@@ -328,6 +328,8 @@ class ATOMIC_OT_inspect_materials(bpy.types.Operator):
# user lists
users_objects = []
users_brushes = []
users_node_groups = []
def draw(self, context):
global inspection_update_trigger
@@ -349,13 +351,35 @@ class ATOMIC_OT_inspect_materials(bpy.types.Operator):
if atom.materials_field in bpy.data.materials.keys():
self.users_objects = \
users.material_objects(atom.materials_field)
self.users_brushes = \
users.material_brushes(atom.materials_field)
self.users_node_groups = \
users.material_node_groups_list(atom.materials_field)
# if key is invalid, empty the user lists
else:
self.users_objects = []
self.users_brushes = []
self.users_node_groups = []
inspection_update_trigger = False
# brushes box list
ui_layouts.box_list(
layout=layout,
title="Brushes",
items=self.users_brushes,
icon="BRUSH_DATA"
)
# node groups box list
ui_layouts.box_list(
layout=layout,
title="Node Groups",
items=self.users_node_groups,
icon="NODETREE"
)
# objects box list
ui_layouts.box_list_diverse(
layout=layout,
@@ -57,6 +57,27 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
atom.worlds
]
# Progress display section (only visible when operation is running)
if atom.is_operation_running:
box = layout.box()
col = box.column(align=True)
# Progress bar with percentage (Blender shows percentage in the bar with PERCENTAGE subtype)
progress_row = col.row(align=True)
progress_row.scale_y = 1.5
progress_row.prop(atom, "operation_progress", text="", slider=True)
# Status text
if atom.operation_status:
col.label(text=atom.operation_status, icon='TIME')
# Cancel button
row = col.row()
row.scale_y = 1.5
row.operator("atomic.cancel_operation", text="Cancel", icon='X')
layout.separator()
# nuke and clean buttons
row = layout.row(align=True)
row.scale_y = 2.0
@@ -160,10 +181,12 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
# right column
col = split.column(align=True)
# images buttons
splitcol = col.split(factor=0.8, align=True)
splitcol.prop(
# images buttons (deep scan checkbox, images checkbox, inspect button)
# Standard split layout for images (matches other categories)
images_split = col.split(factor=0.8, align=True)
# Images checkbox (will be slightly offset due to deep scan, but inspect aligns)
images_split.prop(
atom,
"images",
text="Images",
@@ -171,7 +194,8 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
icon='IMAGE_DATA'
)
splitcol.operator(
# Inspect button (right, aligns with other inspect buttons)
images_split.operator(
"atomic.inspect_images",
icon='VIEWZOOM',
text=""
@@ -267,7 +291,11 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
icon='RESTRICT_SELECT_OFF'
)
# Cache and missing file management
row = layout.row(align=True)
row.operator("atomic.clear_cache", text="Clear Cache", icon="FILE_REFRESH")
row.operator("atomic.detect_missing", text="Detect Missing", icon="LIBRARY_DATA_DIRECT")
reg_list = [ATOMIC_PT_main_panel]
@@ -31,6 +31,41 @@ from .. import config
from ..stats import missing
from .utils import ui_layouts
# Module-level state for detect missing operator instance
_detect_missing_operator_instance = None
def _warp_cursor_to_area_center(context, prefer_area_type="VIEW_3D") -> None:
"""Best-effort: move cursor to area center so popups appear centered.
Blender's popup placement is often tied to the last event mouse position.
Warping is a hack, but it's the only reliable way to 'center' popups in some contexts.
"""
win = getattr(context, "window", None)
screen = getattr(context, "screen", None)
if win is None or screen is None:
return
area = None
for a in screen.areas:
if a.type == prefer_area_type:
area = a
break
if area is None and screen.areas:
area = screen.areas[0]
if area is None:
return
try:
# cursor_warp expects WINDOW-relative coordinates (0,0 at window bottom-left),
# not OS desktop coordinates. Warping to the window center is the most
# reliable option across layouts.
x = int(win.width / 2)
y = int(win.height / 2)
win.cursor_warp(x, y)
except Exception:
pass
# Atomic Data Manager Detect Missing Files Popup
class ATOMIC_OT_detect_missing(bpy.types.Operator):
@@ -42,38 +77,6 @@ class ATOMIC_OT_detect_missing(bpy.types.Operator):
missing_images = []
missing_libraries = []
# missing file recovery option enum property
recovery_option: bpy.props.EnumProperty(
items=[
(
'IGNORE',
'Ignore Missing Files',
'Ignore the missing files and leave them offline'
),
(
'RELOAD',
'Reload Missing Files',
'Reload the missing files from their existing file paths'
),
(
'REMOVE',
'Remove Missing Files',
'Remove the missing files from the project'
),
(
'SEARCH',
'Search for Missing Files (under development)',
'Search for the missing files in a directory'
),
(
'REPLACE',
'Specify Replacement Files (under development)',
'Replace missing files with new files'
),
],
default='IGNORE'
)
def draw(self, context):
layout = self.layout
@@ -109,12 +112,20 @@ class ATOMIC_OT_detect_missing(bpy.types.Operator):
row = layout.separator() # extra space
# recovery option selection
# recovery option buttons
row = layout.row()
row.label(text="What would you like to do?")
row = layout.row()
row.prop(self, 'recovery_option', text="")
row.scale_y = 1.5
op_reload = row.operator("atomic.reload_missing", text="Reload", icon="FILE_REFRESH")
op_remove = row.operator("atomic.remove_missing", text="Remove", icon="TRASH")
op_search = row.operator("atomic.search_missing", text="Search", icon="VIEWZOOM")
op_replace = row.operator("atomic.replace_missing", text="Replace", icon="FILEBROWSER")
# Refresh button
row = layout.row()
refresh_op = row.operator("atomic.detect_missing_refresh", text="Refresh", icon="FILE_REFRESH")
# missing files interface if no missing files are found
else:
@@ -129,54 +140,89 @@ class ATOMIC_OT_detect_missing(bpy.types.Operator):
row = layout.separator() # extra space
def execute(self, context):
# ignore missing files will take no action
# reload missing files
if self.recovery_option == 'RELOAD':
bpy.ops.atomic.reload_missing('INVOKE_DEFAULT')
# remove missing files
elif self.recovery_option == 'REMOVE':
bpy.ops.atomic.remove_missing('INVOKE_DEFAULT')
# search for missing files
elif self.recovery_option == 'SEARCH':
bpy.ops.atomic.search_missing('INVOKE_DEFAULT')
# replace missing files
elif self.recovery_option == 'REPLACE':
bpy.ops.atomic.replace_missing('INVOKE_DEFAULT')
# Buttons now directly invoke operators, so execute just closes the dialog
# IGNORE is the default behavior (no action taken)
return {'FINISHED'}
def invoke(self, context, event):
# update missing file lists
global _detect_missing_operator_instance
# Store operator instance for refresh functionality
_detect_missing_operator_instance = self
# Always refresh missing file lists when invoked
self.missing_images = missing.images()
self.missing_libraries = missing.libraries()
wm = context.window_manager
# Force popup placement to screen center (see BlenderArtists reference).
_warp_cursor_to_area_center(context)
# invoke large dialog if there are missing files
if self.missing_images or self.missing_libraries:
return wm.invoke_props_dialog(self, width=500)
# invoke small dialog if there are no missing files
else:
return wm.invoke_popup(self, width=300)
return wm.invoke_props_dialog(self, width=300)
@persistent
def autodetect_missing_files(dummy=None):
# invokes the detect missing popup when missing files are detected upon
# loading a new Blender project
# Use a timer to defer the operator call since load_post handlers
# cannot directly invoke operators that modify data
if config.enable_missing_file_warning and \
(missing.images() or missing.libraries()):
def invoke_detect_missing():
try:
bpy.ops.atomic.detect_missing('INVOKE_DEFAULT')
except RuntimeError:
# If still in invalid context, ignore (will be handled on next user action)
pass
return None # Run once
bpy.app.timers.register(invoke_detect_missing, first_interval=0.1)
# Refresh operator for missing file detection
class ATOMIC_OT_detect_missing_refresh(bpy.types.Operator):
"""Refresh missing file detection"""
bl_idname = "atomic.detect_missing_refresh"
bl_label = "Refresh Missing Files"
bl_options = {'INTERNAL'}
def execute(self, context):
global _detect_missing_operator_instance
# Update the stored operator instance if it exists and is valid
if _detect_missing_operator_instance is not None:
try:
# Check if operator instance is still valid
_ = _detect_missing_operator_instance.bl_idname
# Update the missing file lists
_detect_missing_operator_instance.missing_images = missing.images()
_detect_missing_operator_instance.missing_libraries = missing.libraries()
# Redraw all areas to refresh the dialog
for area in context.screen.areas:
area.tag_redraw()
self.report({'INFO'}, "Missing files list refreshed")
return {'FINISHED'}
except (ReferenceError, AttributeError, TypeError):
# Operator instance invalidated, clear it
_detect_missing_operator_instance = None
# If no valid instance, invoke a new dialog
bpy.ops.atomic.detect_missing('INVOKE_DEFAULT')
return {'FINISHED'}
reg_list = [ATOMIC_OT_detect_missing]
reg_list = [ATOMIC_OT_detect_missing, ATOMIC_OT_detect_missing_refresh]
def register():
@@ -24,11 +24,41 @@ some functions for syncing the preference properties with external factors.
"""
import bpy
import os
from bpy.utils import register_class
from ..utils import compat
from .. import config
import sys
# updater removed in Blender 4.5 extension format
# Get the root module name dynamically
def _get_addon_module_name():
"""Get the root addon module name for bl_idname."""
# In Blender 5.0 extensions loaded via VSCode, the module name is the full path
# e.g., "bl_ext.vscode_development.atomic_data_manager"
# We need to get it from the parent package (atomic_data_manager)
try:
# Get parent package name from __package__ (remove .ui suffix)
if __package__:
parent_pkg = __package__.rsplit('.', 1)[0] if '.' in __package__ else __package__
# Get the actual module from sys.modules to get its __name__
parent_module = sys.modules.get(parent_pkg)
if parent_module and hasattr(parent_module, '__name__'):
module_name = parent_module.__name__
config.debug_print(f"[Atomic Debug] Using parent module __name__ as bl_idname: {module_name}")
return module_name
else:
# Use the package name directly
config.debug_print(f"[Atomic Debug] Using parent package name as bl_idname: {parent_pkg}")
return parent_pkg
except Exception as e:
config.debug_print(f"[Atomic Debug] Could not get parent module name: {e}")
# Last fallback
module_name = "atomic_data_manager"
config.debug_print(f"[Atomic Debug] Using fallback bl_idname: {module_name}")
return module_name
def _get_addon_prefs():
# robustly find our AddonPreferences instance regardless of module name
@@ -106,6 +136,9 @@ def copy_prefs_to_config(self, context):
config.include_fake_users = \
atomic_preferences.include_fake_users
config.enable_debug_prints = \
atomic_preferences.enable_debug_prints
# hidden atomic preferences
config.pie_menu_type = \
atomic_preferences.pie_menu_type
@@ -192,7 +225,9 @@ def remove_pie_menu_hotkeys():
# Atomic Data Manager Preference Panel UI
class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
bl_idname = "atomic_data_manager"
# bl_idname must match the add-on's module name exactly
# Get it dynamically to ensure it matches what Blender registered
bl_idname = _get_addon_module_name()
# visible atomic preferences
enable_missing_file_warning: bpy.props.BoolProperty(
@@ -214,6 +249,11 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
update=update_pie_menu_hotkeys
)
enable_debug_prints: bpy.props.BoolProperty(
description="Enable debug print statements in the console",
default=False
)
# hidden atomic preferences
pie_menu_type: bpy.props.StringProperty(
default="D"
@@ -243,6 +283,9 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
def draw(self, context):
layout = self.layout
# Debug: verify draw is being called
config.debug_print("[Atomic Debug] Preferences draw() method called")
split = layout.split()
@@ -266,6 +309,13 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
text="Include Fake Users"
)
# enable debug prints toggle
col.prop(
self,
"enable_debug_prints",
text="Enable Debug Prints"
)
# pie menu settings
pie_split = col.split(factor=0.55) # nice
@@ -317,7 +367,13 @@ keymaps = []
def register():
for cls in reg_list:
register_class(cls)
try:
register_class(cls)
config.debug_print(f"[Atomic Debug] Registered preferences class: {cls.__name__} with bl_idname: {cls.bl_idname}")
except Exception as e:
print(f"[Atomic Error] Failed to register preferences class {cls.__name__}: {e}")
import traceback
traceback.print_exc()
# make sure global preferences are updated on registration
copy_prefs_to_config(None, None)
@@ -362,7 +362,6 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
text="Unnamed: {0}".format(count.worlds_unnamed())
)
reg_list = [ATOMIC_PT_stats_panel]
@@ -7,6 +7,7 @@ between Blender 4.2 LTS, 4.5 LTS, and 5.0.
import bpy
from bpy.utils import register_class, unregister_class
from . import version
from .. import config
def safe_register_class(cls):
@@ -116,7 +117,7 @@ def get_scene_compositor_node_tree(scene):
Get the compositor node tree from a scene, handling version differences.
In Blender 4.2/4.5: scene.node_tree
In Blender 5.0+: scene.compositing_node_tree
In Blender 5.0+: scene.compositing_node_group
Args:
scene: The scene object
@@ -124,14 +125,35 @@ def get_scene_compositor_node_tree(scene):
Returns:
NodeTree or None: The compositor node tree if available
"""
# Blender 5.0+ uses compositing_node_tree
# Blender 5.0+ uses compositing_node_group (not compositing_node_tree!)
if version.is_version_at_least(5, 0, 0):
if hasattr(scene, 'compositing_node_tree') and scene.compositing_node_tree:
return scene.compositing_node_tree
# Try compositing_node_group first (Blender 5.0+)
try:
node_tree = getattr(scene, 'compositing_node_group', None)
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: scene='{scene.name}', use_nodes={scene.use_nodes}, compositing_node_group={node_tree}")
if node_tree:
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: Found compositor node tree: {node_tree.name}")
return node_tree
except (AttributeError, TypeError) as e:
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: compositing_node_group access failed: {e}")
# Fallback: try compositing_node_tree (in case it exists in some versions)
try:
node_tree = getattr(scene, 'compositing_node_tree', None)
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: compositing_node_tree={node_tree}")
if node_tree:
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: Found via compositing_node_tree: {node_tree.name}")
return node_tree
except (AttributeError, TypeError) as e:
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: compositing_node_tree access failed: {e}")
else:
# Blender 4.2/4.5 uses node_tree
if hasattr(scene, 'node_tree') and scene.node_tree:
return scene.node_tree
try:
node_tree = getattr(scene, 'node_tree', None)
if node_tree:
return node_tree
except (AttributeError, TypeError):
pass
return None
@@ -1,5 +1,6 @@
import bpy # type: ignore
import os
import shutil
import subprocess
import sys
import tempfile
@@ -91,6 +92,46 @@ def get_file_extension(video_format):
else:
return ".mp4" # Default to mp4 if unknown
def _resolve_ffmpeg_path():
"""Resolve ffmpeg executable. Prefer addon pref, then PATH, then Blender's bundled ffmpeg."""
try:
for addon in (__name__, "basedplayblast", "bl_ext.basedplayblast", "BasedPlayblast"):
prefs = bpy.context.preferences.addons.get(addon)
if prefs and prefs.preferences and hasattr(prefs.preferences, "ffmpeg_path"):
custom = getattr(prefs.preferences, "ffmpeg_path", "").strip()
if custom and os.path.isfile(custom):
return custom
except Exception:
pass
exe = shutil.which("ffmpeg")
if exe:
return exe
try:
blender_dir = os.path.dirname(bpy.app.binary_path)
version_str = f"{bpy.app.version[0]}.{bpy.app.version[1]}"
for search_dir in (blender_dir, os.path.join(blender_dir, version_str)):
for name in ("ffmpeg.exe", "ffmpeg"):
path = os.path.join(search_dir, name)
if os.path.isfile(path):
return path
except Exception:
pass
# Common Windows install locations (Steam/launcher often omit PATH)
for candidate in (
r"C:\ProgramData\chocolatey\bin\ffmpeg.exe", # Chocolatey
r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
r"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe",
r"C:\ffmpeg\bin\ffmpeg.exe",
os.path.join(os.environ.get("ProgramFiles", "C:\\Program Files"), "ffmpeg", "bin", "ffmpeg.exe"),
os.path.join(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"), "ffmpeg", "bin", "ffmpeg.exe"),
):
try:
if candidate and os.path.isfile(candidate):
return candidate
except Exception:
pass
return "ffmpeg" # Fallback; will fail with clear error if missing
# Helper function to convert quality enum to FFmpeg CRF value
def get_ffmpeg_quality(quality_enum):
quality_map = {
@@ -814,7 +855,8 @@ class BPL_OT_create_playblast(Operator):
'frame_end': original_frame_end, # Store original frame end
'image_settings': {
'file_format': scene.render.image_settings.file_format,
'color_mode': scene.render.image_settings.color_mode
'color_mode': scene.render.image_settings.color_mode,
'compression': self._safe_get_compression_value(scene)
},
'display_mode': context.preferences.view.render_display_type,
# Store metadata settings
@@ -1523,7 +1565,7 @@ class BPL_OT_create_playblast(Operator):
# Note: FFmpeg's %04d pattern expects frames starting at 0000, but our frames start at frame_start
# We need to add -start_number to tell FFmpeg the actual starting frame number
ffmpeg_cmd = [
"ffmpeg", "-y", # Overwrite output file
_resolve_ffmpeg_path(), "-y", # Overwrite output file
"-framerate", str(framerate),
"-start_number", str(self._frame_start), # Tell FFmpeg the starting frame number
"-i", frame_pattern,
@@ -1675,7 +1717,7 @@ class BPL_OT_create_playblast(Operator):
duration = (self._frame_end - self._frame_start + 1) / fps
ffmpeg_extract_cmd = [
"ffmpeg", "-y",
_resolve_ffmpeg_path(), "-y",
"-i", sound_path,
"-ss", str(max(0, start_time)),
"-t", str(duration),
@@ -1868,8 +1910,22 @@ class BPL_OT_create_playblast(Operator):
self.report({'ERROR'}, f"Video conversion failed: {result.stderr}")
except Exception as e:
print(f"Error converting frames to video: {str(e)}")
self.report({'ERROR'}, f"Video conversion error: {str(e)}")
err_msg = str(e)
print(f"Error converting frames to video: {err_msg}")
hint = " Set FFmpeg Path in Edit > Preferences > Add-ons > BasedPlayblast." if "WinError 2" in err_msg or "cannot find the file" in err_msg.lower() else ""
self.report({'ERROR'}, f"Video conversion error: {err_msg}{hint}")
def _safe_get_compression_value(self, scene):
"""Safely get compression value, returning None if not available or format doesn't support it."""
try:
if not hasattr(scene.render.image_settings, 'compression'):
return None
current_format = str(scene.render.image_settings.file_format).upper()
if current_format not in ('PNG', 'JPEG', 'JPEG2000'):
return None
return scene.render.image_settings.compression
except (AttributeError, TypeError, ValueError):
return None
def cleanup(self, context):
# Reset progress properties
@@ -1916,8 +1972,42 @@ class BPL_OT_create_playblast(Operator):
scene.render.use_overwrite = self._original_settings['use_overwrite']
scene.render.use_placeholder = self._original_settings['use_placeholder']
scene.camera = self._original_settings['camera']
scene.render.image_settings.file_format = self._original_settings['image_settings']['file_format']
scene.render.image_settings.color_mode = self._original_settings['image_settings']['color_mode']
# Restore image settings safely using .get() to avoid KeyError
try:
if isinstance(self._original_settings, dict):
img_settings = self._original_settings.get('image_settings')
if img_settings and isinstance(img_settings, dict):
# Restore file_format first
file_format = img_settings.get('file_format')
if file_format is not None:
scene.render.image_settings.file_format = file_format
# Restore color_mode
color_mode = img_settings.get('color_mode')
if color_mode is not None:
scene.render.image_settings.color_mode = color_mode
# Restore compression - check if format supports it
compression = img_settings.get('compression')
if compression is not None:
# Get current format (after restoration above)
try:
current_format = scene.render.image_settings.file_format
# Convert to string for comparison (handles enum types)
format_str = str(current_format).upper()
# Only restore compression if file format supports it
if format_str in ('PNG', 'JPEG', 'JPEG2000'):
try:
if hasattr(scene.render.image_settings, 'compression'):
scene.render.image_settings.compression = compression
print(f"Restored PNG compression: {compression}%")
except (AttributeError, TypeError, ValueError):
pass # Silently fail if compression can't be set
except (AttributeError, TypeError):
pass # Silently fail if format can't be read
except (KeyError, TypeError, AttributeError):
pass # Silently fail if settings can't be restored
context.preferences.view.render_display_type = self._original_settings['display_mode']
# CRITICAL: Restore frame range to original values - THIS FIXES THE MAIN BUG
@@ -3893,6 +3983,13 @@ class BPL_AddonPreferences(AddonPreferences):
default="-c:v h264_nvenc -preset fast -crf 0"
)
ffmpeg_path: StringProperty(
name="FFmpeg Path",
description="Full path to ffmpeg.exe (e.g. C:\\ffmpeg\\bin\\ffmpeg.exe). Leave blank to use PATH or Blender's bundled ffmpeg. Required when launching Blender from Steam.",
default="",
subtype='FILE_PATH'
)
repo_initialized: BoolProperty(
name="Rainy's Extensions Added",
description="Internal flag to avoid re-adding Rainy's Extensions repository multiple times.",
@@ -3907,6 +4004,7 @@ class BPL_AddonPreferences(AddonPreferences):
box.prop(self, "default_video_quality")
box.prop(self, "default_use_custom_ffmpeg_args")
box.prop(self, "default_ffmpeg_args")
box.prop(self, "ffmpeg_path", text="FFmpeg Path (Steam)")
def on_load_post(dummy):
"""Applies user defaults after a file is loaded."""
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
id = "basedplayblast"
name = "BasedPlayblast"
tagline = "Easily create playblasts from Blender and Flamenco"
version = "2.6.1"
version = "2.6.3"
type = "add-on"
maintainer = "RaincloudTheDragon <raincloudthedragon@gmail.com>"
@@ -5,15 +5,15 @@ from .panels import bulk_viewport_display
from .panels import bulk_data_remap
from .panels import bulk_path_management
from .panels import bulk_scene_general
from .ops.AutoMatExtractor import AutoMatExtractor, AUTOMAT_OT_summary_dialog
from .ops.Rename_images_by_mat import Rename_images_by_mat, RENAME_OT_summary_dialog
from .ops.FreeGPU import BST_FreeGPU
from .ops.AutoMatExtractor import RBST_AutoMat_OT_AutoMatExtractor, RBST_AutoMat_OT_summary_dialog
from .ops.Rename_images_by_mat import RBST_RenameImg_OT_Rename_images_by_mat, RBST_RenameImg_OT_summary_dialog
from .ops.FreeGPU import RBST_FreeGPU
from .ops import ghost_buster
from . import rainys_repo_bootstrap
from .utils import compat
# Addon preferences class for update settings
class BST_AddonPreferences(AddonPreferences):
class RBST_AddonPreferences(AddonPreferences):
bl_idname = __package__
# AutoMat Extractor settings
@@ -48,12 +48,12 @@ class VIEW3D_PT_BulkSceneTools(Panel):
# List of all classes in this module
classes = (
VIEW3D_PT_BulkSceneTools,
BST_AddonPreferences,
AutoMatExtractor,
AUTOMAT_OT_summary_dialog,
Rename_images_by_mat,
RENAME_OT_summary_dialog,
BST_FreeGPU,
RBST_AddonPreferences,
RBST_AutoMat_OT_AutoMatExtractor,
RBST_AutoMat_OT_summary_dialog,
RBST_RenameImg_OT_Rename_images_by_mat,
RBST_RenameImg_OT_summary_dialog,
RBST_FreeGPU,
)
def register():
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
id = "rainclouds_bulk_scene_tools"
name = "Raincloud's Bulk Scene Tools"
tagline = "Bulk utilities for optimizing scene data"
version = "0.14.0"
version = "0.16.0"
type = "add-on"
maintainer = "RaincloudTheDragon <raincloudthedragon@gmail.com>"
@@ -1,3 +1,23 @@
# v0.16.0 2026-01-15
- Delete Single Keyframe Actions: Blender 5.0 compatible (action layers/strips/channelbags); skip actions from linked libraries
- Find Material Users: removed (issue #14; use Atomic or DBU)
- White World: new operator in Scene General (#13)—white background world, film transparent, orphans purge
# v0.15.1 2026-01-12
- Fixed AutoMatExtractor MSMNAO Pack texture handling: preserve texture type suffixes (MSMNAO Pack, SSTM Pack, etc.) when sanitizing filenames
- Fixed issue #12: corrected duplicated prefix in RBST_ViewDisp_MaterialStatus enum definition
- Added Props subcollection under Char in SpawnSceneStructure operator
# v0.15.0 2026-01-07
- Refactored all class, function, and constant names to use consistent RBST_ prefix system with module-specific sub-prefixes
- Added Data-Block Utils integration: Merge Duplicates button in Bulk Data Remap panel that automates finding and merging duplicates across Node Groups, Materials, Lights, Images, and Meshes
# v0.14.1
- Fixed crash in Blender 5.0 when refreshing material previews (#11)
- Removed problematic batch preview operators and unsafe icon_id access that caused crashes
- Added version-specific optimizations for 4.2/4.5 vs 5.0+ compatibility
- Improved cleanup and reduced CPU usage
# v0.14.0
- Added operator to select all images with absolute paths (#3)
- Added search functionality to filter datablocks in PathMan and Data Remapper panels (#4)
@@ -9,7 +9,7 @@ from ..panels.bulk_path_management import (
)
from ..utils import compat
class AUTOMAT_OT_summary_dialog(bpy.types.Operator):
class RBST_AutoMat_OT_summary_dialog(bpy.types.Operator):
"""Show AutoMat Extractor operation summary"""
bl_idname = "bst.automat_summary_dialog"
bl_label = "AutoMat Extractor Summary"
@@ -62,7 +62,7 @@ class AUTOMAT_OT_summary_dialog(bpy.types.Operator):
def invoke(self, context, event):
return context.window_manager.invoke_popup(self, width=500)
class AutoMatExtractor(bpy.types.Operator):
class RBST_AutoMat_OT_AutoMatExtractor(bpy.types.Operator):
bl_idname = "bst.automatextractor"
bl_label = "AutoMatExtractor"
bl_description = "Pack selected images and extract them with organized paths by blend file and material"
@@ -405,8 +405,10 @@ class AutoMatExtractor(bpy.types.Operator):
def sanitize_filename(self, filename):
"""Sanitize filename/folder name for filesystem compatibility"""
# First, remove potential file extensions, including numerical ones like .001
base_name = re.sub(r'\.\d{3}$', '', filename) # Remove .001, .002 etc.
base_name = os.path.splitext(base_name)[0] # Remove standard extensions
# Remove .001, .002 etc. when followed by _ or space (for CC/iC Pack textures)
base_name = re.sub(r'\.\d{3}(?=[_\s])', '', filename) # Remove .001, .002 etc. when followed by _ or space
base_name = re.sub(r'\.\d{3}$', '', base_name) # Also remove if at the end
base_name = os.path.splitext(base_name)[0] # Remove standard extensions
# Remove or replace invalid characters for Windows/Mac/Linux
sanitized = re.sub(r'[<>:"/\\|?*]', '_', base_name)
@@ -539,8 +541,8 @@ class AutoMatExtractor(bpy.types.Operator):
# Must register the new dialog class as well
classes = (
AUTOMAT_OT_summary_dialog,
AutoMatExtractor,
RBST_AutoMat_OT_summary_dialog,
RBST_AutoMat_OT_AutoMatExtractor,
)
def register():
@@ -1,6 +1,6 @@
import bpy
class BST_FreeGPU(bpy.types.Operator):
class RBST_FreeGPU(bpy.types.Operator):
bl_idname = "bst.free_gpu"
bl_label = "Free VRAM"
bl_description = "Unallocate all material images from VRAM"
@@ -2,7 +2,7 @@ import bpy
import re
from ..utils import compat
class RENAME_OT_summary_dialog(bpy.types.Operator):
class RBST_RenameImg_OT_summary_dialog(bpy.types.Operator):
"""Show rename operation summary"""
bl_idname = "bst.rename_summary_dialog"
bl_label = "Rename Summary"
@@ -66,7 +66,7 @@ class RENAME_OT_summary_dialog(bpy.types.Operator):
def invoke(self, context, event):
return context.window_manager.invoke_popup(self, width=500)
class Rename_images_by_mat(bpy.types.Operator):
class RBST_RenameImg_OT_Rename_images_by_mat(bpy.types.Operator):
bl_idname = "bst.rename_images_by_mat"
bl_label = "Rename Images by Material"
bl_description = "Rename selected images based on their material usage, preserving texture type suffixes"
@@ -500,8 +500,8 @@ class Rename_images_by_mat(bpy.types.Operator):
# Registration classes - need to register both operators
classes = (
RENAME_OT_summary_dialog,
Rename_images_by_mat,
RBST_RenameImg_OT_summary_dialog,
RBST_RenameImg_OT_Rename_images_by_mat,
)
def register():
@@ -1,5 +1,32 @@
import bpy
from ..utils import version
def _collect_keyframe_stats(action):
"""Return (total_keyframes, keyframe_frames_set). Compatible with 4.2/4.5 (action.fcurves) and 5.0 (layers/strips/channelbags)."""
keyframe_frames = set()
total_keyframes = 0
if version.is_version_less_than(5, 0, 0):
for fcurve in action.fcurves:
for kf in fcurve.keyframe_points:
keyframe_frames.add(kf.co[0])
total_keyframes += 1
else:
# Blender 5.0+: legacy action.fcurves removed; use layers → strips → channelbag(slot).fcurves
for layer in action.layers:
for strip in layer.strips:
for slot in action.slots:
channelbag = strip.channelbag(slot, ensure=False)
if channelbag is None:
continue
for fcurve in channelbag.fcurves:
for kf in fcurve.keyframe_points:
keyframe_frames.add(kf.co[0])
total_keyframes += 1
return total_keyframes, keyframe_frames
class DeleteSingleKeyframeActions(bpy.types.Operator):
"""Delete actions that have no keyframes, only one keyframe, or all keyframes on the same frame"""
bl_idname = "bst.delete_single_keyframe_actions"
@@ -12,13 +39,9 @@ class DeleteSingleKeyframeActions(bpy.types.Operator):
actions_to_delete = []
for action in actions:
keyframe_frames = set()
total_keyframes = 0
for fcurve in action.fcurves:
for kf in fcurve.keyframe_points:
keyframe_frames.add(kf.co[0])
total_keyframes += 1
if getattr(action, "library", None) is not None:
continue
total_keyframes, keyframe_frames = _collect_keyframe_stats(action)
# No keyframes
if total_keyframes == 0:
actions_to_delete.append(action)
@@ -1,157 +0,0 @@
import bpy
class MATERIAL_USERS_OT_summary_dialog(bpy.types.Operator):
"""Show material users analysis in a popup dialog"""
bl_idname = "bst.material_users_summary_dialog"
bl_label = "Material Users Summary"
bl_options = {'REGISTER', 'INTERNAL'}
# Properties to store summary data
material_name: bpy.props.StringProperty(default="")
users_count: bpy.props.IntProperty(default=0)
fake_user: bpy.props.BoolProperty(default=False)
object_users: bpy.props.StringProperty(default="")
node_users: bpy.props.StringProperty(default="")
material_node_users: bpy.props.StringProperty(default="")
total_user_count: bpy.props.IntProperty(default=0)
def draw(self, context):
layout = self.layout
# Title
layout.label(text=f"Material Users - '{self.material_name}'", icon='MATERIAL')
layout.separator()
# Basic info box
box = layout.box()
col = box.column(align=True)
col.label(text=f"Blender Users Count: {self.users_count}")
col.label(text=f"Fake User: {'Yes' if self.fake_user else 'No'}")
col.label(text=f"Total Found Users: {self.total_user_count}")
layout.separator()
# Object users section
if self.object_users:
layout.label(text="Object Users:", icon='OBJECT_DATA')
objects_box = layout.box()
objects_col = objects_box.column(align=True)
for obj_name in self.object_users.split('|'):
if obj_name.strip():
objects_col.label(text=f"{obj_name}", icon='RIGHTARROW_THIN')
else:
layout.label(text="Object Users: None", icon='OBJECT_DATA')
# Node tree users section
if self.node_users:
layout.separator()
layout.label(text="Node Tree Users:", icon='NODETREE')
nodes_box = layout.box()
nodes_col = nodes_box.column(align=True)
for node_ref in self.node_users.split('|'):
if node_ref.strip():
nodes_col.label(text=f"{node_ref}", icon='RIGHTARROW_THIN')
# Material node tree users section
if self.material_node_users:
layout.separator()
layout.label(text="Material Node Tree Users:", icon='MATERIAL')
mat_nodes_box = layout.box()
mat_nodes_col = mat_nodes_box.column(align=True)
for mat_node_ref in self.material_node_users.split('|'):
if mat_node_ref.strip():
mat_nodes_col.label(text=f"{mat_node_ref}", icon='RIGHTARROW_THIN')
layout.separator()
def execute(self, context):
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_popup(self, width=500)
class FindMaterialUsers(bpy.types.Operator):
"""Find all users of a specified material and display detailed information"""
bl_idname = "bst.find_material_users"
bl_label = "Find Material Users"
bl_description = "Find and display all users of a specified material"
bl_options = {'REGISTER'}
material_name: bpy.props.StringProperty(
name="Material",
description="Name of the material to analyze",
default="",
)
def draw(self, context):
layout = self.layout
# Set the material if we have a name
if self.material_name and self.material_name in bpy.data.materials:
context.scene.bst_temp_material = bpy.data.materials[self.material_name]
# Use template_ID to get the proper material selector (without new button)
layout.template_ID(context.scene, "bst_temp_material", text="Material")
def execute(self, context):
# Get the material from the temp property
material = getattr(context.scene, 'bst_temp_material', None)
if not material:
self.report({'ERROR'}, "No material selected")
return {'CANCELLED'}
# Update our material_name property
self.material_name = material.name
# Check objects
object_users = []
for obj in bpy.data.objects:
if obj.material_slots:
for slot in obj.material_slots:
if slot.material == material:
object_users.append(obj.name)
break
# Check node groups more thoroughly
node_users = []
for node_tree in bpy.data.node_groups:
for node in node_tree.nodes:
# Check material nodes
if hasattr(node, 'material') and node.material == material:
node_users.append(f"{node_tree.name}.{node.name}")
# Check material input sockets
for input_socket in node.inputs:
if hasattr(input_socket, 'default_value') and hasattr(input_socket.default_value, 'name'):
if input_socket.default_value.name == material.name:
node_users.append(f"{node_tree.name}.{node.name}.{input_socket.name}")
# Check material node trees
material_node_users = []
for mat in bpy.data.materials:
if mat.node_tree:
for node in mat.node_tree.nodes:
if hasattr(node, 'material') and node.material == material:
material_node_users.append(f"{mat.name}.{node.name}")
# Show summary dialog
self.show_summary_dialog(context, material, object_users, node_users, material_node_users)
return {'FINISHED'}
def show_summary_dialog(self, context, material, object_users, node_users, material_node_users):
"""Show the material users summary in a popup dialog"""
total_user_count = len(object_users) + len(node_users) + len(material_node_users)
# Create and configure the summary dialog
dialog_op = bpy.ops.bst.material_users_summary_dialog
dialog_op('INVOKE_DEFAULT',
material_name=material.name,
users_count=material.users,
fake_user=material.use_fake_user,
object_users='|'.join(object_users),
node_users='|'.join(node_users),
material_node_users='|'.join(material_node_users),
total_user_count=total_user_count)
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
@@ -369,7 +369,7 @@ def main(delete_low_priority=False):
print(f"Object ghosts removed: {object_ghosts_removed}")
print("="*80)
class GhostBuster(bpy.types.Operator):
class RBST_Bustin_OT_GhostBuster(bpy.types.Operator):
"""Conservative cleanup of ghost data (unused WGT objects, empty collections)"""
bl_idname = "bst.ghost_buster"
bl_label = "Ghost Buster"
@@ -388,7 +388,7 @@ class GhostBuster(bpy.types.Operator):
self.report({'ERROR'}, f"Ghost buster failed: {str(e)}")
return {'CANCELLED'}
class GhostDetector(bpy.types.Operator):
class RBST_Bustin_OT_GhostDetector(bpy.types.Operator):
"""Detect and analyze ghost data without removing it"""
bl_idname = "bst.ghost_detector"
bl_label = "Ghost Detector"
@@ -606,7 +606,7 @@ class GhostDetector(bpy.types.Operator):
self.analyze_ghost_data()
return context.window_manager.invoke_popup(self, width=500)
class ResyncEnforce(bpy.types.Operator):
class RBST_Bustin_OT_ResyncEnforce(bpy.types.Operator):
"""Resync Enforce: Fix broken library override hierarchies by rebuilding from linked references"""
bl_idname = "bst.resync_enforce"
bl_label = "Resync Enforce"
@@ -674,9 +674,9 @@ class ResyncEnforce(bpy.types.Operator):
# List of classes to register
classes = (
GhostBuster,
GhostDetector,
ResyncEnforce,
RBST_Bustin_OT_GhostBuster,
RBST_Bustin_OT_GhostDetector,
RBST_Bustin_OT_ResyncEnforce,
)
def register():
@@ -81,6 +81,23 @@ class SpawnSceneStructure(bpy.types.Operator):
layer_collection = self.find_layer_collection(view_layer.layer_collection, subcollection_name)
if layer_collection:
layer_collection.exclude = True
# Create Props collection under Char
if subcollection_name == "Char":
char_collection = existing_subcollection if subcollection_exists else subcollection
props_exists = False
existing_props = None
for child in char_collection.children:
if child.name == "Props":
props_exists = True
existing_props = child
skipped_collections.append(f"{main_collection_name}/{subcollection_name}/Props")
break
if not props_exists:
props_collection = bpy.data.collections.new("Props")
char_collection.children.link(props_collection)
created_collections.append(f"{main_collection_name}/{subcollection_name}/Props")
# Report results
if created_collections:
@@ -0,0 +1,28 @@
import bpy
class WhiteWorld(bpy.types.Operator):
"""Create a pure-white world and set it active; remove 'Dual Node Background' if present, enable transparent film"""
bl_idname = "bst.white_world"
bl_label = "White World"
bl_description = "Create white background world, set film transparent, purge orphans"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
if "Dual Node Background" in bpy.data.worlds:
w = bpy.data.worlds["Dual Node Background"]
bpy.data.worlds.remove(w, do_unlink=True)
new_world = bpy.data.worlds.new(name="World")
new_world.use_nodes = True
nodes = new_world.node_tree.nodes
links = new_world.node_tree.links
nodes.clear()
bg = nodes.new(type="ShaderNodeBackground")
bg.inputs[0].default_value = (1.0, 1.0, 1.0, 1.0)
out = nodes.new(type="ShaderNodeOutputWorld")
links.new(bg.outputs[0], out.inputs[0])
context.scene.world = new_world
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
context.scene.render.film_transparent = True
self.report({'INFO'}, "White world set, film transparent, orphans purged")
return {'FINISHED'}
@@ -5,14 +5,14 @@ import sys
import subprocess
# Import ghost buster functionality
from ..ops.ghost_buster import GhostBuster, GhostDetector, ResyncEnforce
from ..ops.ghost_buster import RBST_Bustin_OT_GhostBuster, RBST_Bustin_OT_GhostDetector, RBST_Bustin_OT_ResyncEnforce
from ..utils import compat
# Regular expression to match numbered suffixes like .001, .002, _001, _0001, etc.
NUMBERED_SUFFIX_PATTERN = re.compile(r'(.*?)[._](\d{3,})$')
RBST_DatRem_NUMBERED_SUFFIX_PATTERN = re.compile(r'(.*?)[._](\d{3,})$')
# Function to check if any datablocks in a collection are linked from a library
def has_linked_datablocks(data_collection):
def RBST_DatRem_has_linked_datablocks(data_collection):
"""Check if any datablocks in the collection are linked from a library"""
for data in data_collection:
if data.users > 0 and hasattr(data, 'library') and data.library is not None:
@@ -20,7 +20,7 @@ def has_linked_datablocks(data_collection):
return False
# Register properties for data remap settings
def register_dataremap_properties():
def RBST_DatRem_register_properties():
bpy.types.Scene.dataremap_images = bpy.props.BoolProperty( # type: ignore
name="Images",
description="Find and remap duplicate images",
@@ -136,7 +136,7 @@ def register_dataremap_properties():
default=False
)
def unregister_dataremap_properties():
def RBST_DatRem_unregister_properties():
del bpy.types.Scene.dataremap_images
del bpy.types.Scene.dataremap_materials
del bpy.types.Scene.dataremap_fonts
@@ -162,14 +162,14 @@ def unregister_dataremap_properties():
if hasattr(bpy.types.Scene, "ghost_buster_delete_low_priority"):
del bpy.types.Scene.ghost_buster_delete_low_priority
def get_base_name(name):
def RBST_DatRem_get_base_name(name):
"""Extract the base name without numbered suffix"""
match = NUMBERED_SUFFIX_PATTERN.match(name)
match = RBST_DatRem_NUMBERED_SUFFIX_PATTERN.match(name)
if match:
return match.group(1) # Return the base name
return name
def find_data_groups(data_collection):
def RBST_DatRem_find_data_groups(data_collection):
"""Group data blocks by their base name, excluding those with no users or linked from libraries"""
groups = {}
@@ -182,7 +182,7 @@ def find_data_groups(data_collection):
if hasattr(data, 'library') and data.library is not None:
continue
base_name = get_base_name(data.name)
base_name = RBST_DatRem_get_base_name(data.name)
# Only group local datablocks
if base_name not in groups:
@@ -194,7 +194,7 @@ def find_data_groups(data_collection):
return {name: items for name, items in groups.items()
if len(items) > 1 and any(not (hasattr(item, 'library') and item.library is not None) for item in items)}
def find_target_data(data_group):
def RBST_DatRem_find_target_data(data_group):
"""Find the target data block to remap to"""
# Filter out linked datablocks
local_data_group = [data for data in data_group if not (hasattr(data, 'library') and data.library is not None)]
@@ -205,7 +205,7 @@ def find_target_data(data_group):
# First, try to find a data block without a numbered suffix
for data in local_data_group:
if get_base_name(data.name) == data.name:
if RBST_DatRem_get_base_name(data.name) == data.name:
return data
# If no unnumbered version exists, find the "youngest" version (highest number)
@@ -213,7 +213,7 @@ def find_target_data(data_group):
highest_suffix = 0
for data in local_data_group:
match = NUMBERED_SUFFIX_PATTERN.match(data.name)
match = RBST_DatRem_NUMBERED_SUFFIX_PATTERN.match(data.name)
if match:
suffix_num = int(match.group(2))
if suffix_num > highest_suffix:
@@ -222,7 +222,7 @@ def find_target_data(data_group):
return youngest
def clean_data_names(data_collection):
def RBST_DatRem_clean_data_names(data_collection):
"""Remove numbered suffixes from all data blocks with users"""
cleaned_count = 0
@@ -235,14 +235,14 @@ def clean_data_names(data_collection):
if hasattr(data, 'library') and data.library is not None:
continue
base_name = get_base_name(data.name)
base_name = RBST_DatRem_get_base_name(data.name)
if base_name != data.name:
data.name = base_name
cleaned_count += 1
return cleaned_count
def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap_worlds):
def RBST_DatRem_remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap_worlds):
"""Remap redundant data blocks to their base versions like Blender's Remap Users function, and clean up names."""
remapped_count = 0
cleaned_count = 0
@@ -250,18 +250,18 @@ def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap
# Process images
if remap_images:
# First remap duplicates
image_groups = find_data_groups(bpy.data.images)
image_groups = RBST_DatRem_find_data_groups(bpy.data.images)
for base_name, images in image_groups.items():
# Skip excluded groups
if f"images:{base_name}" in context.scene.excluded_remap_groups:
continue
target_image = find_target_data(images)
target_image = RBST_DatRem_find_target_data(images)
# Rename the target if it has a numbered suffix and is the youngest
if get_base_name(target_image.name) != target_image.name:
if RBST_DatRem_get_base_name(target_image.name) != target_image.name:
try:
target_image.name = get_base_name(target_image.name)
target_image.name = RBST_DatRem_get_base_name(target_image.name)
except AttributeError:
# Skip if the target is linked and can't be renamed
print(f"Warning: Cannot rename linked image {target_image.name}")
@@ -323,23 +323,23 @@ def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap
# This matches Blender's Remap Users behavior
# Then clean up any remaining numbered suffixes
cleaned_count += clean_data_names(bpy.data.images)
cleaned_count += RBST_DatRem_clean_data_names(bpy.data.images)
# Process materials
if remap_materials:
# First remap duplicates
material_groups = find_data_groups(bpy.data.materials)
material_groups = RBST_DatRem_find_data_groups(bpy.data.materials)
for base_name, materials in material_groups.items():
# Skip excluded groups
if f"materials:{base_name}" in context.scene.excluded_remap_groups:
continue
target_material = find_target_data(materials)
target_material = RBST_DatRem_find_target_data(materials)
# Rename the target if it has a numbered suffix and is the youngest
if get_base_name(target_material.name) != target_material.name:
if RBST_DatRem_get_base_name(target_material.name) != target_material.name:
try:
target_material.name = get_base_name(target_material.name)
target_material.name = RBST_DatRem_get_base_name(target_material.name)
except AttributeError:
# Skip if the target is linked and can't be renamed
print(f"Warning: Cannot rename linked material {target_material.name}")
@@ -443,23 +443,23 @@ def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap
# This matches Blender's Remap Users behavior
# Then clean up any remaining numbered suffixes
cleaned_count += clean_data_names(bpy.data.materials)
cleaned_count += RBST_DatRem_clean_data_names(bpy.data.materials)
# Process fonts
if remap_fonts:
# First remap duplicates
font_groups = find_data_groups(bpy.data.fonts)
font_groups = RBST_DatRem_find_data_groups(bpy.data.fonts)
for base_name, fonts in font_groups.items():
# Skip excluded groups
if f"fonts:{base_name}" in context.scene.excluded_remap_groups:
continue
target_font = find_target_data(fonts)
target_font = RBST_DatRem_find_target_data(fonts)
# Rename the target if it has a numbered suffix and is the youngest
if get_base_name(target_font.name) != target_font.name:
if RBST_DatRem_get_base_name(target_font.name) != target_font.name:
try:
target_font.name = get_base_name(target_font.name)
target_font.name = RBST_DatRem_get_base_name(target_font.name)
except AttributeError:
# Skip if the target is linked and can't be renamed
print(f"Warning: Cannot rename linked font {target_font.name}")
@@ -519,23 +519,23 @@ def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap
# This matches Blender's Remap Users behavior
# Then clean up any remaining numbered suffixes
cleaned_count += clean_data_names(bpy.data.fonts)
cleaned_count += RBST_DatRem_clean_data_names(bpy.data.fonts)
# Process worlds
if remap_worlds:
# First remap duplicates
world_groups = find_data_groups(bpy.data.worlds)
world_groups = RBST_DatRem_find_data_groups(bpy.data.worlds)
for base_name, worlds in world_groups.items():
# Skip excluded groups
if f"worlds:{base_name}" in context.scene.excluded_remap_groups:
continue
target_world = find_target_data(worlds)
target_world = RBST_DatRem_find_target_data(worlds)
# Rename the target if it has a numbered suffix and is the youngest
if get_base_name(target_world.name) != target_world.name:
if RBST_DatRem_get_base_name(target_world.name) != target_world.name:
try:
target_world.name = get_base_name(target_world.name)
target_world.name = RBST_DatRem_get_base_name(target_world.name)
except AttributeError:
# Skip if the target is linked and can't be renamed
print(f"Warning: Cannot rename linked world {target_world.name}")
@@ -585,7 +585,7 @@ def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap
# This matches Blender's Remap Users behavior
# Then clean up any remaining numbered suffixes
cleaned_count += clean_data_names(bpy.data.worlds)
cleaned_count += RBST_DatRem_clean_data_names(bpy.data.worlds)
# Force an update of the dependency graph to ensure all users are properly updated
if context.view_layer:
@@ -593,7 +593,7 @@ def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap
return remapped_count, cleaned_count
class DATAREMAP_OT_RemapData(bpy.types.Operator):
class RBST_DatRem_OT_RemapData(bpy.types.Operator):
"""Remap redundant data blocks to reduce duplicates"""
bl_idname = "bst.bulk_data_remap"
bl_label = "Remap Data"
@@ -607,10 +607,10 @@ class DATAREMAP_OT_RemapData(bpy.types.Operator):
remap_worlds = context.scene.dataremap_worlds
# Count duplicates before remapping (only for local datablocks)
image_groups = find_data_groups(bpy.data.images) if remap_images else {}
material_groups = find_data_groups(bpy.data.materials) if remap_materials else {}
font_groups = find_data_groups(bpy.data.fonts) if remap_fonts else {}
world_groups = find_data_groups(bpy.data.worlds) if remap_worlds else {}
image_groups = RBST_DatRem_find_data_groups(bpy.data.images) if remap_images else {}
material_groups = RBST_DatRem_find_data_groups(bpy.data.materials) if remap_materials else {}
font_groups = RBST_DatRem_find_data_groups(bpy.data.fonts) if remap_fonts else {}
world_groups = RBST_DatRem_find_data_groups(bpy.data.worlds) if remap_worlds else {}
total_duplicates = sum(len(group) - 1 for groups in [image_groups, material_groups, font_groups, world_groups] for group in groups.values())
@@ -620,29 +620,29 @@ class DATAREMAP_OT_RemapData(bpy.types.Operator):
total_numbered += sum(1 for img in bpy.data.images
if img.users > 0
and not (hasattr(img, 'library') and img.library is not None)
and get_base_name(img.name) != img.name)
and RBST_DatRem_get_base_name(img.name) != img.name)
if remap_materials:
total_numbered += sum(1 for mat in bpy.data.materials
if mat.users > 0
and not (hasattr(mat, 'library') and mat.library is not None)
and get_base_name(mat.name) != mat.name)
and RBST_DatRem_get_base_name(mat.name) != mat.name)
if remap_fonts:
total_numbered += sum(1 for font in bpy.data.fonts
if font.users > 0
and not (hasattr(font, 'library') and font.library is not None)
and get_base_name(font.name) != font.name)
and RBST_DatRem_get_base_name(font.name) != font.name)
if remap_worlds:
total_numbered += sum(1 for world in bpy.data.worlds
if world.users > 0
and not (hasattr(world, 'library') and world.library is not None)
and get_base_name(world.name) != world.name)
and RBST_DatRem_get_base_name(world.name) != world.name)
if total_duplicates == 0 and total_numbered == 0:
self.report({'INFO'}, "No local data blocks to process")
return {'CANCELLED'}
# Perform the remapping and cleaning
remapped_count, cleaned_count = remap_data_blocks(
remapped_count, cleaned_count = RBST_DatRem_remap_data_blocks(
context,
remap_images,
remap_materials,
@@ -662,8 +662,80 @@ class DATAREMAP_OT_RemapData(bpy.types.Operator):
return {'FINISHED'}
# Add a new operator for merging duplicates using data-block utilities
class RBST_DatRem_OT_MergeDuplicatesWithDBU(bpy.types.Operator):
"""Merge duplicates using data-block utilities addon for all supported data types"""
bl_idname = "bst.merge_duplicates_dbu"
bl_label = "Merge Duplicates (DBU)"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
# Check if data-block utilities addon is installed
if not hasattr(context.scene, 'dbu_similar_settings'):
self.report({'ERROR'}, "Data-block utilities addon is not installed or enabled")
return {'CANCELLED'}
# Data types to process in order
data_types = ['NODETREE', 'MATERIAL', 'LIGHT', 'IMAGE', 'MESH']
type_labels = {
'NODETREE': 'Node Groups',
'MATERIAL': 'Materials',
'LIGHT': 'Lights',
'IMAGE': 'Images',
'MESH': 'Meshes'
}
total_merged = 0
processed_types = []
try:
settings = context.scene.dbu_similar_settings
for id_type in data_types:
# Set the id_type
settings.id_type = id_type
# Find similar and duplicates
try:
bpy.ops.scene.dbu_find_similar_and_duplicates()
except Exception as e:
self.report({'WARNING'}, f"Failed to find duplicates for {type_labels[id_type]}: {str(e)}")
continue
# Check if any duplicates were found
if not settings.duplicates:
continue
# Count items to be merged (each group has duplicates, so count items - groups)
# Each group keeps one item, so we count (total items - number of groups)
total_items = sum(len(group.group) for group in settings.duplicates)
num_groups = len(settings.duplicates)
items_to_remove = total_items - num_groups # One item per group is kept
# Merge duplicates
try:
bpy.ops.scene.dbu_merge_duplicates()
total_merged += items_to_remove
processed_types.append(f"{type_labels[id_type]} ({items_to_remove})")
except Exception as e:
self.report({'WARNING'}, f"Failed to merge duplicates for {type_labels[id_type]}: {str(e)}")
continue
# Report results
if total_merged > 0:
types_str = ", ".join(processed_types)
self.report({'INFO'}, f"Merged {total_merged} duplicate(s) across: {types_str}")
else:
self.report({'INFO'}, "No duplicates found to merge")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Error during merge operation: {str(e)}")
return {'CANCELLED'}
# Add a new operator for purging unused data
class DATAREMAP_OT_PurgeUnused(bpy.types.Operator):
class RBST_DatRem_OT_PurgeUnused(bpy.types.Operator):
"""Purge all unused data-blocks from the file (equivalent to File > Clean Up > Purge Unused Data)"""
bl_idname = "bst.purge_unused_data"
bl_label = "Purge Unused Data"
@@ -678,7 +750,7 @@ class DATAREMAP_OT_PurgeUnused(bpy.types.Operator):
return {'FINISHED'}
# Add a new operator for toggling group exclusion
class DATAREMAP_OT_ToggleGroupExclusion(bpy.types.Operator):
class RBST_DatRem_OT_ToggleGroupExclusion(bpy.types.Operator):
"""Toggle whether this group should be included in remapping"""
bl_idname = "bst.toggle_group_exclusion"
bl_label = "Toggle Group"
@@ -712,7 +784,7 @@ class DATAREMAP_OT_ToggleGroupExclusion(bpy.types.Operator):
return {'FINISHED'}
class DATAREMAP_OT_SelectAllGroups(bpy.types.Operator):
class RBST_DatRem_OT_SelectAllGroups(bpy.types.Operator):
"""Select or deselect all groups of a specific data type"""
bl_idname = "bst.select_all_data_groups"
bl_label = "Select All Groups"
@@ -738,13 +810,13 @@ class DATAREMAP_OT_SelectAllGroups(bpy.types.Operator):
# Get the appropriate data groups based on data_type
data_groups = {}
if self.data_type == "images":
data_groups = find_data_groups(bpy.data.images)
data_groups = RBST_DatRem_find_data_groups(bpy.data.images)
elif self.data_type == "materials":
data_groups = find_data_groups(bpy.data.materials)
data_groups = RBST_DatRem_find_data_groups(bpy.data.materials)
elif self.data_type == "fonts":
data_groups = find_data_groups(bpy.data.fonts)
data_groups = RBST_DatRem_find_data_groups(bpy.data.fonts)
elif self.data_type == "worlds":
data_groups = find_data_groups(bpy.data.worlds)
data_groups = RBST_DatRem_find_data_groups(bpy.data.worlds)
# Process only groups with more than one item
for base_name, items in data_groups.items():
@@ -762,7 +834,7 @@ class DATAREMAP_OT_SelectAllGroups(bpy.types.Operator):
return {'FINISHED'}
# Update the toggle group selection operator to handle shift-click range selection
class DATAREMAP_OT_ToggleGroupSelection(bpy.types.Operator):
class RBST_DatRem_OT_ToggleGroupSelection(bpy.types.Operator):
"""Toggle whether this group should be included in remapping"""
bl_idname = "bst.toggle_group_selection"
bl_label = "Toggle Group Selection"
@@ -803,13 +875,13 @@ class DATAREMAP_OT_ToggleGroupSelection(bpy.types.Operator):
# Get all data groups for this data type
data_groups = []
if self.data_type == "images":
data_groups = list(find_data_groups(bpy.data.images).keys())
data_groups = list(RBST_DatRem_find_data_groups(bpy.data.images).keys())
elif self.data_type == "materials":
data_groups = list(find_data_groups(bpy.data.materials).keys())
data_groups = list(RBST_DatRem_find_data_groups(bpy.data.materials).keys())
elif self.data_type == "fonts":
data_groups = list(find_data_groups(bpy.data.fonts).keys())
data_groups = list(RBST_DatRem_find_data_groups(bpy.data.fonts).keys())
elif self.data_type == "worlds":
data_groups = list(find_data_groups(bpy.data.worlds).keys())
data_groups = list(RBST_DatRem_find_data_groups(bpy.data.worlds).keys())
# Find the indices of the last clicked group and the current group
try:
@@ -869,7 +941,7 @@ class DATAREMAP_OT_ToggleGroupSelection(bpy.types.Operator):
return {'FINISHED'}
# Add a custom draw function for checkboxes that supports drag selection
def draw_drag_selectable_checkbox(layout, context, data_type, group_key):
def RBST_DatRem_draw_drag_selectable_checkbox(layout, context, data_type, group_key):
"""Draw a checkbox that supports drag selection"""
# Create a unique key for this group
key = f"{data_type}:{group_key}"
@@ -885,7 +957,7 @@ def draw_drag_selectable_checkbox(layout, context, data_type, group_key):
op.group_key = group_key
op.data_type = data_type
def search_matches_group(group, search_string):
def RBST_DatRem_search_matches_group(group, search_string):
"""Check if search string matches group base name or any item in group"""
if not search_string:
return True
@@ -901,7 +973,7 @@ def search_matches_group(group, search_string):
return False
# Update the UI code to use the custom draw function
def draw_data_duplicates(layout, context, data_type, data_groups):
def RBST_DatRem_draw_data_duplicates(layout, context, data_type, data_groups):
"""Draw the list of duplicate data items with drag-selectable checkboxes and click to rename"""
box_dup = layout.box()
@@ -945,7 +1017,7 @@ def draw_data_duplicates(layout, context, data_type, data_groups):
search_string = getattr(context.scene, search_prop_name)
if search_string:
group_items = [group for group in group_items if search_matches_group(group, search_string)]
group_items = [group for group in group_items if RBST_DatRem_search_matches_group(group, search_string)]
# Sort by selection if enabled
sort_prop_name = f"dataremap_sort_{data_type}"
@@ -965,7 +1037,7 @@ def draw_data_duplicates(layout, context, data_type, data_groups):
row = box_dup.row()
# Add checkbox to include/exclude this group using the custom draw function
draw_drag_selectable_checkbox(row, context, data_type, base_name)
RBST_DatRem_draw_drag_selectable_checkbox(row, context, data_type, base_name)
# Add dropdown toggle
group_key = f"{data_type}:{base_name}"
@@ -979,7 +1051,7 @@ def draw_data_duplicates(layout, context, data_type, data_groups):
exp_op.data_type = data_type
# Find the original data item (target)
target_item = find_target_data(items)
target_item = RBST_DatRem_find_target_data(items)
# Add icon based on data type
if data_type == "images":
@@ -1046,10 +1118,10 @@ def draw_data_duplicates(layout, context, data_type, data_groups):
rename_op.data_type = data_type
rename_op.old_name = item.name
class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
class RBST_DatRem_PT_BulkDataRemap(bpy.types.Panel):
"""Bulk Data Remap Panel"""
bl_label = "Bulk Data Remap"
bl_idname = "VIEW3D_PT_bulk_data_remap"
bl_idname = "RBST_DatRem_PT_bulk_data_remap"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Edit'
@@ -1068,25 +1140,25 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
linked_types = []
linked_paths = set()
if context.scene.dataremap_images and has_linked_datablocks(bpy.data.images):
if context.scene.dataremap_images and RBST_DatRem_has_linked_datablocks(bpy.data.images):
linked_datablocks_found = True
linked_types.append("images")
linked_paths.update(get_linked_file_paths(bpy.data.images))
linked_paths.update(RBST_DatRem_get_linked_file_paths(bpy.data.images))
if context.scene.dataremap_materials and has_linked_datablocks(bpy.data.materials):
if context.scene.dataremap_materials and RBST_DatRem_has_linked_datablocks(bpy.data.materials):
linked_datablocks_found = True
linked_types.append("materials")
linked_paths.update(get_linked_file_paths(bpy.data.materials))
linked_paths.update(RBST_DatRem_get_linked_file_paths(bpy.data.materials))
if context.scene.dataremap_fonts and has_linked_datablocks(bpy.data.fonts):
if context.scene.dataremap_fonts and RBST_DatRem_has_linked_datablocks(bpy.data.fonts):
linked_datablocks_found = True
linked_types.append("fonts")
linked_paths.update(get_linked_file_paths(bpy.data.fonts))
linked_paths.update(RBST_DatRem_get_linked_file_paths(bpy.data.fonts))
if context.scene.dataremap_worlds and has_linked_datablocks(bpy.data.worlds):
if context.scene.dataremap_worlds and RBST_DatRem_has_linked_datablocks(bpy.data.worlds):
linked_datablocks_found = True
linked_types.append("worlds")
linked_paths.update(get_linked_file_paths(bpy.data.worlds))
linked_paths.update(RBST_DatRem_get_linked_file_paths(bpy.data.worlds))
# Display warning about linked datablocks in a separate section if found
if linked_datablocks_found:
@@ -1116,20 +1188,20 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
col = box.column(align=True)
# Count duplicates and numbered suffixes for each type
image_groups = find_data_groups(bpy.data.images)
material_groups = find_data_groups(bpy.data.materials)
font_groups = find_data_groups(bpy.data.fonts)
world_groups = find_data_groups(bpy.data.worlds)
image_groups = RBST_DatRem_find_data_groups(bpy.data.images)
material_groups = RBST_DatRem_find_data_groups(bpy.data.materials)
font_groups = RBST_DatRem_find_data_groups(bpy.data.fonts)
world_groups = RBST_DatRem_find_data_groups(bpy.data.worlds)
image_duplicates = sum(len(group) - 1 for group in image_groups.values())
material_duplicates = sum(len(group) - 1 for group in material_groups.values())
font_duplicates = sum(len(group) - 1 for group in font_groups.values())
world_duplicates = sum(len(group) - 1 for group in world_groups.values())
image_numbered = sum(1 for img in bpy.data.images if img.users > 0 and get_base_name(img.name) != img.name)
material_numbered = sum(1 for mat in bpy.data.materials if mat.users > 0 and get_base_name(mat.name) != mat.name)
font_numbered = sum(1 for font in bpy.data.fonts if font.users > 0 and get_base_name(font.name) != font.name)
world_numbered = sum(1 for world in bpy.data.worlds if world.users > 0 and get_base_name(world.name) != world.name)
image_numbered = sum(1 for img in bpy.data.images if img.users > 0 and RBST_DatRem_get_base_name(img.name) != img.name)
material_numbered = sum(1 for mat in bpy.data.materials if mat.users > 0 and RBST_DatRem_get_base_name(mat.name) != mat.name)
font_numbered = sum(1 for font in bpy.data.fonts if font.users > 0 and RBST_DatRem_get_base_name(font.name) != font.name)
world_numbered = sum(1 for world in bpy.data.worlds if world.users > 0 and RBST_DatRem_get_base_name(world.name) != world.name)
# Initialize excluded_remap_groups if it doesn't exist
if not hasattr(context.scene, "excluded_remap_groups"):
@@ -1165,7 +1237,7 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
# Show image duplicates if enabled
if context.scene.show_image_duplicates and image_duplicates > 0 and context.scene.dataremap_images:
draw_data_duplicates(col, context, "images", image_groups)
RBST_DatRem_draw_data_duplicates(col, context, "images", image_groups)
# Materials
row = col.row()
@@ -1196,7 +1268,7 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
# Show material duplicates if enabled
if context.scene.show_material_duplicates and material_duplicates > 0 and context.scene.dataremap_materials:
draw_data_duplicates(col, context, "materials", material_groups)
RBST_DatRem_draw_data_duplicates(col, context, "materials", material_groups)
# Fonts
row = col.row()
@@ -1227,7 +1299,7 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
# Show font duplicates if enabled
if context.scene.show_font_duplicates and font_duplicates > 0 and context.scene.dataremap_fonts:
draw_data_duplicates(col, context, "fonts", font_groups)
RBST_DatRem_draw_data_duplicates(col, context, "fonts", font_groups)
# World
row = col.row()
@@ -1258,13 +1330,30 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
# Show world duplicates if enabled
if context.scene.show_world_duplicates and world_duplicates > 0 and context.scene.dataremap_worlds:
draw_data_duplicates(col, context, "worlds", world_groups)
RBST_DatRem_draw_data_duplicates(col, context, "worlds", world_groups)
# Add the operator button
row = box.row()
row.scale_y = 1.5
row.operator("bst.bulk_data_remap")
# Add Data-Block Utils Integration section
box.separator()
dbu_box = box.box()
dbu_box.label(text="Data-Block Utils Integration")
dbu_col = dbu_box.column()
dbu_col.label(text="Merge duplicates using data-block utilities addon")
dbu_col.label(text="Processes: Node Groups, Materials, Lights, Images, Meshes")
# Check if data-block utilities addon is available
if hasattr(context.scene, 'dbu_similar_settings'):
dbu_row = dbu_box.row()
dbu_row.scale_y = 1.5
dbu_row.operator("bst.merge_duplicates_dbu", text="Merge Duplicates (DBU)", icon='FILE_PARENT')
else:
dbu_box.alert = True
dbu_box.label(text="Data-block utilities addon not installed", icon='ERROR')
# Show total counts
total_duplicates = image_duplicates + material_duplicates + font_duplicates + world_duplicates
total_numbered = image_numbered + material_numbered + font_numbered + world_numbered
@@ -1311,7 +1400,7 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
row.operator("bst.resync_enforce", text="Resync Enforce", icon='FILE_REFRESH')
# Add a new operator for toggling data types
class DATAREMAP_OT_ToggleDataType(bpy.types.Operator):
class RBST_DatRem_OT_ToggleDataType(bpy.types.Operator):
"""Toggle whether this data type should be included in remapping"""
bl_idname = "bst.toggle_data_type"
bl_label = "Toggle Data Type"
@@ -1336,7 +1425,7 @@ class DATAREMAP_OT_ToggleDataType(bpy.types.Operator):
return {'FINISHED'}
# Add a new operator for toggling group expansion
class DATAREMAP_OT_ToggleGroupExpansion(bpy.types.Operator):
class RBST_DatRem_OT_ToggleGroupExpansion(bpy.types.Operator):
"""Toggle whether this group should be expanded to show details"""
bl_idname = "bst.toggle_group_expansion"
bl_label = "Toggle Group Expansion"
@@ -1371,7 +1460,7 @@ class DATAREMAP_OT_ToggleGroupExpansion(bpy.types.Operator):
return {'FINISHED'}
# Function to get unique linked file paths from datablocks
def get_linked_file_paths(data_collection):
def RBST_DatRem_get_linked_file_paths(data_collection):
"""Get unique file paths of linked libraries from datablocks"""
linked_paths = set()
@@ -1382,7 +1471,7 @@ def get_linked_file_paths(data_collection):
return linked_paths
class DATAREMAP_OT_OpenLinkedFile(bpy.types.Operator):
class RBST_DatRem_OT_OpenLinkedFile(bpy.types.Operator):
"""Open the linked file in a new Blender instance"""
bl_idname = "bst.open_linked_file"
bl_label = "Open Linked File"
@@ -1411,7 +1500,7 @@ class DATAREMAP_OT_OpenLinkedFile(bpy.types.Operator):
return {'FINISHED'}
# Add a new operator for renaming datablocks
class DATAREMAP_OT_RenameDatablock(bpy.types.Operator):
class RBST_DatRem_OT_RenameDatablock(bpy.types.Operator):
"""Click to rename datablock"""
bl_idname = "bst.rename_datablock_remap"
bl_label = "Rename Datablock"
@@ -1483,21 +1572,22 @@ class DATAREMAP_OT_RenameDatablock(bpy.types.Operator):
# List of all classes in this module
classes = (
DATAREMAP_OT_RemapData,
DATAREMAP_OT_PurgeUnused,
DATAREMAP_OT_ToggleDataType,
DATAREMAP_OT_ToggleGroupExclusion,
DATAREMAP_OT_SelectAllGroups,
VIEW3D_PT_BulkDataRemap,
DATAREMAP_OT_ToggleGroupExpansion,
DATAREMAP_OT_ToggleGroupSelection,
DATAREMAP_OT_OpenLinkedFile,
DATAREMAP_OT_RenameDatablock,
RBST_DatRem_OT_RemapData,
RBST_DatRem_OT_MergeDuplicatesWithDBU,
RBST_DatRem_OT_PurgeUnused,
RBST_DatRem_OT_ToggleDataType,
RBST_DatRem_OT_ToggleGroupExclusion,
RBST_DatRem_OT_SelectAllGroups,
RBST_DatRem_PT_BulkDataRemap,
RBST_DatRem_OT_ToggleGroupExpansion,
RBST_DatRem_OT_ToggleGroupSelection,
RBST_DatRem_OT_OpenLinkedFile,
RBST_DatRem_OT_RenameDatablock,
)
# Registration
def register():
register_dataremap_properties()
RBST_DatRem_register_properties()
for cls in classes:
compat.safe_register_class(cls)
@@ -1507,6 +1597,6 @@ def unregister():
compat.safe_unregister_class(cls)
# Unregister properties
try:
unregister_dataremap_properties()
RBST_DatRem_unregister_properties()
except Exception:
pass
@@ -5,7 +5,7 @@ import os
import re
from ..utils import compat
class REMOVE_EXT_OT_summary_dialog(bpy.types.Operator):
class RBST_PathMan_OT_summary_dialog(bpy.types.Operator):
"""Show remove extensions operation summary"""
bl_idname = "remove_ext.summary_dialog"
bl_label = "Remove Extensions Summary"
@@ -204,7 +204,7 @@ def bulk_remap_paths(mapping_dict):
return (success_count, failed_list)
# Properties for path management
class BST_PathProperties(PropertyGroup):
class RBST_PathMan_PG_PathProperties(PropertyGroup):
# Active image pointer
active_image: PointerProperty(
name="Image",
@@ -328,7 +328,7 @@ class BST_PathProperties(PropertyGroup):
)
# Operator to remap a single datablock path
class BST_OT_remap_path(Operator):
class RBST_PathMan_OT_remap_path(Operator):
bl_idname = "bst.remap_path"
bl_label = "Remap Path"
bl_description = "Change the filepath and filepath_raw for a datablock"
@@ -374,7 +374,7 @@ class BST_OT_remap_path(Operator):
return context.window_manager.invoke_props_dialog(self)
# Operator to toggle all image selections
class BST_OT_toggle_select_all(Operator):
class RBST_PathMan_OT_toggle_select_all(Operator):
bl_idname = "bst.toggle_select_all"
bl_label = "Toggle All"
bl_description = "Toggle selection of all datablocks"
@@ -394,7 +394,7 @@ class BST_OT_toggle_select_all(Operator):
return {'FINISHED'}
# Operator to remap multiple paths at once
class BST_OT_bulk_remap(Operator):
class RBST_PathMan_OT_bulk_remap(Operator):
bl_idname = "bst.bulk_remap"
bl_label = "Remap Paths"
bl_description = "Apply the new path to all selected datablocks"
@@ -493,7 +493,7 @@ class BST_OT_bulk_remap(Operator):
return 0.05 # Process next item in 0.05 seconds (50ms) for better stability
# Operator to toggle path editing mode
class BST_OT_toggle_path_edit(Operator):
class RBST_PathMan_OT_toggle_path_edit(Operator):
bl_idname = "bst.toggle_path_edit"
bl_label = "Toggle Path Edit"
bl_description = "Toggle between view and edit mode for paths"
@@ -535,7 +535,7 @@ class BST_OT_toggle_path_edit(Operator):
return {'FINISHED'}
# Operator to select all images used in the current material
class BST_OT_select_material_images(Operator):
class RBST_PathMan_OT_select_material_images(Operator):
bl_idname = "bst.select_material_images"
bl_label = "Select Material Images"
bl_description = "Select all images used in the current material in the node editor"
@@ -569,7 +569,7 @@ class BST_OT_select_material_images(Operator):
return {'FINISHED'}
# Operator to select active/selected image texture nodes
class BST_OT_select_active_images(Operator):
class RBST_PathMan_OT_select_active_images(Operator):
bl_idname = "bst.select_active_images"
bl_label = "Select Active Images"
bl_description = "Select all images from currently selected texture nodes in the node editor"
@@ -603,7 +603,7 @@ class BST_OT_select_active_images(Operator):
return {'FINISHED'}
# Operator to select all images with absolute paths
class BST_OT_select_absolute_images(Operator):
class RBST_PathMan_OT_select_absolute_images(Operator):
bl_idname = "bst.select_absolute_images"
bl_label = "Select Absolute Images"
bl_description = "Select all images with absolute file paths"
@@ -658,7 +658,7 @@ class BST_OT_select_absolute_images(Operator):
return {'FINISHED'}
# Add a class for renaming datablocks
class BST_OT_rename_datablock(Operator):
class RBST_PathMan_OT_rename_datablock(Operator):
"""Click to rename datablock"""
bl_idname = "bst.rename_datablock"
bl_label = "Rename Datablock"
@@ -706,7 +706,7 @@ class BST_OT_rename_datablock(Operator):
return {'CANCELLED'}
# Update class for shift+click selection
class BST_OT_toggle_image_selection(Operator):
class RBST_PathMan_OT_toggle_image_selection(Operator):
"""Toggle whether this image should be included in bulk operations"""
bl_idname = "bst.toggle_image_selection"
bl_label = "Toggle Image Selection"
@@ -759,7 +759,7 @@ class BST_OT_toggle_image_selection(Operator):
return {'FINISHED'}
# Add new operator for reusing material name in path
class BST_OT_reuse_material_path(Operator):
class RBST_PathMan_OT_reuse_material_path(Operator):
"""Use the active material's name in the path"""
bl_idname = "bst.reuse_material_path"
bl_label = "Use Material Path"
@@ -793,7 +793,7 @@ class BST_OT_reuse_material_path(Operator):
return {'CANCELLED'}
# Add new operator for reusing blend file name in path
class BST_OT_reuse_blend_name(Operator):
class RBST_PathMan_OT_reuse_blend_name(Operator):
"""Use the current blend file name in the path"""
bl_idname = "bst.reuse_blend_name"
bl_label = "Use Blend Name"
@@ -817,7 +817,7 @@ class BST_OT_reuse_blend_name(Operator):
return {'CANCELLED'}
# Make Paths Relative Operator
class BST_OT_make_paths_relative(Operator):
class RBST_PathMan_OT_make_paths_relative(Operator):
bl_idname = "bst.make_paths_relative"
bl_label = "Make Paths Relative"
bl_description = "Convert absolute paths to relative paths for all datablocks"
@@ -829,7 +829,7 @@ class BST_OT_make_paths_relative(Operator):
return {'FINISHED'}
# Make Paths Absolute Operator
class BST_OT_make_paths_absolute(Operator):
class RBST_PathMan_OT_make_paths_absolute(Operator):
bl_idname = "bst.make_paths_absolute"
bl_label = "Make Paths Absolute"
bl_description = "Convert relative paths to absolute paths for all datablocks"
@@ -841,7 +841,7 @@ class BST_OT_make_paths_absolute(Operator):
return {'FINISHED'}
# Pack Images Operator
class BST_OT_pack_images(Operator):
class RBST_PathMan_OT_pack_images(Operator):
bl_idname = "bst.pack_images"
bl_label = "Pack Images"
bl_description = "Pack selected images into the .blend file"
@@ -883,7 +883,7 @@ class BST_OT_pack_images(Operator):
return {'FINISHED'}
# Unpack Images Operator
class BST_OT_unpack_images(Operator):
class RBST_PathMan_OT_unpack_images(Operator):
bl_idname = "bst.unpack_images"
bl_label = "Unpack Images (Use Local)"
bl_description = "Unpack selected images to their file paths using the 'USE_LOCAL' option"
@@ -918,7 +918,7 @@ class BST_OT_unpack_images(Operator):
return {'FINISHED'}
# Remove Packed Images Operator
class BST_OT_remove_packed_images(Operator):
class RBST_PathMan_OT_remove_packed_images(Operator):
bl_idname = "bst.remove_packed_images"
bl_label = "Remove Packed Data"
bl_description = "Remove packed image data without saving to disk"
@@ -953,7 +953,7 @@ class BST_OT_remove_packed_images(Operator):
return {'FINISHED'}
# Save All Images Operator
class BST_OT_save_all_images(Operator):
class RBST_PathMan_OT_save_all_images(Operator):
bl_idname = "bst.save_all_images"
bl_label = "Save All Images"
bl_description = "Save all selected images to image paths"
@@ -1059,7 +1059,7 @@ class BST_OT_save_all_images(Operator):
return 0.05 # Process next item in 0.05 seconds (50ms) for better stability
# Remove Extensions Operator
class BST_OT_remove_extensions(Operator):
class RBST_PathMan_OT_remove_extensions(Operator):
bl_idname = "bst.remove_extensions"
bl_label = "Remove Extensions"
bl_description = "Remove common file extensions from selected image datablock names."
@@ -1148,7 +1148,7 @@ class BST_OT_remove_extensions(Operator):
removal_details=details_text.strip())
# Add new operator for flat color texture renaming
class BST_OT_rename_flat_colors(Operator):
class RBST_PathMan_OT_rename_flat_colors(Operator):
"""Rename flat color textures to their hex color values"""
bl_idname = "bst.rename_flat_colors"
bl_label = "Rename Flat Colors"
@@ -1354,7 +1354,7 @@ class BST_OT_rename_flat_colors(Operator):
return 0.05 # Process next item in 0.05 seconds (50ms) for better stability
# Cancel Operation Operator
class BST_OT_cancel_operation(Operator):
class RBST_PathMan_OT_cancel_operation(Operator):
bl_idname = "bst.cancel_operation"
bl_label = "Cancel Operation"
bl_description = "Cancel the currently running operation"
@@ -1433,9 +1433,9 @@ def get_combined_path(context, datablock_name, extension=""):
return path + datablock_name + extension
# Panel for Shader Editor sidebar
class NODE_PT_bulk_path_tools(Panel):
class RBST_PathMan_PT_bulk_path_tools(Panel):
bl_label = "Bulk Pathing"
bl_idname = "NODE_PT_bulk_path_tools"
bl_idname = "RBST_PathMan_PT_bulk_path_tools"
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = 'Node'
@@ -1643,9 +1643,9 @@ class NODE_PT_bulk_path_tools(Panel):
box.label(text="No images in blend file")
# Sub-panel for existing Bulk Scene Tools
class VIEW3D_PT_bulk_path_subpanel(Panel):
class RBST_PathMan_PT_bulk_path_subpanel(Panel):
bl_label = "Bulk Path Management"
bl_idname = "VIEW3D_PT_bulk_path_subpanel"
bl_idname = "RBST_PathMan_PT_bulk_path_subpanel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Edit'
@@ -1655,34 +1655,34 @@ class VIEW3D_PT_bulk_path_subpanel(Panel):
def draw(self, context):
# Use the same draw function as the NODE_EDITOR panel
NODE_PT_bulk_path_tools.draw(self, context)
RBST_PathMan_PT_bulk_path_tools.draw(self, context)
# Registration function for this module
classes = (
REMOVE_EXT_OT_summary_dialog,
BST_PathProperties,
BST_OT_remap_path,
BST_OT_toggle_select_all,
BST_OT_bulk_remap,
BST_OT_toggle_path_edit,
BST_OT_select_material_images,
BST_OT_select_active_images,
BST_OT_select_absolute_images,
BST_OT_rename_datablock,
BST_OT_toggle_image_selection,
BST_OT_reuse_material_path,
BST_OT_reuse_blend_name,
BST_OT_make_paths_relative,
BST_OT_make_paths_absolute,
BST_OT_pack_images,
BST_OT_unpack_images,
BST_OT_remove_packed_images,
BST_OT_save_all_images,
BST_OT_remove_extensions,
BST_OT_rename_flat_colors,
BST_OT_cancel_operation,
NODE_PT_bulk_path_tools,
VIEW3D_PT_bulk_path_subpanel,
RBST_PathMan_OT_summary_dialog,
RBST_PathMan_PG_PathProperties,
RBST_PathMan_OT_remap_path,
RBST_PathMan_OT_toggle_select_all,
RBST_PathMan_OT_bulk_remap,
RBST_PathMan_OT_toggle_path_edit,
RBST_PathMan_OT_select_material_images,
RBST_PathMan_OT_select_active_images,
RBST_PathMan_OT_select_absolute_images,
RBST_PathMan_OT_rename_datablock,
RBST_PathMan_OT_toggle_image_selection,
RBST_PathMan_OT_reuse_material_path,
RBST_PathMan_OT_reuse_blend_name,
RBST_PathMan_OT_make_paths_relative,
RBST_PathMan_OT_make_paths_absolute,
RBST_PathMan_OT_pack_images,
RBST_PathMan_OT_unpack_images,
RBST_PathMan_OT_remove_packed_images,
RBST_PathMan_OT_save_all_images,
RBST_PathMan_OT_remove_extensions,
RBST_PathMan_OT_rename_flat_colors,
RBST_PathMan_OT_cancel_operation,
RBST_PathMan_PT_bulk_path_tools,
RBST_PathMan_PT_bulk_path_subpanel,
)
def register():
@@ -1690,7 +1690,7 @@ def register():
compat.safe_register_class(cls)
# Register properties
bpy.types.Scene.bst_path_props = PointerProperty(type=BST_PathProperties)
bpy.types.Scene.bst_path_props = PointerProperty(type=RBST_PathMan_PG_PathProperties)
# Add custom property to images for selection
bpy.types.Image.bst_selected = BoolProperty(
@@ -4,15 +4,15 @@ from ..ops.remove_custom_split_normals import RemoveCustomSplitNormals
from ..ops.create_ortho_camera import CreateOrthoCamera
from ..ops.spawn_scene_structure import SpawnSceneStructure
from ..ops.delete_single_keyframe_actions import DeleteSingleKeyframeActions
from ..ops.find_material_users import FindMaterialUsers, MATERIAL_USERS_OT_summary_dialog
from ..ops.remove_unused_material_slots import RemoveUnusedMaterialSlots
from ..ops.convert_relations_to_constraint import ConvertRelationsToConstraint
from ..ops.white_world import WhiteWorld
from ..utils import compat
class BulkSceneGeneral(bpy.types.Panel):
class RBST_SceneGen_PT_BulkSceneGeneral(bpy.types.Panel):
"""Bulk Scene General Panel"""
bl_label = "Scene General"
bl_idname = "VIEW3D_PT_bulk_scene_general"
bl_idname = "RBST_SceneGen_PT_bulk_scene_general"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Edit'
@@ -28,6 +28,8 @@ class BulkSceneGeneral(bpy.types.Panel):
row = box.row()
row.scale_y = 1.2
row.operator("bst.spawn_scene_structure", text="Spawn Scene Structure", icon='OUTLINER_COLLECTION')
row = box.row(align=True)
row.operator("bst.white_world", text="White World", icon='WORLD')
# Mesh section
box = layout.box()
@@ -49,8 +51,6 @@ class BulkSceneGeneral(bpy.types.Panel):
box.label(text="Materials")
row = box.row(align=True)
row.operator("bst.remove_unused_material_slots", text="Remove Unused Material Slots", icon='MATERIAL')
row = box.row(align=True)
row.operator("bst.find_material_users", text="Find Material Users", icon='VIEWZOOM')
# Animation Data section
box = layout.box()
@@ -62,14 +62,13 @@ class BulkSceneGeneral(bpy.types.Panel):
# List of all classes in this module
classes = (
BulkSceneGeneral,
RBST_SceneGen_PT_BulkSceneGeneral,
NoSubdiv, # Add NoSubdiv operator class
RemoveCustomSplitNormals,
CreateOrthoCamera,
SpawnSceneStructure,
WhiteWorld,
DeleteSingleKeyframeActions,
FindMaterialUsers,
MATERIAL_USERS_OT_summary_dialog,
RemoveUnusedMaterialSlots,
ConvertRelationsToConstraint,
)
@@ -84,19 +83,10 @@ def register():
description="Apply only to selected objects",
default=True
)
# Register temporary material property for Find Material Users operator
bpy.types.Scene.bst_temp_material = bpy.props.PointerProperty(
name="Temporary Material",
description="Temporary material selection for Find Material Users operator",
type=bpy.types.Material
)
def unregister():
for cls in reversed(classes):
compat.safe_unregister_class(cls)
# Unregister the window manager property
if hasattr(bpy.types.WindowManager, "bst_no_subdiv_only_selected"):
del bpy.types.WindowManager.bst_no_subdiv_only_selected
# Unregister temporary material property
if hasattr(bpy.types.Scene, "bst_temp_material"):
del bpy.types.Scene.bst_temp_material
del bpy.types.WindowManager.bst_no_subdiv_only_selected
@@ -6,9 +6,10 @@ from enum import Enum
import colorsys # Add colorsys for RGB to HSV conversion
from ..ops.select_diffuse_nodes import select_diffuse_nodes # Import the specific function
from ..utils import compat
from ..utils import version
# Material processing status enum
class MaterialStatus(Enum):
class RBST_ViewDisp_MaterialStatus(Enum):
PENDING = 0
PROCESSING = 1
COMPLETED = 2
@@ -26,7 +27,7 @@ material_queue = []
current_index = 0
# Scene properties for viewport display settings
def register_viewport_properties():
def RBST_ViewDisp_register_properties():
bpy.types.Scene.viewport_colors_selected_only = bpy.props.BoolProperty( # type: ignore
name="Selected Objects Only",
description="Apply viewport colors only to materials in selected objects",
@@ -104,7 +105,7 @@ def unregister_viewport_properties():
del bpy.types.Scene.viewport_colors_show_advanced
del bpy.types.Scene.show_material_results
class VIEWPORT_OT_SetViewportColors(bpy.types.Operator):
class RBST_ViewDisp_OT_SetViewportColors(bpy.types.Operator):
"""Set Viewport Display colors from BSDF base color or texture"""
bl_idname = "bst.set_viewport_colors"
bl_label = "Set Viewport Colors"
@@ -252,11 +253,11 @@ class VIEWPORT_OT_SetViewportColors(bpy.types.Operator):
failed_count = 0
for _, status in material_results.values():
if status == MaterialStatus.PREVIEW_BASED:
if status == RBST_ViewDisp_MaterialStatus.PREVIEW_BASED:
preview_count += 1
elif status == MaterialStatus.COMPLETED:
elif status == RBST_ViewDisp_MaterialStatus.COMPLETED:
node_count += 1
elif status == MaterialStatus.FAILED:
elif status == RBST_ViewDisp_MaterialStatus.FAILED:
failed_count += 1
# Use a popup menu instead of self.report since this might be called from a timer
@@ -268,7 +269,7 @@ class VIEWPORT_OT_SetViewportColors(bpy.types.Operator):
bpy.context.window_manager.popup_menu(draw_popup, title="Processing Complete", icon='INFO')
class VIEWPORT_OT_RefreshMaterialPreviews(bpy.types.Operator):
class RBST_ViewDisp_OT_RefreshMaterialPreviews(bpy.types.Operator):
"""Regenerate material previews to avoid stale thumbnails"""
bl_idname = "bst.refresh_material_previews"
bl_label = "Refresh Material Previews"
@@ -276,22 +277,37 @@ class VIEWPORT_OT_RefreshMaterialPreviews(bpy.types.Operator):
def execute(self, context):
forced_count = 0
try:
bpy.ops.wm.previews_clear()
bpy.ops.wm.previews_batch_generate()
bpy.ops.wm.previews_ensure()
except Exception as exc:
self.report({'WARNING'}, f"Pre-clearing previews failed: {exc}")
# Skip the preview clearing operators in Blender 5.0 - they can cause crashes
# Instead, we'll just regenerate previews for each material individually
# This is safer and avoids the EXCEPTION_ACCESS_VIOLATION crashes
if not version.is_version_5_0():
# Only use these operators in older Blender versions
try:
if hasattr(bpy.context, 'temp_override'):
with bpy.context.temp_override():
if hasattr(bpy.ops.wm, 'previews_clear'):
bpy.ops.wm.previews_clear()
else:
if hasattr(bpy.ops.wm, 'previews_clear'):
bpy.ops.wm.previews_clear()
except Exception as exc:
# These operations are optional - continue even if they fail
print(f"BST preview refresh: Pre-clearing previews failed (non-fatal): {exc}")
temp_obj = self._create_preview_object(context)
if not temp_obj:
self.report({'ERROR'}, "Failed to create preview object. Cannot refresh material previews.")
return {'CANCELLED'}
try:
for material in bpy.data.materials:
if not material or material.is_grease_pencil:
continue
try:
self._force_preview(material, temp_obj)
self._force_preview(material, temp_obj, context)
forced_count += 1
except Exception as exc:
print(f"BST preview refresh: failed for {material.name}: {exc}")
@@ -303,36 +319,106 @@ class VIEWPORT_OT_RefreshMaterialPreviews(bpy.types.Operator):
return {'FINISHED'}
def _create_preview_object(self, context):
mesh = bpy.data.meshes.new("BST_PreviewMesh")
mesh.from_pydata(
[(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)],
[],
[(0, 1, 2), (0, 2, 3), (0, 3, 1), (1, 3, 2)]
)
obj = bpy.data.objects.new("BST_PreviewObject", mesh)
obj.hide_viewport = True
obj.hide_render = True
context.scene.collection.objects.link(obj)
return obj
"""Create a temporary preview object for material preview generation."""
try:
mesh = bpy.data.meshes.new("RBST_PreviewMesh")
mesh.from_pydata(
[(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)],
[],
[(0, 1, 2), (0, 2, 3), (0, 3, 1), (1, 3, 2)]
)
obj = bpy.data.objects.new("RBST_PreviewObject", mesh)
obj.hide_viewport = True
obj.hide_render = True
# Link to scene collection - may fail in some contexts
try:
context.scene.collection.objects.link(obj)
except Exception:
# Fallback: try master collection or view layer
if hasattr(context, 'view_layer') and hasattr(context.view_layer, 'active_layer_collection'):
context.view_layer.active_layer_collection.collection.objects.link(obj)
elif hasattr(bpy.context, 'scene') and hasattr(bpy.context.scene, 'collection'):
bpy.context.scene.collection.objects.link(obj)
return obj
except Exception as exc:
print(f"BST preview refresh: Failed to create preview object: {exc}")
return None
def _cleanup_preview_object(self, obj):
"""Safely cleanup the preview object with proper error handling."""
if not obj:
return
mesh = obj.data
bpy.data.objects.remove(obj, do_unlink=True)
if mesh:
bpy.data.meshes.remove(mesh, do_unlink=True)
try:
# Clear any material assignments first to avoid dangling references
if hasattr(obj, 'data') and obj.data and hasattr(obj.data, 'materials'):
try:
obj.data.materials.clear()
except Exception:
pass
# Get mesh reference before removing object
mesh = obj.data if hasattr(obj, 'data') else None
# Remove object
try:
bpy.data.objects.remove(obj, do_unlink=True)
except Exception as exc:
print(f"BST preview refresh: Failed to remove preview object: {exc}")
# Remove mesh if it still exists
if mesh and mesh.name in bpy.data.meshes:
try:
bpy.data.meshes.remove(mesh, do_unlink=True)
except Exception as exc:
print(f"BST preview refresh: Failed to remove preview mesh: {exc}")
except Exception as exc:
print(f"BST preview refresh: Error during cleanup: {exc}")
def _force_preview(self, material, temp_obj):
if temp_obj.data.materials:
temp_obj.data.materials[0] = material
else:
temp_obj.data.materials.append(material)
material.preview_render_type = 'SPHERE'
preview = material.preview_ensure()
if preview:
# Touch icon id to ensure generation
_ = preview.icon_id
def _force_preview(self, material, temp_obj, context):
"""Force preview generation for a material with proper error handling."""
try:
# Assign material to temp object
if temp_obj.data.materials:
temp_obj.data.materials[0] = material
else:
temp_obj.data.materials.append(material)
# Set preview render type if available
if hasattr(material, 'preview_render_type'):
material.preview_render_type = 'SPHERE'
# Ensure preview exists - this may fail in some Blender versions
# In Blender 5.0, accessing icon_id immediately after preview_ensure() can cause crashes
if hasattr(material, 'preview_ensure'):
try:
preview = material.preview_ensure()
# In Blender 4.2/4.5, accessing icon_id is safe and helps ensure generation
# In Blender 5.0+, we skip this to avoid crashes
if not version.is_version_5_0() and preview and hasattr(preview, 'icon_id'):
try:
_ = preview.icon_id
except Exception:
# If icon_id access fails, that's okay - preview_ensure() is enough
pass
except Exception as exc:
# Preview generation may fail for some materials - this is acceptable
print(f"BST preview refresh: Could not generate preview for {material.name}: {exc}")
# Try alternative method: force update by accessing preview property
if hasattr(material, 'preview'):
try:
_ = material.preview
except Exception:
pass
# Clear material assignment to avoid keeping references
if temp_obj.data.materials:
temp_obj.data.materials.clear()
except Exception as exc:
# Re-raise to be caught by caller
raise exc
def correct_viewport_color(color):
@@ -373,11 +459,11 @@ def process_material(material, use_vectorized=True):
"""Process a material to determine its viewport color"""
if not material:
print(f"Material is None, using fallback color")
return (1, 1, 1), MaterialStatus.PREVIEW_BASED
return (1, 1, 1), RBST_ViewDisp_MaterialStatus.PREVIEW_BASED
if material.is_grease_pencil:
print(f"Material {material.name}: is a grease pencil material, using fallback color")
return (1, 1, 1), MaterialStatus.PREVIEW_BASED
return (1, 1, 1), RBST_ViewDisp_MaterialStatus.PREVIEW_BASED
try:
# Get color from material thumbnail
@@ -393,14 +479,14 @@ def process_material(material, use_vectorized=True):
corrected_color = correct_viewport_color(color)
print(f"Material {material.name}: Corrected thumbnail color = {corrected_color}")
return corrected_color, MaterialStatus.PREVIEW_BASED
return corrected_color, RBST_ViewDisp_MaterialStatus.PREVIEW_BASED
else:
print(f"Material {material.name}: Could not extract color from thumbnail, using fallback color")
return (1, 1, 1), MaterialStatus.PREVIEW_BASED
return (1, 1, 1), RBST_ViewDisp_MaterialStatus.PREVIEW_BASED
except Exception as e:
print(f"Error processing material {material.name}: {e}")
return (1, 1, 1), MaterialStatus.FAILED
return (1, 1, 1), RBST_ViewDisp_MaterialStatus.FAILED
def get_average_color(image, use_vectorized=True):
"""Calculate the average color of an image"""
@@ -719,38 +805,38 @@ def find_diffuse_texture(material):
def get_status_icon(status):
"""Get the icon for a material status"""
if status == MaterialStatus.PENDING:
if status == RBST_ViewDisp_MaterialStatus.PENDING:
return 'TRIA_RIGHT'
elif status == MaterialStatus.PROCESSING:
elif status == RBST_ViewDisp_MaterialStatus.PROCESSING:
return 'SORTTIME'
elif status == MaterialStatus.COMPLETED:
elif status == RBST_ViewDisp_MaterialStatus.COMPLETED:
return 'CHECKMARK'
elif status == MaterialStatus.PREVIEW_BASED:
elif status == RBST_ViewDisp_MaterialStatus.PREVIEW_BASED:
return 'IMAGE_DATA'
elif status == MaterialStatus.FAILED:
elif status == RBST_ViewDisp_MaterialStatus.FAILED:
return 'ERROR'
else:
return 'QUESTION'
def get_status_text(status):
"""Get the text for a material status"""
if status == MaterialStatus.PENDING:
if status == RBST_ViewDisp_MaterialStatus.PENDING:
return "Pending"
elif status == MaterialStatus.PROCESSING:
elif status == RBST_ViewDisp_MaterialStatus.PROCESSING:
return "Processing"
elif status == MaterialStatus.COMPLETED:
elif status == RBST_ViewDisp_MaterialStatus.COMPLETED:
return "Node-based"
elif status == MaterialStatus.PREVIEW_BASED:
elif status == RBST_ViewDisp_MaterialStatus.PREVIEW_BASED:
return "Thumbnail-based"
elif status == MaterialStatus.FAILED:
elif status == RBST_ViewDisp_MaterialStatus.FAILED:
return "Failed"
else:
return "Unknown"
class VIEW3D_PT_BulkViewportDisplay(bpy.types.Panel):
class RBST_ViewDisp_PT_BulkViewportDisplay(bpy.types.Panel):
"""Bulk Viewport Display Panel"""
bl_label = "Bulk Viewport Display"
bl_idname = "VIEW3D_PT_bulk_viewport_display"
bl_idname = "RBST_ViewDisp_PT_bulk_viewport_display"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Edit'
@@ -827,9 +913,9 @@ class VIEW3D_PT_BulkViewportDisplay(bpy.types.Panel):
color, status = material_results[material_name]
# Update counts
if status == MaterialStatus.PREVIEW_BASED:
if status == RBST_ViewDisp_MaterialStatus.PREVIEW_BASED:
preview_count += 1
elif status == MaterialStatus.FAILED:
elif status == RBST_ViewDisp_MaterialStatus.FAILED:
failed_count += 1
row = col.row(align=True)
@@ -868,7 +954,7 @@ class VIEW3D_PT_BulkViewportDisplay(bpy.types.Panel):
layout.separator()
layout.operator("bst.select_diffuse_nodes", icon='NODE_TEXTURE')
class MATERIAL_OT_SelectInEditor(bpy.types.Operator):
class RBST_ViewDisp_OT_SelectInEditor(bpy.types.Operator):
"""Select this material in the editor"""
bl_idname = "bst.select_in_editor"
bl_label = "Select Material"
@@ -989,7 +1075,7 @@ def get_color_from_preview(material, use_vectorized=True):
else:
return None
class VIEWPORT_OT_SelectDiffuseNodes(bpy.types.Operator):
class RBST_ViewDisp_OT_SelectDiffuseNodes(bpy.types.Operator):
bl_idname = "bst.select_diffuse_nodes"
bl_label = "Set Texture Display"
bl_description = "Select the most relevant diffuse/base color image texture node in each material"
@@ -1005,11 +1091,11 @@ class VIEWPORT_OT_SelectDiffuseNodes(bpy.types.Operator):
# List of all classes in this module
classes = (
VIEWPORT_OT_SetViewportColors,
VIEWPORT_OT_RefreshMaterialPreviews,
VIEW3D_PT_BulkViewportDisplay,
MATERIAL_OT_SelectInEditor,
VIEWPORT_OT_SelectDiffuseNodes,
RBST_ViewDisp_OT_SetViewportColors,
RBST_ViewDisp_OT_RefreshMaterialPreviews,
RBST_ViewDisp_PT_BulkViewportDisplay,
RBST_ViewDisp_OT_SelectInEditor,
RBST_ViewDisp_OT_SelectDiffuseNodes,
)
# Registration
@@ -1018,12 +1104,12 @@ def register():
compat.safe_register_class(cls)
# Register properties
register_viewport_properties()
RBST_ViewDisp_register_properties()
def unregister():
# Unregister properties
try:
unregister_viewport_properties()
RBST_ViewDisp_unregister_properties()
except Exception:
pass
# Unregister classes
@@ -0,0 +1,96 @@
## [v0.0.7] - 2026-02-12
### Fixed
- Pack: enable autopack before pack_all; force-load images and run pack_all twice; pack remaining images so textures are embedded (fixes "Failed to create GPU texture from Blender image" when rendering headless).
- Remap: print actual paths in warnings (not placeholders); normalized path lookup and reverse copy_map so library blend image paths resolve.
- pack_linked: catch PermissionError on library path checks so inaccessible (e.g. NAS) libs dont abort; remove missing/inaccessible library refs from blend before save.
---
## [v0.0.6] - 2026-01-27
### Fixed
- Config import in `utils.compat`: use `from .. import config` (config is at addon root)
- Output panel: no longer write to scene in draw(); Blender 5.0 forbids ID writes in draw; operators already fall back to prefs when output_path empty
---
## [v0.0.5] - 2026-01-30
### Added
- Project size limit (GB) in Output panel: per-pack int (0 = no limit, default 2), max 32-bit int
### Fixed
- USD/cache file paths remapped: `bpy.data.cache_files[].filepath` remapped to packed location; .usd/.usdc/.usda added to copy_map
---
## [v0.0.4] - 2026-01-27
### Added
- ZIP pack: option to exclude video and audio files from archive
- Default output path in preferences
- NLA enable for animation layers (moved to UI panel; only runs on objects with anim layers)
### Fixed
- Physics/point cache included in ZIP pack (robocopy fallback when Python copy fails on network paths)
- Cache truncated to frame range (Blender bphys `name_frame_index` naming; safeguard if no files match)
- External cache paths remapped to relative (cache dirs in copy_map; prefix matching in remap script)
- Frame range applied only to top-level target blend, not dependent blends
- Recursion issue in all three pack ops; send-current-blend path handling
- Removed packed-suffix behavior
---
## [v0.0.3] - 2026-01-22
### Changed
- **Removed all website functionality** per SheepIt developer request
- Operators now save packed files to user-specified locations instead of uploading
- All authentication and website interaction code has been removed
- Users must manually upload and configure projects on the SheepIt website
---
## [v0.0.2] - 2026-01-22
### Fixed
- Fixed Blender extension policy violations related to `batter.asset_usage` module import
- Removed `sys.path` manipulation to comply with Blender extension policies
- Changed from top-level module import to submodule import (registered as `ops._asset_usage`)
- Fixed `dataclasses` `__module__` resolution issue when loading modules via `importlib`
### Internal
- Refactored `batter.asset_usage` import to use `importlib` without violating extension policies
- Module now properly registered in `sys.modules` as a submodule before execution
---
## [v0.0.1] - 2026-01-21
### Features
- Initial release of SheepIt Project Submitter
- Three submission workflows:
- Submit Current: Direct submission of current blend file
- Submit as ZIP: Automatic asset packing with ZIP archive creation
- Submit as Packed Blend: Automatic asset packing directly into blend file
- Frame range configuration (full range or custom)
- Automatic asset packing for linked blend files, textures, images, and videos
- Cache truncation to match selected frame range
- Real-time progress tracking with cancellable operations
- File size validation (2GB limit) with optimization suggestions
- Automatic path remapping for all asset types
- Missing file detection and reporting
- Oversized file detection (>2GB linked files)
- Automatic backup file cleanup (`.blend1` through `.blend32`)
- Compressed blend file saves
- Username/password authentication
- Browser redirect to project configuration page after submission
- Works with unsaved blend files (operates on in-memory state)
### Internal
- Based on asset usage detection from Batter project
- Modal operator architecture for responsive UI
- Incremental packing system for large projects
- Subprocess-based asset processing for stability
- Comprehensive error handling and user feedback
@@ -0,0 +1,53 @@
# SheepIt Project Submitter
A Blender addon for packing projects for the SheepIt render farm with automatic asset packing and intelligent workflow management.
## Features
| Automatic Asset Packing | Frame Range Control | Multiple Packing Methods |
|--|--|--|
| Automatically packs all linked blend files, textures, images, and external assets into your project. Supports both ZIP and packed blend file workflows. | Configure custom frame ranges directly in Blender without saving your file. Frame ranges are automatically applied to packed files. | Pack as current blend file, packed ZIP archive, or packed blend file. Choose the method that best fits your project. |
| Cache Management | Size Validation | Progress Tracking |
|--|--|--|
| Automatically truncates cache files to match your selected frame range, reducing file sizes significantly. | Validates file sizes before packing (2GB limit) with helpful suggestions for optimization. | Real-time progress bars and status messages for all operations. All steps are cancellable. |
| Path Remapping | Missing File Detection | Error Reporting |
|--|--|--|
| Intelligently remaps all asset paths to work correctly on the render farm. Handles textures, images, videos, and linked blend files. | Detects and reports missing linked files and oversized files (>2GB) that cannot be packed. | Comprehensive error messages with actionable suggestions for resolving issues. |
### Additional Features:
- Works with unsaved blend files (operates on in-memory state)
- Automatic backup file cleanup (`.blend1` through `.blend32`)
- Compressed blend file saves for optimal file sizes
- File browser for selecting output location
## Installation
1. Download the latest release from [GitHub Releases](https://github.com/RaincloudTheDragon/sheepit-project-submitter/releases)
2. In Blender, go to `Edit > Preferences > Add-ons`
3. Click `Install...` and select the downloaded ZIP file
4. Enable the addon by checking the box next to "SheepIt Project Submitter"
## Usage
1. **Set Frame Range**: In the Output properties panel, configure your frame range (full range or custom)
2. **Pack Project**: Choose your packing method:
- **Pack Current Blend**: Saves the current blend file with frame range applied
- **Pack as ZIP**: Packs all assets and creates a ZIP archive (recommended for scenes with caches)
- **Pack as Blend**: Packs all assets directly into the blend file
3. **Select Output Location**: A file browser will open to select where to save the packed file
4. **Upload Manually**: Upload the packed file to SheepIt via the website and configure your project settings
## Requirements
- Blender 3.0.0 or later
## License
GPL-3.0-or-later
## Links
- **GitHub Repository**: [https://github.com/RaincloudTheDragon/sheepit-project-submitter](https://github.com/RaincloudTheDragon/sheepit-project-submitter)
- **SheepIt Render Farm**: [https://www.sheepit-renderfarm.com](https://www.sheepit-renderfarm.com)
@@ -0,0 +1,188 @@
"""
SheepIt Project Submitter Addon
A Blender addon for submitting projects to SheepIt render farm with automatic asset packing.
"""
import bpy
from bpy.utils import register_class
from .utils import compat
from . import ops
from . import ui
from . import rainys_repo_bootstrap
def _update_output_path(self, context):
"""Update callback for output_path property - auto-populates from preferences if empty."""
if not self.output_path:
from .utils.compat import get_addon_prefs
prefs = get_addon_prefs()
if prefs and prefs.default_output_path:
self.output_path = prefs.default_output_path
# SheepIt Submit Settings Property Group
class SHEEPIT_PG_submit_settings(bpy.types.PropertyGroup):
"""Property group for storing submit settings."""
# Frame range mode
frame_range_mode: bpy.props.EnumProperty(
name="Frame Range Mode",
description="Choose between full range or custom frame range",
items=[
('FULL', "Full Range", "Use the full frame range from scene settings"),
('CUSTOM', "Custom", "Specify custom start, end, and step frames"),
],
default='FULL',
)
# Custom frame range
frame_start: bpy.props.IntProperty(
name="Start Frame",
description="Start frame for rendering",
default=1,
min=0,
)
frame_end: bpy.props.IntProperty(
name="End Frame",
description="End frame for rendering",
default=250,
min=0,
)
frame_step: bpy.props.IntProperty(
name="Frame Step",
description="Frame step (render every Nth frame)",
default=1,
min=1,
)
# Compute method
compute_method: bpy.props.EnumProperty(
name="Compute Method",
description="Choose CPU or GPU rendering",
items=[
('CPU', "CPU", "Use CPU for rendering"),
('GPU', "GPU", "Use GPU for rendering"),
],
default='CPU',
)
# Checkboxes
renderable_by_all: bpy.props.BoolProperty(
name="Renderable by all members",
description="Allow all SheepIt members to render this project",
default=True,
)
generate_mp4: bpy.props.BoolProperty(
name="Generate MP4 video",
description="Generate MP4 video from rendered frames",
default=False,
)
# Advanced options
memory_used_mb: bpy.props.StringProperty(
name="Memory used (MB)",
description="Memory limit in MB (leave empty for default)",
default="",
)
# Advanced options visibility
show_advanced: bpy.props.BoolProperty(
name="Show Advanced Options",
description="Show advanced submission options",
default=False,
)
# ZIP pack: exclude video/audio files
exclude_video_from_zip: bpy.props.BoolProperty(
name="Exclude video/audio from ZIP",
description="Exclude video and audio files (e.g. mp4, avi, mov, wav, mp3) from the ZIP pack",
default=False,
)
# Project size limit (GB); 0 = no limit (max 32-bit signed int for Blender C API)
project_size_limit_gb: bpy.props.IntProperty(
name="Project Size Limit (GB)",
description="Maximum project/ZIP/blend size in GB (0 = no limit)",
default=2,
min=0,
max=(1 << 31) - 1,
)
# Pack output path (set by pack operators)
pack_output_path: bpy.props.StringProperty(
name="Pack Output Path",
description="Path to the packed output directory",
default="",
)
output_file_path: bpy.props.StringProperty(
name="Output File Path",
description="Path where the packed file (ZIP or blend) will be saved",
default="",
subtype='FILE_PATH',
)
output_path: bpy.props.StringProperty(
name="Output Path",
description="Directory path where packed files will be saved",
default="",
subtype='DIR_PATH',
update=_update_output_path,
)
# Progress tracking for submission operations
is_submitting: bpy.props.BoolProperty(
name="Is Submitting",
description="Whether a submission is currently in progress",
default=False,
)
submit_progress: bpy.props.FloatProperty(
name="Submit Progress",
description="Progress percentage for submission operations",
default=0.0,
min=0.0,
max=100.0,
subtype='PERCENTAGE',
)
submit_status_message: bpy.props.StringProperty(
name="Submit Status Message",
description="Current status message for submission operations",
default="",
)
def register():
"""Register the addon."""
from .utils import compat
compat.safe_register_class(SHEEPIT_PG_submit_settings)
bpy.types.Scene.sheepit_submit = bpy.props.PointerProperty(type=SHEEPIT_PG_submit_settings)
# Register operators and UI (preferences are registered in ui.register())
ops.register()
ui.register()
# Bootstrap Rainy's Extensions repository
rainys_repo_bootstrap.register()
def unregister():
"""Unregister the addon."""
from .utils import compat
# Bootstrap unregistration
rainys_repo_bootstrap.unregister()
# Unregister operators and UI
ui.unregister()
ops.unregister()
compat.safe_unregister_class(SHEEPIT_PG_submit_settings)
if hasattr(bpy.types.Scene, 'sheepit_submit'):
del bpy.types.Scene.sheepit_submit
@@ -0,0 +1,213 @@
from __future__ import annotations
import dataclasses
import functools
from collections import defaultdict
from pathlib import Path
import bpy
from bpy.types import Library, ID
def find() -> dict[Library | None, set[AssetUsage]]:
"""Return a mapping from each blend file to the assets it uses.
The None key indicates the direct dependencies of the currently-open blend file.
"""
_blend_asset_usage = find_blend_asset_usage()
_nonblend_asset_usage = find_nonblend_asset_usage()
return _merge_keys(_blend_asset_usage, _nonblend_asset_usage)
@dataclasses.dataclass
class AssetUsage:
"""The usage of an asset by a specific blend file."""
abspath: Path
"""Absolute path to the asset."""
# user: Path
# """Absolute path of whatever blend file uses this Asset."""
reference_path: str
"""The path by which this asset is referenced.
This is tracked so that the search & replace operation for path rewriting
knows what to search for.
NOTE: the above may not be true, as paths to assets from a library blend
file may already be rewritten by Blender upon loading; this process ensures
that all relative paths are relative to the main blend file, and so they may
not be the same in the library itself.
"""
is_blendfile: bool
"""Whether this asset is a blend file or not.
Blend files can refer to other assets.
"""
def __hash__(self) -> int:
return hash((self.abspath, self.reference_path, self.is_blendfile))
def __eq__(self, value: object) -> bool:
if not isinstance(value, AssetUsage):
return False
return (
self.abspath,
self.reference_path,
self.is_blendfile,
) == (
value.abspath,
value.reference_path,
value.is_blendfile,
)
@functools.lru_cache
def library_abspath(lib: Library | None) -> Path:
"""Return the absolute path to the library.
lib=None returns the absolute path of the current blend file.
"""
if lib is None:
filepath = bpy.data.filepath
else:
filepath = bpy.path.abspath(lib.filepath)
return Path(filepath).resolve()
def find_blend_asset_usage() -> dict[Library | None, set[AssetUsage]]:
"""Return a mapping from each blend file to the blend files it uses as libraries."""
# Find all dependencies between libraries.
# Keys: Library ID (or `None` for current blendfile)
# Values: Libraries used by the key one.
libs_deps: dict[Library | None, set[AssetUsage]] = defaultdict(set)
for id, id_users in bpy.data.user_map().items():
id_lib = id.library
libs_deps.setdefault(id_lib, set())
for id_user in id_users:
if id_user.library == id_lib:
continue
asset_usage = AssetUsage(
abspath=library_abspath(id_lib),
reference_path=id_lib.filepath,
is_blendfile=True,
)
libs_deps[id_user.library].add(asset_usage)
return dict(libs_deps)
def find_nonblend_asset_usage() -> dict[Library | None, set[AssetUsage]]:
"""Return a mapping from a blend file to the non-blend asset files it uses."""
file_path_map = bpy.data.file_path_map(include_libraries=False)
asset_usages: dict[Library | None, set[AssetUsage]] = defaultdict(set)
for asset_user, filepaths in file_path_map.items():
if not filepaths:
continue
if isinstance(asset_user, Library):
continue
assert isinstance(asset_user, ID)
lib = asset_user.library
if lib and lib.packed_file:
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
for filepath in filepaths:
abspath = Path(bpy.path.abspath(filepath, library=lib)).resolve()
asset_usage = AssetUsage(
abspath=abspath,
reference_path=filepath,
is_blendfile=False,
)
asset_usages[lib].add(asset_usage)
# Also check bpy.data.images directly, as file_path_map may miss some image references
# (e.g., PNG, EXR files in certain node setups)
for img in bpy.data.images:
if img.packed_file:
continue
if not img.filepath:
continue
# Skip generated images and sequences/movies
if getattr(img, 'source', 'FILE') not in {'FILE', 'TILED'}:
continue
lib = getattr(img, 'library', None)
if lib and lib.packed_file:
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
try:
abspath = Path(bpy.path.abspath(img.filepath, library=lib)).resolve()
asset_usage = AssetUsage(
abspath=abspath,
reference_path=img.filepath,
is_blendfile=False,
)
asset_usages[lib].add(asset_usage)
except Exception:
pass
# Also check sounds and movie clips for completeness
for snd in getattr(bpy.data, 'sounds', []):
if snd.packed_file:
continue
if not snd.filepath:
continue
lib = getattr(snd, 'library', None)
if lib and lib.packed_file:
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
try:
abspath = Path(bpy.path.abspath(snd.filepath, library=lib)).resolve()
asset_usage = AssetUsage(
abspath=abspath,
reference_path=snd.filepath,
is_blendfile=False,
)
asset_usages[lib].add(asset_usage)
except Exception:
pass
for mc in getattr(bpy.data, 'movieclips', []):
if not mc.filepath:
continue
lib = getattr(mc, 'library', None)
if lib and lib.packed_file:
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
try:
abspath = Path(bpy.path.abspath(mc.filepath, library=lib)).resolve()
asset_usage = AssetUsage(
abspath=abspath,
reference_path=mc.filepath,
is_blendfile=False,
)
asset_usages[lib].add(asset_usage)
except Exception:
pass
return dict(asset_usages)
def _merge_keys(
a: dict[Library | None, set[AssetUsage]],
b: dict[Library | None, set[AssetUsage]],
) -> dict[Library | None, set[AssetUsage]]:
merged = defaultdict(set)
for key, values in a.items():
merged[key].update(values)
for key, values in b.items():
merged[key].update(values)
return merged
@@ -0,0 +1,18 @@
schema_version = "1.0.0"
id = "sheepit_project_submitter"
name = "SheepIt Project Submitter"
version = "0.0.7"
type = "add-on"
author = "RaincloudTheDragon"
maintainer = "RaincloudTheDragon"
blender_version_min = "3.0.0"
license = ["GPL-3.0-or-later"]
description = "Submit Blender projects to SheepIt render farm with automatic asset packing."
homepage = "https://github.com/RaincloudTheDragon/sheepit-project-submitter/"
tagline = "Submit projects to SheepIt render farm"
tags = ["render", "farm", "submission", "utility"]
# Python modules to load for this add-on
modules = ["sheepit_project_submitter"]
@@ -0,0 +1,20 @@
"""
Configuration constants for SheepIt Project Submitter addon.
"""
# Addon metadata
ADDON_NAME = "SheepIt Project Submitter"
ADDON_ID = "sheepit_project_submitter"
# SheepIt API endpoints (to be researched and updated)
SHEEPIT_API_BASE = "https://www.sheepit-renderfarm.com"
SHEEPIT_CLIENT_BASE = "https://client.sheepit-renderfarm.com"
# Debug mode
DEBUG = False
def debug_print(message: str) -> None:
"""Print debug message if DEBUG is enabled."""
if DEBUG:
print(f"[{ADDON_NAME}] {message}")
@@ -0,0 +1,24 @@
"""
Operators for SheepIt Project Submitter addon.
"""
def register():
"""Register all operators."""
# Lazy imports - these are only executed when register() is called
# This avoids circular import issues since imports happen at function call time
from . import pack_ops
from . import submit_ops
pack_ops.register()
submit_ops.register()
def unregister():
"""Unregister all operators."""
# Lazy imports - these are only executed when unregister() is called
from . import pack_ops
from . import submit_ops
submit_ops.unregister()
pack_ops.unregister()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,460 @@
"""
Submission operations for SheepIt render farm.
"""
import os
import zipfile
import tempfile
import subprocess
from pathlib import Path
from typing import Optional, Tuple
import bpy
from bpy.types import Operator
from .. import config
def apply_frame_range_to_blend(blend_path: Path, frame_start: int, frame_end: int, frame_step: int) -> None:
"""
Apply frame range settings to a blend file using subprocess.
Args:
blend_path: Path to the blend file to modify
frame_start: Start frame value
frame_end: End frame value
frame_step: Frame step value
"""
script = f"""
import bpy
for scene in bpy.data.scenes:
scene.frame_start = {frame_start}
scene.frame_end = {frame_end}
scene.frame_step = {frame_step}
bpy.ops.wm.save_mainfile(compress=True)
print(f'Applied frame range {frame_start}-{frame_end} (step {frame_step}) to all scenes')
"""
result = subprocess.run([
"blender", "--factory-startup", "-b", str(blend_path), "--python-expr", script
], capture_output=True, text=True, check=False)
if result.returncode != 0:
print(f"[SheepIt Submit] WARNING: Failed to apply frame range to {blend_path.name}")
if result.stderr:
print(f"[SheepIt Submit] Error: {result.stderr[:200]}")
else:
print(f"[SheepIt Submit] Applied frame range {frame_start}-{frame_end} (step {frame_step}) to {blend_path.name}")
def save_current_blend_with_frame_range(submit_settings, temp_dir: Optional[Path] = None) -> Tuple[Path, int, int, int]:
"""
Save current blend state to a temporary file and apply frame range from submit_settings.
Args:
submit_settings: Submit settings containing frame range configuration
temp_dir: Optional temporary directory (if None, creates a new one)
Returns:
Tuple of (temp_blend_path, frame_start, frame_end, frame_step)
"""
# Determine frame range from submit_settings
if submit_settings.frame_range_mode == 'FULL':
frame_start = bpy.context.scene.frame_start
frame_end = bpy.context.scene.frame_end
frame_step = bpy.context.scene.frame_step
else:
frame_start = submit_settings.frame_start
frame_end = submit_settings.frame_end
frame_step = submit_settings.frame_step
# Create temp directory if not provided
if temp_dir is None:
temp_dir = Path(tempfile.mkdtemp(prefix="sheepit_submit_"))
# Generate temp blend filename
blend_name = bpy.data.filepath if bpy.data.filepath else "untitled"
blend_name = Path(blend_name).stem if blend_name else "untitled"
temp_blend = temp_dir / f"{blend_name}.blend"
print(f"[SheepIt Submit] Saving current blend state to: {temp_blend}")
print(f"[SheepIt Submit] Frame range: {frame_start} - {frame_end} (step: {frame_step})")
# Save current blend state
try:
temp_dir.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(temp_blend), copy=True, compress=True)
print(f"[SheepIt Submit] Saved current blend state to temp file")
except Exception as e:
error_msg = f"Failed to save current blend state: {type(e).__name__}: {str(e)}"
print(f"[SheepIt Submit] ERROR: {error_msg}")
raise RuntimeError(error_msg) from e
# Apply frame range to the saved file
apply_frame_range_to_blend(temp_blend, frame_start, frame_end, frame_step)
return temp_blend, frame_start, frame_end, frame_step
class SHEEPIT_OT_submit_current(Operator):
"""Pack current blend file to output location without packing assets."""
bl_idname = "sheepit.submit_current"
bl_label = "Pack Current Blend"
bl_description = "Save the current blend file with frame range applied to the specified output location"
bl_options = {'REGISTER', 'UNDO'}
def invoke(self, context, event):
"""Start the packing operation."""
submit_settings = context.scene.sheepit_submit
# Check if already packing
if submit_settings.is_submitting:
self.report({'WARNING'}, "A packing operation is already in progress.")
return {'CANCELLED'}
# Get output path from settings or preferences
output_dir = submit_settings.output_path
if not output_dir:
from ..utils.compat import get_addon_prefs
prefs = get_addon_prefs()
if prefs and prefs.default_output_path:
output_dir = prefs.default_output_path
submit_settings.output_path = output_dir
if not output_dir:
self.report({'ERROR'}, "Please specify an output path in the panel below.")
return {'CANCELLED'}
# Generate filename
blend_name = bpy.data.filepath if bpy.data.filepath else "untitled"
if blend_name:
blend_name = Path(blend_name).stem
else:
blend_name = "untitled"
output_file = Path(output_dir) / f"{blend_name}.blend"
# Initialize progress properties
submit_settings.is_submitting = True
submit_settings.submit_progress = 0.0
submit_settings.submit_status_message = "Initializing..."
# Initialize phase tracking
self._phase = 'INIT'
self._output_path = output_file
self._temp_blend_path = None
self._temp_dir = None
self._success = False
self._message = ""
self._error = None
# Create timer for modal updates
self._timer = context.window_manager.event_timer_add(0.1, window=context.window)
# Force UI redraw
for area in context.screen.areas:
if area.type == 'PROPERTIES':
area.tag_redraw()
# Start modal operation
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
"""Legacy execute method - redirects to invoke for modal operation."""
return self.invoke(context, None)
def modal(self, context, event):
"""Handle modal events and update progress."""
submit_settings = context.scene.sheepit_submit
# Handle ESC key to cancel
if event.type == 'ESC':
self._cleanup(context, cancelled=True)
self.report({'INFO'}, "Packing cancelled.")
return {'CANCELLED'}
# Handle timer events
if event.type == 'TIMER':
try:
if self._phase == 'INIT':
submit_settings.submit_progress = 0.0
submit_settings.submit_status_message = "Initializing..."
self._phase = 'SAVING_BLEND'
return {'RUNNING_MODAL'}
elif self._phase == 'SAVING_BLEND':
submit_settings.submit_progress = 10.0
submit_settings.submit_status_message = "Saving current blend state..."
# Save current blend state to temp file with frame range applied
try:
self._temp_blend_path, frame_start, frame_end, frame_step = save_current_blend_with_frame_range(submit_settings)
self._temp_dir = self._temp_blend_path.parent
print(f"[SheepIt Submit] Using temp blend file: {self._temp_blend_path}")
except Exception as e:
self._error = f"Failed to save current blend state: {str(e)}"
self._cleanup(context, cancelled=True)
self.report({'ERROR'}, self._error)
return {'CANCELLED'}
self._phase = 'APPLYING_FRAME_RANGE'
return {'RUNNING_MODAL'}
elif self._phase == 'APPLYING_FRAME_RANGE':
submit_settings.submit_progress = 20.0
submit_settings.submit_status_message = "Frame range applied."
# Frame range is already applied in save_current_blend_with_frame_range
self._phase = 'VALIDATING_FILE_SIZE'
return {'RUNNING_MODAL'}
elif self._phase == 'VALIDATING_FILE_SIZE':
submit_settings.submit_progress = 30.0
submit_settings.submit_status_message = "Validating file size..."
# Check blend file size
if self._temp_blend_path and self._temp_blend_path.exists():
blend_size = self._temp_blend_path.stat().st_size
blend_size_gb = blend_size / (1024 * 1024 * 1024)
from .pack_ops import _get_project_size_limit_bytes
max_bytes = _get_project_size_limit_bytes(context)
if max_bytes is not None and blend_size > max_bytes:
limit_gb = max_bytes / (1024 * 1024 * 1024)
print(f"[SheepIt Pack] Blend file size: {blend_size_gb:.2f} GB")
error_msg = (
f"Blend file size ({blend_size_gb:.2f} GB) exceeds project limit ({limit_gb:.1f} GB).\n\n"
"To reduce file size, consider:\n"
"- Optimizing the scene (reduce geometry, simplify materials)\n"
"- Optimizing asset files (compress textures, reduce resolution)\n"
"- Splitting the frame range (render in smaller chunks)"
)
print(f"[SheepIt Pack] ERROR: {error_msg}")
self._error = error_msg
self._cleanup(context, cancelled=True)
self.report({'ERROR'}, self._error)
return {'CANCELLED'}
self._phase = 'SAVING_FILE'
return {'RUNNING_MODAL'}
elif self._phase == 'SAVING_FILE':
submit_settings.submit_progress = 50.0
submit_settings.submit_status_message = "Saving file to output location..."
try:
# Ensure output directory exists
self._output_path.parent.mkdir(parents=True, exist_ok=True)
# Copy temp file to output location
import shutil
shutil.copy2(self._temp_blend_path, self._output_path)
print(f"[SheepIt Pack] Saved blend file to: {self._output_path}")
self._success = True
self._message = f"Blend file saved to: {self._output_path}"
submit_settings.submit_progress = 90.0
submit_settings.submit_status_message = "File saved successfully!"
self._phase = 'CLEANUP'
except Exception as e:
self._error = f"Failed to save file: {str(e)}"
self._cleanup(context, cancelled=True)
self.report({'ERROR'}, self._error)
return {'CANCELLED'}
return {'RUNNING_MODAL'}
elif self._phase == 'CLEANUP':
submit_settings.submit_progress = 98.0
submit_settings.submit_status_message = "Cleaning up..."
# Clean up temp file on success
if self._temp_blend_path and self._temp_blend_path.exists():
try:
self._temp_blend_path.unlink()
if self._temp_dir and self._temp_dir.exists():
try:
self._temp_dir.rmdir()
except Exception:
pass # Directory may not be empty
print(f"[SheepIt Submit] Cleaned up temp file: {self._temp_blend_path}")
except Exception as e:
print(f"[SheepIt Submit] WARNING: Could not clean up temp file: {e}")
self._phase = 'COMPLETE'
return {'RUNNING_MODAL'}
elif self._phase == 'COMPLETE':
submit_settings.submit_progress = 100.0
submit_settings.submit_status_message = "Packing complete!"
# Small delay to show completion
import time
time.sleep(0.2)
self._cleanup(context, cancelled=False)
self.report({'INFO'}, f"Blend file saved to: {self._output_path}")
return {'FINISHED'}
except Exception as e:
import traceback
traceback.print_exc()
self._error = f"Packing failed: {type(e).__name__}: {str(e)}"
self._cleanup(context, cancelled=True)
self.report({'ERROR'}, self._error)
return {'CANCELLED'}
return {'RUNNING_MODAL'}
def _cleanup(self, context, cancelled=False):
"""Clean up progress properties and timer."""
submit_settings = context.scene.sheepit_submit
# Remove timer
if hasattr(self, '_timer') and self._timer:
context.window_manager.event_timer_remove(self._timer)
# Reset progress properties
submit_settings.is_submitting = False
submit_settings.submit_progress = 0.0
if cancelled and self._error:
submit_settings.submit_status_message = self._error
else:
submit_settings.submit_status_message = ""
# Force UI redraw
for area in context.screen.areas:
if area.type == 'PROPERTIES':
area.tag_redraw()
def execute(self, context):
"""Execute method - starts the modal operation."""
# This is called by invoke, so we don't redirect back to avoid recursion
# The actual implementation is in the invoke method above
pass
# Video and audio extensions to exclude when exclude_video=True
_MEDIA_EXTENSIONS = frozenset({
'.mp4', '.avi', '.mov', '.mkv', '.webm', '.m4v', '.wmv', '.flv', '.ogv', '.mpg', '.mpeg', '.m2v',
'.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a', '.wma', '.opus', '.aiff', '.aif',
})
def create_zip_from_directory(directory: Path, output_zip: Path, progress_callback=None, cancel_check=None, exclude_video: bool = False) -> None:
"""Create a ZIP file from a directory.
Args:
directory: Directory to zip
output_zip: Output ZIP file path
progress_callback: Optional callback(progress_pct, message) for progress updates
cancel_check: Optional callback() -> bool to check for cancellation
exclude_video: If True, skip common video and audio file extensions
"""
import time
print(f"[SheepIt Submit] Starting ZIP creation...")
print(f"[SheepIt Submit] Directory: {directory}")
print(f"[SheepIt Submit] Output: {output_zip}")
# Delete .blend1 through .blend32 backup files before zipping
if progress_callback:
progress_callback(0.0, "Removing backup files...")
backup_files = []
for i in range(1, 33): # blend1 through blend32
pattern = f"*.blend{i}"
backup_files.extend(directory.rglob(pattern))
if backup_files:
print(f"[SheepIt Submit] Found {len(backup_files)} backup files (.blend1-.blend32), deleting...")
deleted_count = 0
for backup_file in backup_files:
try:
backup_file.unlink()
deleted_count += 1
except Exception as e:
print(f"[SheepIt Submit] WARNING: Could not delete {backup_file.name}: {e}")
print(f"[SheepIt Submit] Deleted {deleted_count}/{len(backup_files)} backup files")
else:
print(f"[SheepIt Submit] No backup files (.blend1-.blend32) found")
if progress_callback:
progress_callback(0.0, "Counting files...")
# Collect all dirs (for empty-dir entries) and files
dir_arcs = set()
file_list = []
file_count = 0
total_size = 0
for root, dirs, files in os.walk(directory):
root_path = Path(root)
for d in dirs:
dir_arcs.add(root_path.joinpath(d).relative_to(directory))
for file in files:
file_path = root_path / file
if not file_path.exists():
continue
if exclude_video and file_path.suffix.lower() in _MEDIA_EXTENSIONS:
continue
file_count += 1
total_size += file_path.stat().st_size
file_list.append((file_path, file_path.relative_to(directory)))
print(f"[SheepIt Submit] Found {file_count} files, total size: {total_size / (1024*1024):.2f} MB")
print(f"[SheepIt Submit] Creating ZIP (this may take a while)...")
if progress_callback:
progress_callback(1.0, f"Creating ZIP archive ({file_count} files, {total_size / (1024*1024):.1f} MB)...")
start_time = time.time()
files_added = 0
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_STORED) as zipf:
for dir_arc in sorted(dir_arcs):
arcname = str(dir_arc).replace("\\", "/") + "/"
zipf.writestr(arcname, "")
for file_path, arcname in file_list:
if cancel_check and cancel_check():
raise InterruptedError("ZIP creation cancelled by user")
if not file_path.exists():
continue
try:
zipf.write(file_path, arcname)
files_added += 1
# Progress updates - more frequent for large files
if files_added == 1:
print(f"[SheepIt Submit] Adding files to ZIP...")
if progress_callback:
progress_callback(2.0, f"Adding files to ZIP... (1/{file_count})")
elif files_added % 10 == 0 or (file_count > 0 and files_added % max(1, file_count // 100) == 0):
elapsed = time.time() - start_time
rate = files_added / elapsed if elapsed > 0 else 0
progress_pct = 2.0 + (files_added / file_count * 93.0) if file_count > 0 else 2.0
print(f"[SheepIt Submit] Progress: {files_added}/{file_count} files ({files_added*100//file_count}%), {rate:.1f} files/sec")
if progress_callback:
progress_callback(progress_pct, f"Creating ZIP... ({files_added}/{file_count} files, {rate:.1f} files/sec)")
except Exception as e:
print(f"[SheepIt Submit] WARNING: Failed to add {arcname}: {type(e).__name__}: {str(e)}")
elapsed = time.time() - start_time
print(f"[SheepIt Submit] ZIP creation completed!")
print(f"[SheepIt Submit] Files added: {files_added}/{file_count}")
print(f"[SheepIt Submit] Time taken: {elapsed:.2f} seconds")
if elapsed > 0:
print(f"[SheepIt Submit] Average rate: {files_added/elapsed:.1f} files/sec")
if progress_callback:
progress_callback(100.0, "ZIP archive created")
def register():
"""Register operators."""
bpy.utils.register_class(SHEEPIT_OT_submit_current)
def unregister():
"""Unregister operators."""
bpy.utils.unregister_class(SHEEPIT_OT_submit_current)
@@ -0,0 +1,174 @@
import bpy # type: ignore
RAINYS_EXTENSIONS_REPO_NAME = "Rainy's Extensions"
RAINYS_EXTENSIONS_REPO_URL = (
"https://raw.githubusercontent.com/RaincloudTheDragon/rainys-blender-extensions/refs/heads/main/index.json"
)
_BOOTSTRAP_DONE = False
def _log(message: str) -> None:
print(f"RainysExtensionsCheck: {message}")
def ensure_rainys_extensions_repo(_deferred: bool = False) -> None:
"""
Ensure the Rainy's Extensions repository is registered in Blender.
Safe to import and call from multiple add-ons; the helper guards against doing the
work more than once per Blender session.
"""
global _BOOTSTRAP_DONE
if _BOOTSTRAP_DONE:
return
_log("starting repository verification")
context_class_name = type(bpy.context).__name__
if context_class_name == "_RestrictContext":
if _deferred:
_log("context still restricted after deferral; aborting repo check")
return
_log("context restricted; scheduling repo check retry")
def _retry():
ensure_rainys_extensions_repo(_deferred=True)
return None
bpy.app.timers.register(_retry, first_interval=0.5)
return
prefs = getattr(bpy.context, "preferences", None)
if prefs is None:
_log("no preferences available on context; skipping")
return
preferences_changed = False
addon_prefs = None
addon_entry = None
if hasattr(getattr(prefs, "addons", None), "get"):
addon_entry = prefs.addons.get(__name__)
elif hasattr(prefs, "addons"):
try:
addon_entry = prefs.addons[__name__]
except Exception:
addon_entry = None
if addon_entry:
addon_prefs = getattr(addon_entry, "preferences", None)
addon_repo_initialized = bool(
addon_prefs and getattr(addon_prefs, "repo_initialized", False)
)
experimental = getattr(prefs, "experimental", None)
if experimental and hasattr(experimental, "use_extension_platform"):
if not experimental.use_extension_platform:
experimental.use_extension_platform = True
preferences_changed = True
_log("enabled experimental extension platform")
repositories = None
extensions_obj = getattr(prefs, "extensions", None)
if extensions_obj:
if hasattr(extensions_obj, "repos"):
repositories = extensions_obj.repos
elif hasattr(extensions_obj, "repositories"):
repositories = extensions_obj.repositories
if repositories is None:
filepaths = getattr(prefs, "filepaths", None)
repositories = getattr(filepaths, "extension_repos", None) if filepaths else None
if repositories is None:
_log("extension repositories collection missing; skipping")
return
def _repo_matches(repo) -> bool:
return getattr(repo, "remote_url", "") == RAINYS_EXTENSIONS_REPO_URL or getattr(
repo, "url", ""
) == RAINYS_EXTENSIONS_REPO_URL
matching_indices = [idx for idx, repo in enumerate(repositories) if _repo_matches(repo)]
target_repo = None
if matching_indices:
target_repo = repositories[matching_indices[0]]
if len(matching_indices) > 1 and hasattr(repositories, "remove"):
for dup_idx in reversed(matching_indices[1:]):
try:
repositories.remove(dup_idx)
_log(f"removed duplicate repository entry at index {dup_idx}")
except Exception as exc:
_log(f"could not remove duplicate repository at index {dup_idx}: {exc}")
else:
target_repo = next(
(
repo
for repo in repositories
if getattr(repo, "name", "") == RAINYS_EXTENSIONS_REPO_NAME
),
None,
)
if target_repo is None:
_log("repo missing; creating new entry")
if hasattr(repositories, "new"):
target_repo = repositories.new()
elif hasattr(repositories, "add"):
target_repo = repositories.add()
else:
_log("repository collection does not support creation; aborting")
return
else:
_log("repo entry already present; validating fields")
changed = preferences_changed
def _ensure_attr(obj, attr, value):
if hasattr(obj, attr) and getattr(obj, attr) != value:
setattr(obj, attr, value)
return True
if not hasattr(obj, attr):
_log(f"repository entry missing attribute '{attr}', skipping field")
return False
changed |= _ensure_attr(target_repo, "name", RAINYS_EXTENSIONS_REPO_NAME)
changed |= _ensure_attr(target_repo, "module", "rainys_extensions")
changed |= _ensure_attr(target_repo, "use_remote_url", True)
changed |= _ensure_attr(target_repo, "remote_url", RAINYS_EXTENSIONS_REPO_URL)
changed |= _ensure_attr(target_repo, "use_sync_on_startup", True)
changed |= _ensure_attr(target_repo, "use_cache", True)
changed |= _ensure_attr(target_repo, "use_access_token", False)
if addon_prefs and hasattr(addon_prefs, "repo_initialized") and not addon_prefs.repo_initialized:
addon_prefs.repo_initialized = True
changed = True
if not changed:
_log("repository already configured; skipping preference save")
_BOOTSTRAP_DONE = True
return
if hasattr(bpy.ops, "wm") and hasattr(bpy.ops.wm, "save_userpref"):
try:
bpy.ops.wm.save_userpref()
_log("preferences updated and saved")
except Exception as exc: # pragma: no cover
print(f"RainysExtensionsCheck: could not save preferences after repo update -> {exc}")
else:
_log("preferences API unavailable; changes not persisted")
_BOOTSTRAP_DONE = True
def register() -> None:
"""Entry point for Blender add-on registration."""
ensure_rainys_extensions_repo()
def unregister() -> None:
"""Reset bootstrap guard so next registration re-runs the checks."""
global _BOOTSTRAP_DONE
_BOOTSTRAP_DONE = False
@@ -0,0 +1,23 @@
"""
UI modules for SheepIt Project Submitter addon.
"""
from . import output_panel
from . import preferences_ui
__all__ = ["output_panel", "preferences_ui"]
def register():
"""Register all UI classes."""
# Register preferences first so we can access variables in config.py
preferences_ui.register()
# Register other UI components
output_panel.register()
def unregister():
"""Unregister all UI classes."""
output_panel.unregister()
preferences_ui.unregister()
@@ -0,0 +1,90 @@
"""
Output panel UI for SheepIt Project Submitter.
Located in the Output tab, similar to Flamenco addon.
"""
import bpy
from bpy.types import Panel
from ..utils import compat
class SHEEPIT_PT_output_panel(Panel):
"""SheepIt submission panel in Output properties."""
bl_label = "SheepIt Render Farm"
bl_idname = "SHEEPIT_PT_output_panel"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "output"
def draw(self, context):
layout = self.layout
scene = context.scene
submit_settings = scene.sheepit_submit
# Do not write to scene in draw(); operators fall back to prefs.default_output_path when output_path is empty.
# Animation Setup Section
box = layout.box()
box.label(text="Animation Setup:", icon='ACTION')
col = box.column()
col.operator("sheepit.enable_nla", text="Disable Animation Layers", icon='ACTION')
layout.separator()
# Frame Range Section
box = layout.box()
box.label(text="Frame Range:", icon='RENDER_ANIMATION')
row = box.row()
row.prop(submit_settings, "frame_range_mode", expand=True)
if submit_settings.frame_range_mode == 'CUSTOM':
col = box.column(align=True)
col.prop(submit_settings, "frame_start")
col.prop(submit_settings, "frame_end")
col.prop(submit_settings, "frame_step")
else:
# Show current scene frame range
row = box.row()
row.label(text=f"Scene Range: {scene.frame_start} - {scene.frame_end} (Step: {scene.frame_step})")
layout.separator()
# Progress Bar Section (when submitting)
if submit_settings.is_submitting:
box = layout.box()
box.label(text=submit_settings.submit_status_message, icon='TIME')
box.prop(submit_settings, "submit_progress", text="Progress", slider=True)
layout.separator()
# Packing Buttons
col = layout.column()
col.scale_y = 1.5
# Pack Current Blend button (first)
op = col.operator("sheepit.submit_current", text="Pack Current Blend", icon='EXPORT')
op = col.operator("sheepit.pack_zip", text="Pack as ZIP (for scenes with caches)", icon='PACKAGE')
row = layout.row()
row.prop(submit_settings, "exclude_video_from_zip", text="Exclude video/audio from ZIP")
# Pack as Blend button
op = col.operator("sheepit.pack_blend", text="Pack as Blend", icon='FILE_BLEND')
layout.separator()
# Output Path Section
box = layout.box()
box.label(text="Output Path:", icon='FILE_FOLDER')
row = box.row()
row.prop(submit_settings, "output_path", text="")
row = box.row()
row.prop(submit_settings, "project_size_limit_gb", text="Size limit (GB)")
def register():
"""Register panel."""
compat.safe_register_class(SHEEPIT_PT_output_panel)
def unregister():
"""Unregister panel."""
compat.safe_unregister_class(SHEEPIT_PT_output_panel)
@@ -0,0 +1,106 @@
"""
Preferences UI for SheepIt Project Submitter.
"""
import sys
import bpy
from bpy.types import AddonPreferences
from bpy.props import StringProperty
from .. import config
# Get the root module name dynamically
def _get_addon_module_name():
"""Get the root addon module name for bl_idname."""
# In Blender 5.0 extensions loaded via VSCode, the module name is the full path
# e.g., "bl_ext.vscode_development.sheepit_project_submitter"
# We need to get it from the parent package (sheepit_project_submitter)
try:
# Get parent package name from __package__ (remove .ui suffix)
if __package__:
parent_pkg = __package__.rsplit('.', 1)[0] if '.' in __package__ else __package__
# Get the actual module from sys.modules to get its __name__
parent_module = sys.modules.get(parent_pkg)
if parent_module and hasattr(parent_module, '__name__'):
module_name = parent_module.__name__
config.debug_print(f"[SheepIt Debug] Using parent module __name__ as bl_idname: {module_name}")
return module_name
else:
# Use the package name directly
config.debug_print(f"[SheepIt Debug] Using parent package name as bl_idname: {parent_pkg}")
return parent_pkg
except Exception as e:
config.debug_print(f"[SheepIt Debug] Could not get parent module name: {e}")
# Last fallback
module_name = config.ADDON_ID
config.debug_print(f"[SheepIt Debug] Using fallback bl_idname: {module_name}")
return module_name
class SHEEPIT_AddonPreferences(AddonPreferences):
"""Addon preferences for SheepIt Project Submitter."""
# bl_idname must match the add-on's module name exactly
# Get it dynamically to ensure it matches what Blender registered
bl_idname = _get_addon_module_name()
default_output_path: StringProperty(
name="Default Output Path",
description="Default directory path where packed files will be saved",
default="",
subtype='DIR_PATH',
update=lambda self, context: _sync_default_output_path(self, context),
)
def draw(self, context):
layout = self.layout
# Output path settings
box = layout.box()
box.label(text="Output Settings:", icon='FILE_FOLDER')
box.prop(self, "default_output_path")
layout.separator()
# Info box
box = layout.box()
box.label(text="About:", icon='INFO')
box.label(text="This addon packs your Blender projects for manual upload to SheepIt.")
box.label(text="You must manually upload and configure projects on the SheepIt website.")
def _sync_default_output_path(prefs, context):
"""Sync default output path to all scenes' output_path if they're empty."""
if not prefs.default_output_path:
return
# Update all scenes' output_path if they're empty
for scene in bpy.data.scenes:
if hasattr(scene, 'sheepit_submit') and scene.sheepit_submit:
if not scene.sheepit_submit.output_path:
scene.sheepit_submit.output_path = prefs.default_output_path
reg_list = [SHEEPIT_AddonPreferences]
def register():
"""Register preferences."""
# Register preferences class
for cls in reg_list:
try:
from bpy.utils import register_class
register_class(cls)
config.debug_print(f"[SheepIt Debug] Registered preferences class: {cls.__name__} with bl_idname: {cls.bl_idname}")
except Exception as e:
print(f"[SheepIt Error] Failed to register preferences class {cls.__name__}: {e}")
import traceback
traceback.print_exc()
def unregister():
"""Unregister preferences."""
# Unregister preferences class
from ..utils import compat
for cls in reg_list:
compat.safe_unregister_class(cls)
@@ -0,0 +1,21 @@
"""
Utility modules for SheepIt Project Submitter addon.
"""
from . import compat
from . import version
# Don't import auth at module level to avoid circular imports
# It will be imported lazily when needed
# Users should import it as: from ..utils.auth import <function>
# Or: from ..utils import auth (which will trigger lazy import)
__all__ = ["compat", "version"]
def __getattr__(name):
"""Lazy import for auth module to avoid circular imports."""
if name == "auth":
from . import auth
return auth
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -0,0 +1,116 @@
"""
Compatibility layer for handling differences across Blender versions.
Supports full SheepIt range: 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 4.0, 4.1, 4.2, 4.3, 4.4, 4.5, 5.0+
Minimum supported: Blender 3.0
"""
import bpy
from bpy.utils import register_class, unregister_class
from . import version
def safe_register_class(cls):
"""
Safely register a class, handling any version-specific registration issues.
Args:
cls: The class to register
Returns:
bool: True if registration succeeded, False otherwise
"""
try:
register_class(cls)
return True
except Exception as e:
print(f"Warning: Failed to register {cls.__name__}: {e}")
return False
def safe_unregister_class(cls):
"""
Safely unregister a class, handling any version-specific unregistration issues.
Args:
cls: The class to unregister
Returns:
bool: True if unregistration succeeded, False otherwise
"""
try:
unregister_class(cls)
return True
except Exception as e:
print(f"Warning: Failed to unregister {cls.__name__}: {e}")
return False
def get_addon_prefs():
"""
Get the addon preferences instance, compatible across versions.
Returns:
AddonPreferences or None: The addon preferences instance if found
"""
from .. import config
prefs = bpy.context.preferences
addon = prefs.addons.get(config.ADDON_ID, None)
if addon and getattr(addon, "preferences", None):
return addon.preferences
for addon in prefs.addons.values():
ap = getattr(addon, "preferences", None)
if ap and hasattr(ap, "default_output_path"):
return ap
return None
def is_library_or_override(datablock):
"""
Check if a datablock is library-linked or an override.
Args:
datablock: The datablock to check
Returns:
bool: True if the datablock is library-linked or an override, False otherwise
"""
# Check if datablock is linked from a library
if hasattr(datablock, 'library') and datablock.library:
return True
# Check if datablock is an override (Blender 3.0+)
if hasattr(datablock, 'override_library') and datablock.override_library:
return True
return False
def get_file_path_map(include_libraries=False):
"""
Get file path map, handling version differences.
Args:
include_libraries (bool): Whether to include library paths
Returns:
dict: File path map
"""
try:
return bpy.data.file_path_map(include_libraries=include_libraries)
except Exception:
# Fallback for older versions
return {}
def get_user_map():
"""
Get user map, handling version differences.
Returns:
dict: User map
"""
try:
return bpy.data.user_map()
except Exception:
# Fallback for older versions
return {}
@@ -0,0 +1,139 @@
"""
Version detection and comparison utilities for multi-version Blender support.
Supports full SheepIt range: 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 4.0, 4.1, 4.2, 4.3, 4.4, 4.5, 5.0+
Minimum supported: Blender 3.0
"""
import bpy
def get_blender_version():
"""
Returns the current Blender version as a tuple (major, minor, patch).
Returns:
tuple: (major, minor, patch) version numbers
"""
return bpy.app.version
def get_version_string():
"""
Returns the current Blender version as a string (e.g., "4.2.0").
Returns:
str: Version string in format "major.minor.patch"
"""
version = get_blender_version()
return f"{version[0]}.{version[1]}.{version[2]}"
def is_version_at_least(major, minor=0, patch=0):
"""
Check if the current Blender version is at least the specified version.
Args:
major (int): Major version number
minor (int): Minor version number (default: 0)
patch (int): Patch version number (default: 0)
Returns:
bool: True if current version >= specified version
"""
current = get_blender_version()
target = (major, minor, patch)
if current[0] != target[0]:
return current[0] > target[0]
if current[1] != target[1]:
return current[1] > target[1]
return current[2] >= target[2]
def is_version_less_than(major, minor=0, patch=0):
"""
Check if the current Blender version is less than the specified version.
Args:
major (int): Major version number
minor (int): Minor version number (default: 0)
patch (int): Patch version number (default: 0)
Returns:
bool: True if current version < specified version
"""
return not is_version_at_least(major, minor, patch)
def is_version_3_x():
"""Check if running Blender 3.x."""
version = get_blender_version()
return version[0] == 3
def is_version_4_0():
"""Check if running Blender 4.0."""
return is_version_at_least(4, 0, 0) and is_version_less_than(4, 1, 0)
def is_version_4_1():
"""Check if running Blender 4.1."""
return is_version_at_least(4, 1, 0) and is_version_less_than(4, 2, 0)
def is_version_4_2():
"""Check if running Blender 4.2 LTS."""
version = get_blender_version()
return version[0] == 4 and version[1] == 2
def is_version_4_3():
"""Check if running Blender 4.3."""
return is_version_at_least(4, 3, 0) and is_version_less_than(4, 4, 0)
def is_version_4_4():
"""Check if running Blender 4.4."""
return is_version_at_least(4, 4, 0) and is_version_less_than(4, 5, 0)
def is_version_4_5():
"""Check if running Blender 4.5 LTS."""
return is_version_at_least(4, 5, 0) and is_version_less_than(5, 0, 0)
def is_version_5_0():
"""Check if running Blender 5.0 or later."""
return is_version_at_least(5, 0, 0)
def get_version_category():
"""
Returns the version category string for the current Blender version.
Returns:
str: Version category like '3.0', '3.x', '4.0', '4.1', '4.2', '4.3', '4.4', '4.5', or '5.0+'
"""
version = get_blender_version()
major, minor = version[0], version[1]
if major == 3:
return '3.x'
elif major == 4:
if minor == 0:
return '4.0'
elif minor == 1:
return '4.1'
elif minor == 2:
return '4.2'
elif minor == 3:
return '4.3'
elif minor == 4:
return '4.4'
elif minor >= 5:
return '4.5'
elif major >= 5:
return '5.0+'
# Fallback
return f"{major}.{minor}"