Scene Manager: LOD switching; Shot Viewer renamed Shot Manager

LODs are child collections of the asset collection (COL_{asset}_LOD{n}).
Overrides hide the other LOD collections (viewport and render), keeping
the rig, pose and animation; collection instances instance the LOD
collection. Each instance has its own LOD, set per instance or for the
selection in the Loaded panel, kept through save, reload and updates.
Loading shows SCENE_MANAGER_DEFAULT_LOD (0).

The Shot Viewer panel is now "Shot Manager" and starts closed. Code
names and SHOT_VIEWER_* config keys are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NDemaerschalkandClaude Opus 5.5 committed 2026-10-27 16:42:24 +01:00
1 parent 9fe38ce58a
commit bac7a6f5da
10 files changed
+258 -45

No files matched your search

+11 -4
View File
@@ -1,7 +1,7 @@
# Melvile Tools: notes for Claude # Melvile Tools: notes for Claude
Blender add-on (Blender 5.2) for the Melvile pipeline at Walking The Dog: Sanity Check, Blender add-on (Blender 5.2) for the Melvile pipeline at Walking The Dog: Sanity Check,
Rename, Shot Viewer and Scene Manager panels in the 3D View sidebar (WTD tab). `README.md` Rename, Shot Manager and Scene Manager panels in the 3D View sidebar (WTD tab). `README.md`
is the user guide: keep it up to date with every user-visible change. is the user guide: keep it up to date with every user-visible change.
## Layout and conventions ## Layout and conventions
@@ -42,7 +42,7 @@ that contains this repo on `sys.path` and importing the repo folder name (see
1. `blender -b --factory-startup --python dev/make_test_assets.py -- <workdir>` 1. `blender -b --factory-startup --python dev/make_test_assets.py -- <workdir>`
2. `python dev/fake_libreflow.py <workdir>` (keep running; restart for a clean state) 2. `python dev/fake_libreflow.py <workdir>` (keep running; restart for a clean state)
3. `blender -b --factory-startup --python-exit-code 1 --python dev/test_scene_manager.py -- <workdir>` 3. `blender -b --factory-startup --python-exit-code 1 --python dev/test_scene_manager.py -- <workdir>`
(45 checks, "All checks passed"). `/_test/publish` and `/_test/download` simulate a new (58 checks, "All checks passed"). `/_test/publish` and `/_test/download` simulate a new
publish and a requested revision reaching this site. publish and a requested revision reaching this site.
The fake server follows the real `wtdcmds` replies; check them against the real The fake server follows the real `wtdcmds` replies; check them against the real
Libreflow when one is available. Libreflow when one is available.
@@ -54,12 +54,12 @@ that contains this repo on `sys.path` and importing the repo folder name (see
(`src/libreflow/extensions/wtdcmds/wtd_cmds.py`, `BLENDER_ADDON_BRIEF.md`). (`src/libreflow/extensions/wtdcmds/wtd_cmds.py`, `BLENDER_ADDON_BRIEF.md`).
Server address/token: `~/.kabaret/ipc/server.json` (config `LIBREFLOW_SERVER_FILE`). Server address/token: `~/.kabaret/ipc/server.json` (config `LIBREFLOW_SERVER_FILE`).
- Add Camera Rigs extension: `bpy.ops.object.build_camera_rig(mode=...)` builds the - Add Camera Rigs extension: `bpy.ops.object.build_camera_rig(mode=...)` builds the
Shot Viewer cameras. It finds its rigs by the `rig_id` custom property, not by name. Shot Manager cameras. It finds its rigs by the `rig_id` custom property, not by name.
- `tools_ops.py` "Get Oid" uses the separate kabaret Blender client (`bpy.ops.kabaret.cmds_call`). - `tools_ops.py` "Get Oid" uses the separate kabaret Blender client (`bpy.ops.kabaret.cmds_call`).
## Design notes (found by testing) ## Design notes (found by testing)
- Shot Viewer: shots are timeline markers bound to cameras, in one scene. Setting - Shot Manager: shots are timeline markers bound to cameras, in one scene. Setting
`scene.frame_current` does not switch the camera (only `frame_set` does): set `scene.frame_current` does not switch the camera (only `frame_set` does): set
`scene.camera` explicitly. `scene.camera` explicitly.
- Scene Manager: tags live on `bpy.types.Library` only. Library overrides lose custom - Scene Manager: tags live on `bpy.types.Library` only. Library overrides lose custom
@@ -72,6 +72,13 @@ that contains this repo on `sys.path` and importing the repo folder name (see
- Removing a library leaves the libraries it linked indirectly (e.g. Blender's - Removing a library leaves the libraries it linked indirectly (e.g. Blender's
`geometry_nodes_essentials.blend`): `remove_unused_libraries()` drops the ones whose data `geometry_nodes_essentials.blend`): `remove_unused_libraries()` drops the ones whose data
is no longer used, never a managed library. is no longer used, never a managed library.
- LODs are child collections of the asset collection (`COL_{asset}_LOD{n}`). Overrides
switch by `hide_viewport`/`hide_render` on the LOD override collections: Blender stores
them as override properties (kept through save, reload and resync) and hidden LODs are
not evaluated. Collection instances instance the LOD collection itself. A second override's
collections are renamed (`.001`): match LODs on `override_library.reference.name`.
- "Shot Manager" is the user-facing name of what the code calls `shot_viewer`
(module, operators, `SHOT_VIEWER_*` config keys kept for existing override files).
- Background requests (`scene_manager.ipc.run`) hand results back through a timer; - Background requests (`scene_manager.ipc.run`) hand results back through a timer;
in callbacks, find scenes by name (`bpy.context.scene` is not reliable in timers). in callbacks, find scenes by name (`bpy.context.scene` is not reliable in timers).
+33 -14
View File
@@ -4,7 +4,7 @@ Blender add-on for the Melvile pipeline (Walking The Dog). It brings together:
- **[Sanity Check](#sanity-check)**: checks a file against the production specs and fixes what can be fixed. - **[Sanity Check](#sanity-check)**: checks a file against the production specs and fixes what can be fixed.
- **[Rename](#rename)**: batch renames objects and materials to the naming convention, with consistent numbering. - **[Rename](#rename)**: batch renames objects and materials to the naming convention, with consistent numbering.
- **[Shot Viewer](#shot-viewer)**: switch, create and delete the shots of a sequence (camera markers) from the sidebar, with quick access to the character and prop rigs. - **[Shot Manager](#shot-manager)**: switch, create and delete the shots of a sequence (camera markers) from the sidebar, with quick access to the character and prop rigs.
- **[Scene Manager](#scene-manager)**: loads the assets cast in a shot from Libreflow, keeps them up to date, and publishes scene collections as new assets. - **[Scene Manager](#scene-manager)**: loads the assets cast in a shot from Libreflow, keeps them up to date, and publishes scene collections as new assets.
- **[Get Oid](#get-oid-for-open-scene)**: asks Libreflow for the id of the open file. - **[Get Oid](#get-oid-for-open-scene)**: asks Libreflow for the id of the open file.
@@ -19,7 +19,7 @@ Most values the tools rely on (prefixes, shot naming, frame rate…) live in `co
- [Sanity Check](#sanity-check) - [Sanity Check](#sanity-check)
- [Tools panel](#tools-panel) - [Tools panel](#tools-panel)
- [Rename](#rename) - [Rename](#rename)
- [Shot Viewer](#shot-viewer) - [Shot Manager](#shot-manager)
- [Scene Manager](#scene-manager) - [Scene Manager](#scene-manager)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
@@ -31,7 +31,7 @@ Most values the tools rely on (prefixes, shot naming, frame rate…) live in `co
Tested on **Blender 5.2**. Tested on **Blender 5.2**.
The Shot Viewer builds its cameras with the **Add Camera Rigs** extension (*Edit > Preferences > Get Extensions*, search "Add Camera Rigs"). Install and enable it next to Melvile Tools. The Shot Manager builds its cameras with the **Add Camera Rigs** extension (*Edit > Preferences > Get Extensions*, search "Add Camera Rigs"). Install and enable it next to Melvile Tools.
The Scene Manager talks to a **running Libreflow** with the `wtdcmds` extension, on the same machine. The Scene Manager talks to a **running Libreflow** with the `wtdcmds` extension, on the same machine.
@@ -63,7 +63,7 @@ Open the sidebar of the 3D Viewport (**N**) and click the **WTD** tab.
| **Sanity Check** | 3D Viewport, Video Sequencer | | **Sanity Check** | 3D Viewport, Video Sequencer |
| **Tools** (Get Oid) | 3D Viewport | | **Tools** (Get Oid) | 3D Viewport |
| **Rename** | 3D Viewport, Shader Editor | | **Rename** | 3D Viewport, Shader Editor |
| **Shot Viewer** | 3D Viewport | | **Shot Manager** | 3D Viewport |
| **Scene Manager** (Casting, Loaded, Assetize) | 3D Viewport | | **Scene Manager** (Casting, Loaded, Assetize) | 3D Viewport |
--- ---
@@ -238,7 +238,7 @@ The box under the **Rename** button previews every change (`old name → new nam
--- ---
## Shot Viewer ## Shot Manager
A sidebar panel to go through the shots of a sequence in their camera. You can switch shots, add and delete them, and grab the character and prop rigs. A sidebar panel to go through the shots of a sequence in their camera. You can switch shots, add and delete them, and grab the character and prop rigs.
@@ -255,7 +255,7 @@ During playback, Blender itself switches to the camera of each marker, so playin
### Where it is ### Where it is
*3D Viewport sidebar (**N**) > WTD tab > **Shot Viewer***. *3D Viewport sidebar (**N**) > WTD tab > **Shot Manager***.
Choosing a shot in the panel moves the playhead to it and puts **the 3D view you clicked in** into that shot's camera view. Keep a Timeline open (for example at the bottom of the Layout workspace) to see the camera markers and where you are in the sequence. Choosing a shot in the panel moves the playhead to it and puts **the 3D view you clicked in** into that shot's camera view. Keep a Timeline open (for example at the bottom of the Layout workspace) to see the camera markers and where you are in the sequence.
@@ -360,6 +360,7 @@ Lists the assets cast in the shot, with how many instances are loaded out of how
- **Collection:** from that file, `COL_<asset>`, else `<asset>`, else `environments` (set dressing files), else the file's only top-level collection. - **Collection:** from that file, `COL_<asset>`, else `<asset>`, else `environments` (set dressing files), else the file's only top-level collection.
- **Characters, props and vehicles** (`*_character`, `main_props`, `secondary_props`, `vehicles`) become **library overrides**, so animators can pose them. Other types, like sets and set props, become **collection instances**. - **Characters, props and vehicles** (`*_character`, `main_props`, `secondary_props`, `vehicles`) become **library overrides**, so animators can pose them. Other types, like sets and set props, become **collection instances**.
- **Where they go:** each asset type gets its own collection, `COL_main_character`, `COL_main_props`, `COL_bg_location`… - **Where they go:** each asset type gets its own collection, `COL_main_character`, `COL_main_props`, `COL_bg_location`…
- **Level of detail:** when the asset collection holds LOD collections (`COL_<asset>_LOD0`, `COL_<asset>_LOD1`…), LOD 0 is shown (the lowest LOD the file has, if not LOD 0). See [Levels of detail](#levels-of-detail).
All of these can be changed in the [Configuration](#configuration). All of these can be changed in the [Configuration](#configuration).
@@ -368,14 +369,32 @@ All of these can be changed in the [Configuration](#configuration).
Lists everything the Scene Manager loaded, one line per asset, with its version: Lists everything the Scene Manager loaded, one line per asset, with its version:
- The version is **red** when it is not the latest publish. The line under it says either "v004 available" (click the update icon), or "v004 published, not available on this site yet" (click the globe to request it, then "v004 requested, waiting for the download"). - The version is **red** when it is not the latest publish. The line under it says either "v004 available" (click the update icon), or "v004 published, not available on this site yet" (click the globe to request it, then "v004 requested, waiting for the download").
- **Arrow**: shows the asset's instances, each with a button to select its objects and one to remove it. - **Arrow**: shows the asset's instances, each with a button to select its objects and one to remove it. Under an instance with levels of detail, one button per LOD (`LOD 0 1 2`), the shown one pressed.
- **Menu icon**: pick another published version, for example to roll back. Versions not on this site are marked: picking one requests it; pick it again once it is downloaded. - **Menu icon**: pick another published version, for example to roll back. Versions not on this site are marked: picking one requests it; pick it again once it is downloaded.
- **✕**: removes the asset entirely: all its instances and the link to its file. - **✕**: removes the asset entirely: all its instances and the link to its file.
- **Update All** / **Update Selected**: switch all the loaded assets (or the ones of the selected objects) to their latest available publish. - **Update All** / **Update Selected**: switch all the loaded assets (or the ones of the selected objects) to their latest available publish.
- **Remove Selected**: removes the instances of the selected objects. - **Remove Selected**: removes the instances of the selected objects.
- **Selected LOD** (shown when a selected instance has LODs): switch the instances of the selected objects to that LOD.
- The magnifier re-checks the versions. - The magnifier re-checks the versions.
Updating swaps the linked file and reloads it. Overrides keep their posing and animation. **Ctrl+Z** undoes removing and updating. Updating swaps the linked file and reloads it. Overrides keep their posing and animation, and instances keep their LOD (an instance showing a LOD the new version no longer has goes back to LOD 0). **Ctrl+Z** undoes removing, updating and LOD switches.
### Levels of detail
Rigs and models can hold their levels of detail as **child collections of the asset collection**, named `COL_<asset>_LOD<n>`. One LOD is shown at a time:
```
COL_bob
├─ RIG_bob (shared: rig, controllers…)
├─ COL_bob_LOD0 shown
├─ COL_bob_LOD1 hidden
└─ COL_bob_LOD2 hidden
```
- **Overrides** (characters, props, vehicles): the other LOD collections are hidden in the viewport and the render, so they cost nothing. The rig and everything outside the LOD collections stay; posing and animation are kept when switching.
- **Collection instances** (sets, set props…): the instance shows the LOD collection itself. This needs everything of the asset to sit in its LOD collections: an asset with objects or collections next to them cannot switch LODs as an instance (load it as an override instead).
Each instance has its own LOD: two instances of the same asset can show different LODs. The choice is saved with the file. The naming and the LOD shown on load are `SCENE_MANAGER_LOD_COLLECTION` and `SCENE_MANAGER_DEFAULT_LOD` in the [Configuration](#configuration).
### Assetize ### Assetize
@@ -434,8 +453,8 @@ Name formats use Python's `str.format`: `"sq{:04d}"` turns `10` into `sq0010`. `
| Sanity Check: layout | `SET_COLLECTION_PATTERN` | | Sanity Check: layout | `SET_COLLECTION_PATTERN` |
| Sanity Check: panel | `CATEGORY_ORDER`, `MAX_ISSUES_DRAWN` | | Sanity Check: panel | `CATEGORY_ORDER`, `MAX_ISSUES_DRAWN` |
| Rename | `RENAME_SEPARATOR`, `RENAME_PADDING`, `RENAME_SIDES`, `RENAME_TYPE_SUFFIXES` (plus `OBJECT_PREFIXES` / `DATABLOCK_PREFIXES` for *By Type* prefixes) | | Rename | `RENAME_SEPARATOR`, `RENAME_PADDING`, `RENAME_SIDES`, `RENAME_TYPE_SUFFIXES` (plus `OBJECT_PREFIXES` / `DATABLOCK_PREFIXES` for *By Type* prefixes) |
| Shot Viewer | `SHOT_VIEWER_RIG_GROUPS`, `SHOT_VIEWER_ASSET_TYPE_GROUPS`, `SHOT_NAME_FORMAT`, `SHOT_NUMBER_STEP`, `SHOT_FRAME_START`, `SHOT_DEFAULT_DURATION`, `SEQUENCE_NAME_FORMAT`, `SHOT_CAMERA_NAME_FORMAT`, `SHOT_CAMERA_RIG_NAME_FORMAT`, `SHOT_CAMERA_RIG_MODES` | | Shot Manager | `SHOT_VIEWER_RIG_GROUPS`, `SHOT_VIEWER_ASSET_TYPE_GROUPS`, `SHOT_NAME_FORMAT`, `SHOT_NUMBER_STEP`, `SHOT_FRAME_START`, `SHOT_DEFAULT_DURATION`, `SEQUENCE_NAME_FORMAT`, `SHOT_CAMERA_NAME_FORMAT`, `SHOT_CAMERA_RIG_NAME_FORMAT`, `SHOT_CAMERA_RIG_MODES` |
| Scene Manager | `LIBREFLOW_SERVER_FILE`, `LIBREFLOW_TIMEOUT`, `SCENE_MANAGER_FILE_PATTERNS`, `SCENE_MANAGER_COLLECTION_NAMES`, `SCENE_MANAGER_LINK_MODES`, `SCENE_MANAGER_TYPE_COLLECTION`, `SCENE_MANAGER_ASSETIZE_TASK` | | Scene Manager | `LIBREFLOW_SERVER_FILE`, `LIBREFLOW_TIMEOUT`, `SCENE_MANAGER_FILE_PATTERNS`, `SCENE_MANAGER_COLLECTION_NAMES`, `SCENE_MANAGER_LINK_MODES`, `SCENE_MANAGER_LOD_COLLECTION`, `SCENE_MANAGER_DEFAULT_LOD`, `SCENE_MANAGER_TYPE_COLLECTION`, `SCENE_MANAGER_ASSETIZE_TASK` |
--- ---
@@ -453,10 +472,10 @@ Either no character reference is set (see [Character reference](#character-refer
**Rename: the preview shows nothing.** **Rename: the preview shows nothing.**
Nothing is selected, the selection is linked from a library, or (in *Materials* mode) the selected objects have no local material. Nothing is selected, the selection is linked from a library, or (in *Materials* mode) the selected objects have no local material.
**Shot Viewer: a rig is not in the list.** **Shot Manager: a rig is not in the list.**
Only armatures in the scene's view layer are listed. A collection instance that has not been overridden does not expose its armature: make a library override first. Only armatures in the scene's view layer are listed. A collection instance that has not been overridden does not expose its armature: make a library override first.
**Shot Viewer: a shot is missing from the list.** **Shot Manager: a shot is missing from the list.**
Only markers bound to a camera are shots. Select the marker, select its camera, and press **Ctrl+B** in the Timeline. Only markers bound to a camera are shots. Select the marker, select its camera, and press **Ctrl+B** in the Timeline.
**Scene Manager: "Libreflow is not running".** **Scene Manager: "Libreflow is not running".**
@@ -471,7 +490,7 @@ The newer version is published but not downloaded on this site yet. Request it (
**Scene Manager: "...its wtdcmds extension is too old".** **Scene Manager: "...its wtdcmds extension is too old".**
Libreflow runs an older `wtdcmds` without the command (e.g. requests). Update the extension and restart Libreflow. Libreflow runs an older `wtdcmds` without the command (e.g. requests). Update the extension and restart Libreflow.
**Shot Viewer: playback only plays one shot.** **Shot Manager: playback only plays one shot.**
**Loop Shot** is on. Untick it to play the whole sequence. **Loop Shot** is on. Untick it to play the whole sequence.
--- ---
@@ -489,7 +508,7 @@ Libreflow runs an older `wtdcmds` without the command (e.g. requests). Update th
| `checks/` | The checks, one folder per family | | `checks/` | The checks, one folder per family |
| `ui/` | Sanity Check operators, settings and every panel | | `ui/` | Sanity Check operators, settings and every panel |
| `rename.py` | Rename tool (logic, settings, operator) | | `rename.py` | Rename tool (logic, settings, operator) |
| `shot_viewer.py` | Shot Viewer (logic, settings, operators) | | `shot_viewer.py` | Shot Manager (logic, settings, operators) |
| `tools_ops.py` | Libreflow tools (Get Oid) | | `tools_ops.py` | Libreflow tools (Get Oid) |
| `scene_manager/` | Scene Manager: `ipc.py` (Libreflow client and background jobs), `library.py` (linking, overrides, updates), `operators.py` | | `scene_manager/` | Scene Manager: `ipc.py` (Libreflow client and background jobs), `library.py` (linking, overrides, updates), `operators.py` |
+2 -2
View File
@@ -46,7 +46,7 @@ def register() -> None:
# Register the rename tool (settings and operator, before its panel) # Register the rename tool (settings and operator, before its panel)
rename.register() rename.register()
# Register the shot viewer (settings and operators, before its panel) # Register the shot manager (settings and operators, before its panel)
shot_viewer.register() shot_viewer.register()
# Register the scene manager (settings and operators, before its panels) # Register the scene manager (settings and operators, before its panels)
@@ -80,7 +80,7 @@ def unregister() -> None:
# Unregister the rename tool (after its panel) # Unregister the rename tool (after its panel)
rename.unregister() rename.unregister()
# Unregister the shot viewer (after its panel) # Unregister the shot manager (after its panel)
shot_viewer.unregister() shot_viewer.unregister()
# Unregister the scene manager (after its panels) # Unregister the scene manager (after its panels)
+10 -4
View File
@@ -234,7 +234,7 @@ RENAME_TYPE_SUFFIXES = {
} }
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Shot viewer # Shot manager
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Groups of the rig quick selection: an armature goes to the first group whose # Groups of the rig quick selection: an armature goes to the first group whose
# pattern matches its name, one of its collections or its library file name. # pattern matches its name, one of its collections or its library file name.
@@ -253,11 +253,11 @@ SHOT_VIEWER_ASSET_TYPE_GROUPS = {
"vehicles": "Props", "vehicles": "Props",
} }
# Shots added by the shot viewer. TODO: confirm with production. # Shots added by the shot manager. TODO: confirm with production.
SHOT_NAME_FORMAT = "sh{:04d}" SHOT_NAME_FORMAT = "sh{:04d}"
# Sequence of the scene (number set in the Shot Viewer panel): SEQUENCE_NAME_FORMAT.format(number). # Sequence of the scene (number set in the Shot Manager panel): SEQUENCE_NAME_FORMAT.format(number).
SEQUENCE_NAME_FORMAT = "sq{:04d}" SEQUENCE_NAME_FORMAT = "sq{:04d}"
# Cameras added by the shot viewer. Tokens: {prefix} (OBJECT_PREFIXES["CAMERA"]), # Cameras added by the shot manager. Tokens: {prefix} (OBJECT_PREFIXES["CAMERA"]),
# {sequence} (SEQUENCE_NAME_FORMAT) and {shot} (the shot name). # {sequence} (SEQUENCE_NAME_FORMAT) and {shot} (the shot name).
SHOT_CAMERA_NAME_FORMAT = "{prefix}{sequence}_{shot}" SHOT_CAMERA_NAME_FORMAT = "{prefix}{sequence}_{shot}"
# Their rig, same tokens with {prefix} = OBJECT_PREFIXES["ARMATURE"]. # Their rig, same tokens with {prefix} = OBJECT_PREFIXES["ARMATURE"].
@@ -298,6 +298,12 @@ SCENE_MANAGER_LINK_MODES = {
"vehicles": "OVERRIDE", "vehicles": "OVERRIDE",
"*": "INSTANCE", "*": "INSTANCE",
} }
# Levels of detail: child collections of the asset collection, one per LOD ({asset} =
# asset name, {lod} = LOD number). An override shows one and hides the others; a
# collection instance instances it.
SCENE_MANAGER_LOD_COLLECTION = "COL_{asset}_LOD{lod}"
# LOD shown when an asset is loaded (the lowest one when missing). None: as in the file.
SCENE_MANAGER_DEFAULT_LOD = 0
# Collection gathering the loaded assets of a type, under the scene collection. # Collection gathering the loaded assets of a type, under the scene collection.
SCENE_MANAGER_TYPE_COLLECTION = "COL_{asset_type}" SCENE_MANAGER_TYPE_COLLECTION = "COL_{asset_type}"
# Default task of the assetize dialog. # Default task of the assetize dialog.
+14 -5
View File
@@ -3,8 +3,9 @@
blender -b --factory-startup --python dev/make_test_assets.py -- <workdir> blender -b --factory-startup --python dev/make_test_assets.py -- <workdir>
bob: a rig (COL_bob: armature + meshes), v001 has 1 mesh, v002 has 2. bob: a rig (COL_bob: armature + meshes), v001 has 1 mesh, v002 has 2.
tree: COL_tree, plus an unrelated top-level collection. tree: COL_tree holding only LOD collections (COL_tree_LOD0, COL_tree_LOD1), plus an
car: COL_car. unrelated top-level collection. v002 adds a mesh outside the LODs, v003 has no LOD.
car: a rig, COL_car: armature + COL_car_LOD0..2.
rock: a single top-level collection not named after the asset (fallback case), its mesh rock: a single top-level collection not named after the asset (fallback case), its mesh
using a node group linked from Blender's essentials (an indirect library). using a node group linked from Blender's essentials (an indirect library).
""" """
@@ -23,7 +24,7 @@ def mesh_object(name):
return bpy.data.objects.new(name, mesh) return bpy.data.objects.new(name, mesh)
def write(path, collection_name, meshes=1, rig=False, extra=False, node_group=False): def write(path, collection_name, meshes=1, rig=False, extra=False, node_group=False, lods=0):
bpy.ops.wm.read_homefile(use_empty=True) bpy.ops.wm.read_homefile(use_empty=True)
scene = bpy.context.scene scene = bpy.context.scene
collection = bpy.data.collections.new(collection_name) collection = bpy.data.collections.new(collection_name)
@@ -41,6 +42,12 @@ def write(path, collection_name, meshes=1, rig=False, extra=False, node_group=Fa
ob = mesh_object(f"GEO_{collection_name}_{index}") ob = mesh_object(f"GEO_{collection_name}_{index}")
ob.parent = parent ob.parent = parent
collection.objects.link(ob) collection.objects.link(ob)
for lod in range(lods):
lod_collection = bpy.data.collections.new(f"{collection_name}_LOD{lod}")
collection.children.link(lod_collection)
ob = mesh_object(f"GEO_{collection_name}_LOD{lod}")
ob.parent = parent
lod_collection.objects.link(ob)
if node_group: if node_group:
essentials = os.path.join(bpy.utils.system_resource("DATAFILES"), "assets", "nodes", "geometry_nodes_essentials.blend") essentials = os.path.join(bpy.utils.system_resource("DATAFILES"), "assets", "nodes", "geometry_nodes_essentials.blend")
with bpy.data.libraries.load(essentials, link=True) as (_data_from, data_to): with bpy.data.libraries.load(essentials, link=True) as (_data_from, data_to):
@@ -55,7 +62,9 @@ def write(path, collection_name, meshes=1, rig=False, extra=False, node_group=Fa
lib = os.path.join(WORKDIR, "lib") lib = os.path.join(WORKDIR, "lib")
write(os.path.join(lib, "bob", "v001", "rig.blend"), "COL_bob", meshes=1, rig=True, extra=True) write(os.path.join(lib, "bob", "v001", "rig.blend"), "COL_bob", meshes=1, rig=True, extra=True)
write(os.path.join(lib, "bob", "v002", "rig.blend"), "COL_bob", meshes=2, rig=True, extra=True) write(os.path.join(lib, "bob", "v002", "rig.blend"), "COL_bob", meshes=2, rig=True, extra=True)
write(os.path.join(lib, "tree", "v001", "model.blend"), "COL_tree", extra=True) write(os.path.join(lib, "tree", "v001", "model.blend"), "COL_tree", meshes=0, extra=True, lods=2)
write(os.path.join(lib, "tree", "v002", "model.blend"), "COL_tree", meshes=1, extra=True, lods=2)
write(os.path.join(lib, "tree", "v003", "model.blend"), "COL_tree", meshes=1, extra=True)
write(os.path.join(lib, "rock", "v001", "rock.blend"), "rock_geo", node_group=True) write(os.path.join(lib, "rock", "v001", "rock.blend"), "rock_geo", node_group=True)
write(os.path.join(lib, "car", "v001", "car.blend"), "COL_car") write(os.path.join(lib, "car", "v001", "car.blend"), "COL_car", meshes=0, rig=True, lods=3)
print("Test assets written in", lib) print("Test assets written in", lib)
+41 -1
View File
@@ -109,7 +109,7 @@ check(
"chars are posable overrides", "chars are posable overrides",
all(ob.override_library for ob in bpy.data.collections["COL_bob"].all_objects), all(ob.override_library for ob in bpy.data.collections["COL_bob"].all_objects),
) )
check("shot viewer groups bob as character", "Characters" in shot_viewer.rig_groups(bpy.context.view_layer)) check("shot manager groups bob as character", "Characters" in shot_viewer.rig_groups(bpy.context.view_layer))
bpy.ops.wtd_scene.load() bpy.ops.wtd_scene.load()
pump() pump()
check("load again adds nothing", message().startswith("Nothing new to load"), message()) check("load again adds nothing", message().startswith("Nothing new to load"), message())
@@ -129,6 +129,44 @@ bpy.ops.wtd_scene.load(asset_oid=CAR)
pump() pump()
check("car loaded once downloaded", loaded().get("car", (None,))[0] == "v001", message()) check("car loaded once downloaded", loaded().get("car", (None,))[0] == "v001", message())
print("LOD")
tree_lib, car_lib = bpy.data.libraries["tree"], bpy.data.libraries["car"]
tree, car = lib.instances(tree_lib)[0], lib.instances(car_lib)[0]
check("instance shows the default LOD", lib.current_lod(tree_lib, tree) == 0 and tree.instance_collection.name == "COL_tree_LOD0")
check("override shows the default LOD", lib.current_lod(car_lib, car) == 0)
hidden = {c.name: (c.hide_viewport, c.hide_render) for c in lib.lods(car_lib, car).values()}
check(
"other LODs hidden (viewport and render)",
hidden == {"COL_car_LOD0": (False, False), "COL_car_LOD1": (True, True), "COL_car_LOD2": (True, True)},
str(hidden),
)
evaluated = {ob.name for ob in bpy.context.evaluated_depsgraph_get().objects}
check("hidden LODs not evaluated", "GEO_COL_car_LOD0" in evaluated and not evaluated & {"GEO_COL_car_LOD1", "GEO_COL_car_LOD2"})
bpy.ops.wtd_scene.set_lod(lod=2, library="car", instance=car.name)
check("override switched to LOD 2", lib.current_lod(car_lib, car) == 2)
for ob in bpy.context.view_layer.objects:
ob.select_set(ob in (tree, bpy.data.objects["RIG_COL_car"]))
check("LOD of selected", bpy.ops.wtd_scene.set_lod(lod=1, selected=True) == {"FINISHED"})
check("both switched to LOD 1", (lib.current_lod(tree_lib, tree), lib.current_lod(car_lib, car)) == (1, 1))
check("missing LOD refused", bpy.ops.wtd_scene.set_lod(lod=5, library="tree", instance="tree") == {"CANCELLED"})
second = lib.add_instance(scene, bpy.context.view_layer, car_lib)
check(
"second override: LODs found by their linked names",
list(lib.lods(car_lib, second)) == [0, 1, 2] and lib.current_lod(car_lib, second) == 0,
str([c.name for c in second.children]),
)
lib.remove_instance(second)
lib.update("tree", {"name": "v002", "path": os.path.join(WORKDIR, "lib", "tree", "v002", "model.blend")})
tree = lib.instances(bpy.data.libraries["tree"])[0]
check("LOD kept by an update", lib.current_lod(bpy.data.libraries["tree"], tree) == 1)
check("content outside the LODs: instance refused", bpy.ops.wtd_scene.set_lod(lod=0, library="tree", instance="tree") == {"CANCELLED"})
lib.update("tree", {"name": "v003", "path": os.path.join(WORKDIR, "lib", "tree", "v003", "model.blend")})
tree = lib.instances(bpy.data.libraries["tree"])[0]
check("LODs gone: whole collection instanced", tree.instance_collection.name == "COL_tree" and not lib.lods(bpy.data.libraries["tree"], tree))
lib.update("tree", {"name": "v001", "path": os.path.join(WORKDIR, "lib", "tree", "v001", "model.blend")})
tree = lib.instances(bpy.data.libraries["tree"])[0]
check("LODs back: default LOD", lib.current_lod(bpy.data.libraries["tree"], tree) == 0)
print("Versions") print("Versions")
check("bob is the latest", sm.version_status(bpy.data.libraries["bob"])[0] == "LATEST") check("bob is the latest", sm.version_status(bpy.data.libraries["bob"])[0] == "LATEST")
fake("/_test/publish", {"file_oid": BOB_FILE, "name": "v003", "available": False}) fake("/_test/publish", {"file_oid": BOB_FILE, "name": "v003", "available": False})
@@ -217,6 +255,8 @@ bpy.ops.wm.save_as_mainfile(filepath=path)
bpy.ops.wm.open_mainfile(filepath=path) bpy.ops.wm.open_mainfile(filepath=path)
sm._on_load_post() sm._on_load_post()
check("everything found after reopening", set(loaded()) == {"bob", "car", "lamp", "rock", "tree"}) check("everything found after reopening", set(loaded()) == {"bob", "car", "lamp", "rock", "tree"})
car_lib = bpy.data.libraries["car"]
check("LOD kept after reopening", lib.current_lod(car_lib, lib.instances(car_lib)[0]) == 1)
sm.connect(bpy.context.scene) sm.connect(bpy.context.scene)
pump() pump()
check("versions checked again", sm.version_status(bpy.data.libraries["bob"])[0] == "UPDATE") check("versions checked again", sm.version_status(bpy.data.libraries["bob"])[0] == "UPDATE")
+87 -11
View File
@@ -158,17 +158,90 @@ def add_instance(scene, view_layer, library, parent=None):
raise LoadError(f"{library.name}: collection {library.get('wtd_collection')} missing from the file") raise LoadError(f"{library.name}: collection {library.get('wtd_collection')} missing from the file")
parent = parent or _type_collection(scene, library.get("wtd_asset_type", "")) parent = parent or _type_collection(scene, library.get("wtd_asset_type", ""))
if link_mode(library.get("wtd_asset_type", "")) == "OVERRIDE": if link_mode(library.get("wtd_asset_type", "")) == "OVERRIDE":
override = collection.override_hierarchy_create(scene, view_layer) instance = collection.override_hierarchy_create(scene, view_layer)
if scene.collection.children.get(override.name) == override and parent != scene.collection: if scene.collection.children.get(instance.name) == instance and parent != scene.collection:
scene.collection.children.unlink(override) scene.collection.children.unlink(instance)
if parent.children.get(override.name) != override: if parent.children.get(instance.name) != instance:
parent.children.link(override) parent.children.link(instance)
return override else:
empty = bpy.data.objects.new(library.get("wtd_asset_name", collection.name), None) instance = bpy.data.objects.new(library.get("wtd_asset_name", collection.name), None)
empty.instance_type = "COLLECTION" instance.instance_type = "COLLECTION"
empty.instance_collection = collection instance.instance_collection = collection
parent.objects.link(empty) parent.objects.link(instance)
return empty apply_default_lod(library, instance)
return instance
# --------------------------------------------------------------------------- #
# Levels of detail
# --------------------------------------------------------------------------- #
def _lod_pattern(asset_name):
before, _, after = config.SCENE_MANAGER_LOD_COLLECTION.partition("{lod}")
return re.compile(re.escape(before.format(asset=asset_name)) + r"(\d+)" + re.escape(after.format(asset=asset_name)) + "$")
def lods(library, instance):
"""{LOD number: collection}: the LOD child collections of the asset collection, linked
for a collection instance, their overrides for an override."""
root = linked_collection(library) if isinstance(instance, bpy.types.Object) else instance
if root is None:
return {}
pattern = _lod_pattern(library.get("wtd_asset_name", library.name))
found = {}
for child in root.children:
# Overrides of a second instance are renamed (COL_bob_LOD0.001): match the linked name.
name = child.override_library.reference.name if child.override_library else child.name
match = pattern.match(name)
if match:
found[int(match.group(1))] = child
return dict(sorted(found.items()))
def current_lod(library, instance):
"""LOD shown by the instance, None when it shows none or several."""
found = lods(library, instance)
if isinstance(instance, bpy.types.Object):
return next((lod for lod, c in found.items() if c == instance.instance_collection), None)
shown = [lod for lod, c in found.items() if not c.hide_viewport]
return shown[0] if len(shown) == 1 else None
def set_lod(library, instance, lod):
"""Show one LOD: an override hides the other LOD collections (viewport and render, so
they are not evaluated), a collection instance instances the LOD collection."""
found = lods(library, instance)
if lod not in found:
available = ", ".join(str(n) for n in found) or "none"
raise LoadError(f"{instance.name}: no LOD {lod} (LODs in the file: {available})")
if isinstance(instance, bpy.types.Object):
root = linked_collection(library)
others = [c.name for c in root.children if c not in found.values()]
if root.objects or others:
raise LoadError(
f"{instance.name}: {library.name} has content outside its LOD collections "
f"({', '.join([ob.name for ob in root.objects] + others)}); load it as an override to switch LODs"
)
instance.instance_collection = found[lod]
return
for number, collection in found.items():
collection.hide_viewport = collection.hide_render = number != lod
def apply_default_lod(library, instance):
"""After a load or an update: show config SCENE_MANAGER_DEFAULT_LOD (else the lowest LOD)
when the instance shows no single LOD. An instance of a LOD the file no longer has goes
back to the whole asset collection first."""
found = lods(library, instance)
root = linked_collection(library)
if isinstance(instance, bpy.types.Object) and root and instance.instance_collection not in (root, *found.values()):
instance.instance_collection = root
default = config.SCENE_MANAGER_DEFAULT_LOD
if not found or default is None or current_lod(library, instance) is not None:
return
try:
set_lod(library, instance, default if default in found else min(found))
except LoadError:
pass # a collection instance with content outside its LODs keeps the whole collection
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -190,6 +263,9 @@ def update(library_name, revision):
for key, value in tags.items(): for key, value in tags.items():
library[key] = value library[key] = value
library["wtd_revision"] = revision["name"] library["wtd_revision"] = revision["name"]
# The new revision may add, rename or drop LOD collections.
for instance in instances(library):
apply_default_lod(library, instance)
return library return library
+36 -2
View File
@@ -1,4 +1,4 @@
"""Scene Manager operators: connection, shot context, casting, load, update, remove, assetize. """Scene Manager operators: connection, shot context, casting, load, update, LOD, remove, assetize.
Libreflow is only reached through ``ipc``; every request but the ones filling a Libreflow is only reached through ``ipc``; every request but the ones filling a
dialog runs in the background and applies its result on the main thread. dialog runs in the background and applies its result on the main thread.
@@ -13,7 +13,7 @@ import time
import bpy import bpy
from bpy.app.handlers import persistent from bpy.app.handlers import persistent
from bpy.props import BoolProperty, EnumProperty, PointerProperty, StringProperty from bpy.props import BoolProperty, EnumProperty, IntProperty, PointerProperty, StringProperty
from .. import config from .. import config
from . import ipc from . import ipc
@@ -731,6 +731,39 @@ class WTD_OT_scene_select_instance(bpy.types.Operator):
return {"FINISHED"} return {"FINISHED"}
class WTD_OT_scene_set_lod(bpy.types.Operator):
bl_idname = "wtd_scene.set_lod"
bl_label = "Set LOD"
bl_description = "Show this level of detail (one of the asset's LOD collections)"
bl_options = {"REGISTER", "UNDO"}
lod: IntProperty(name="LOD", min=0)
library: StringProperty(options={"SKIP_SAVE"})
instance: StringProperty(description="Only this instance (object or collection name)", options={"SKIP_SAVE"})
selected: BoolProperty(description="The instances of the selected objects", options={"SKIP_SAVE"})
def execute(self, context):
if self.selected:
targets = lib.selected_instances(context.view_layer)
else:
library = bpy.data.libraries.get(self.library)
targets = [(library, i) for i in lib.instances(library) if i.name == self.instance] if library else []
targets = [(library, instance) for library, instance in targets if lib.lods(library, instance)]
if not targets:
self.report({"WARNING"}, "No loaded instance with LODs selected")
return {"CANCELLED"}
switched, errors = [], []
for library, instance in targets:
try:
lib.set_lod(library, instance, self.lod)
switched.append(instance.name)
except lib.LoadError as exc:
errors.append(str(exc))
text = f"LOD {self.lod}: {', '.join(switched)}" if switched else f"LOD {self.lod} not set"
self.report({"WARNING"} if errors else {"INFO"}, "; ".join([text, *errors]))
return {"FINISHED"} if switched else {"CANCELLED"}
class WTD_OT_scene_toggle_expand(bpy.types.Operator): class WTD_OT_scene_toggle_expand(bpy.types.Operator):
bl_idname = "wtd_scene.toggle_expand" bl_idname = "wtd_scene.toggle_expand"
bl_label = "Show Instances" bl_label = "Show Instances"
@@ -948,6 +981,7 @@ classes = (
WTD_OT_scene_pick_revision, WTD_OT_scene_pick_revision,
WTD_OT_scene_remove, WTD_OT_scene_remove,
WTD_OT_scene_select_instance, WTD_OT_scene_select_instance,
WTD_OT_scene_set_lod,
WTD_OT_scene_toggle_expand, WTD_OT_scene_toggle_expand,
WTD_OT_scene_assetize, WTD_OT_scene_assetize,
) )
+1 -1
View File
@@ -1,4 +1,4 @@
"""Shot viewer: switch, add and delete the shots of the scene from the sidebar. """Shot manager: switch, add and delete the shots of the scene from the sidebar.
Shots are the timeline markers bound to a camera (Ctrl+B in the timeline). A Shots are the timeline markers bound to a camera (Ctrl+B in the timeline). A
shot runs from its marker to the frame before the next camera marker, the last shot runs from its marker to the frame before the next camera marker, the last
+23 -1
View File
@@ -278,7 +278,8 @@ class WTD_UL_shots(bpy.types.UIList):
class _ShotViewerPanel: class _ShotViewerPanel:
bl_region_type = "UI" bl_region_type = "UI"
bl_category = "WTD" bl_category = "WTD"
bl_label = "Shot Viewer" bl_label = "Shot Manager"
bl_options = {"DEFAULT_CLOSED"} # panel starts collapsed
def draw(self, context) -> None: def draw(self, context) -> None:
layout = self.layout layout = self.layout
@@ -482,11 +483,32 @@ class _SceneLoadedPanel:
op = sub.operator(sm.WTD_OT_scene_select_instance.bl_idname, text="", icon="RESTRICT_SELECT_OFF") op = sub.operator(sm.WTD_OT_scene_select_instance.bl_idname, text="", icon="RESTRICT_SELECT_OFF")
op.library, op.instance = library.name, instance.name op.library, op.instance = library.name, instance.name
sub.operator(sm.WTD_OT_scene_remove.bl_idname, text="", icon="X").instance = instance.name sub.operator(sm.WTD_OT_scene_remove.bl_idname, text="", icon="X").instance = instance.name
lods = sm_library.lods(library, instance)
if lods:
current = sm_library.current_lod(library, instance)
sub = col.row(align=True)
sub.label(text="LOD", icon="BLANK1")
for number in lods:
op = sub.operator(sm.WTD_OT_scene_set_lod.bl_idname, text=str(number), depress=number == current)
op.lod, op.library, op.instance = number, library.name, instance.name
layout.separator() layout.separator()
row = layout.row(align=True) row = layout.row(align=True)
row.operator(sm.WTD_OT_scene_update.bl_idname, text="Update Selected").selected = True row.operator(sm.WTD_OT_scene_update.bl_idname, text="Update Selected").selected = True
row.operator(sm.WTD_OT_scene_remove.bl_idname, text="Remove Selected").selected = True row.operator(sm.WTD_OT_scene_remove.bl_idname, text="Remove Selected").selected = True
selected = [
(library, instance)
for library, instance in sm_library.selected_instances(context.view_layer)
if sm_library.lods(library, instance)
]
if selected:
split = layout.split(factor=0.4, align=True)
split.label(text="Selected LOD")
row = split.row(align=True)
shown = {sm_library.current_lod(library, instance) for library, instance in selected}
for number in sorted({n for library, instance in selected for n in sm_library.lods(library, instance)}):
op = row.operator(sm.WTD_OT_scene_set_lod.bl_idname, text=str(number), depress=shown == {number})
op.lod, op.selected = number, True
class _SceneAssetizePanel: class _SceneAssetizePanel: