Panel update + fix for remove function so it doesn't remove all instances

This commit is contained in:
Nero Frame 13 committed 2026-10-28 11:03:16 +01:00
1 parent fbc7ac18e4
commit 3f87904cd1
5 files changed
+182 -159

No files matched your search

+3
View File
@@ -7,3 +7,6 @@ private
.idea
# ignore mkdocs build
site/
# ignore dev utilities
dev/
-115
View File
@@ -1,115 +0,0 @@
# Melvile Tools: notes for Claude
Blender add-on (Blender 5.2) for the Melvile pipeline at Walking The Dog: Sanity Check,
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.
## Layout and conventions
- `config.py`: every production value (prefixes, formats, patterns, modes). Values are
read at run time (`config.X`, never copied at import) because a studio JSON file can
override them (add-on preferences "Config Override", or `WTD_SANITY_CONFIG`).
New tunable values go here, with a comment, and in the README's Configuration table.
- `ui/panel.py`: all panels. Operators live with their tool (`rename.py`,
`shot_viewer.py`, `scene_manager/`, `ui/operators.py` for the Sanity Check).
- `checks/`: sanity checks, auto-registered (see README > For developers).
- `__init__.py` deletes cached submodules on reload, so Reload Scripts picks up every file.
- Versions: `blender_manifest.toml` and `bl_info` in `__init__.py` (keep both in sync).
- Style: match the surrounding code; short docstrings, few comments, no heavy abstractions.
## Working with the user
- Commits: when asked, create a branch from `master`, commit there (end with the
Co-Authored-By line), and give the user the merge / push / branch-delete commands:
they run them themselves. Only commit straight on `master` when told to.
- Real Blender API facts beat memory: probe in headless Blender before relying on an API.
## Testing
Blender binary: macOS `/Applications/Blender.app/Contents/MacOS/Blender`, Windows
`blender.exe` from the install folder. Import the add-on in tests by putting the folder
that contains this repo on `sys.path` and importing the repo folder name (see
`dev/test_scene_manager.py`).
- Headless: `blender -b --factory-startup --python-exit-code 1 --python <script> -- <args>`.
Timers do not run in background mode: pump background jobs by hand
(`scene_manager.ipc._poll()`).
- UI: run Blender with a window (`--factory-startup <file.blend> --python <script>`, a
.blend argument avoids the splash), drive it with `bpy.app.timers`, take screenshots with
`bpy.ops.screen.screenshot_area`, quit with `bpy.ops.wm.quit_blender()`. A sidebar tab
(`region.active_panel_category`) can only be set once the region has been drawn.
- Scene Manager without Libreflow (`dev/`):
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)
3. `blender -b --factory-startup --python-exit-code 1 --python dev/test_scene_manager.py -- <workdir>`
(71 checks, "All checks passed"). `/_test/publish` and `/_test/download` simulate a new
publish and a requested revision reaching this site.
The fake server follows the real `wtdcmds` replies; check them against the real
Libreflow when one is available.
## External pieces
- Libreflow `wtdcmds` extension (commands used by the Scene Manager), on the share:
`WG/_Dev/GIT/ndemaerschalk/_MELVILE/libreflow_dev_utils/dev/src/wtdcmds/`
(`src/libreflow/extensions/wtdcmds/wtd_cmds.py`, `BLENDER_ADDON_BRIEF.md`).
Server address/token: `~/.kabaret/ipc/server.json` (config `LIBREFLOW_SERVER_FILE`).
- Add Camera Rigs extension: `bpy.ops.object.build_camera_rig(mode=...)` builds the
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`).
## Design notes (found by testing)
- 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.camera` explicitly.
- Scene Manager: tags live on `bpy.types.Library` only. Library overrides lose custom
properties when they resync, so instances are traced back to their library (empty →
`instance_collection.library`, override → `override_library.reference.library`).
- `library.reload()` invalidates every Python reference to linked and override data:
look IDs up again afterwards. In Blender 5.2 it also replaces the Library itself (renamed
after its file, custom properties gone): `library.update()` finds it by path and restores
the name and tags.
- 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
is no longer used, never a managed library.
- LODs are child collections of the asset collection (`COL_{asset}_LOD{lod}`, any name,
ordered by `SCENE_MANAGER_LODS`, then numbers, then names; the first is the default). 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;
in callbacks, find scenes by name (`bpy.context.scene` is not reliable in timers).
## Real Libreflow (first run, 2026-10-27)
- Kitsu asset types: `main_character`, `secondary_character`, `tertiary_character`,
`main_props`, `secondary_props`, `set_props`, `vehicles`, `bg_location`, `bg_set`,
`skies`, `setdress_bank`. The fake server uses the same names.
- Paths come back as Windows paths with backslashes; `comment` can be `null`.
- Set dressing publishes (`setdress_low`) hold a whole layout scene: the set is the
`environments` collection, next to `lights`, `cameras`, `characters`, `_unused`.
- Tested live: connection, context, casting, load (instance and override), version switch
and update, remove, assetize errors, one request (`car_paul` modeling.blend v001 →
`Requested`). Not tested: an actual assetize publish (it writes to production).
New wtdcmds commands need every Libreflow session restarted (a running one keeps the
old code and answers 404).
- Load Publish (2026-10-28) uses only existing commands (`wtd_list_assets`,
`wtd_get_asset_files`, `wtd_get_revisions`): 309 assets in ~0.1 s, live-tested on
`car_paul` (loaded twice: `car_paul_01`, `car_paul_02`).
- `/melvile/films/melvile` has no sequences or shots in Libreflow yet; `melvile_test` has 9.
- Deployed Libreflow (Overseer launcher): wtdcmds must be an "extension" entry, so the
launcher adds `libreflow.extensions.wtdcmds:install_extensions` to
`KABARET_FLOW_EXT_INSTALLERS`; the package name must stay `libreflow.extensions.wtdcmds`.
The launcher only rebuilds its env when the project config changes: "Force update".
## Open points
- Override vs instance per asset type (`SCENE_MANAGER_LINK_MODES`), and the file patterns
of sets (`setdress_low` seen on one asset only): confirm with production.
- The server picks the first file matching a pattern even when it is not on this site,
while another matching file is (e.g. `car_paul`: `modeling/modeling.blend` v001 not
available, `modeling/tes_modeling.blend` v002 available).
- Values marked TODO in `config.py` wait for production specs.
+2 -2
View File
@@ -369,7 +369,7 @@ All of these can be changed in the [Configuration](#configuration).
Lists everything the Scene Manager loaded, one line per asset, with its version:
- **Load Publish...**: load any published file of the project, cast in the shot or not (see [Load any publish](#load-any-publish)).
- **Load...**: load any published file of the project, cast in the shot or not (see [Load any publish](#load-any-publish)).
- 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. Under an instance with levels of detail, one button per LOD (`LOD high mid low`), the shown one pressed.
@@ -384,7 +384,7 @@ Updating swaps the linked file and reloads it. Overrides keep their posing and a
### Load any publish
**Load Publish...** (top of the *Loaded* sub-panel) searches every asset of the project: type a few letters of its name, pick it. A dialog then shows:
**Load...** (top of the *Loaded* sub-panel) searches every asset of the project: type a few letters of its name, pick it. A dialog then shows:
| Setting | Use |
|---|---|
+6 -1
View File
@@ -67,7 +67,12 @@ def instances(library):
def instance_objects(instance):
return [instance] if isinstance(instance, bpy.types.Object) else list(instance.all_objects)
"""The objects of one instance: the empty, or the override's own objects. Linked objects an
override keeps (bone widgets in a collection with nothing to override...) are shared by
every instance of the asset: they are left out, so selecting them picks no instance."""
if isinstance(instance, bpy.types.Object):
return [instance]
return [ob for ob in instance.all_objects if ob.library is None]
def selected_instances(view_layer):
+171 -41
View File
@@ -3,11 +3,11 @@ import textwrap
import bpy
from .. import config, rename, shot_viewer, tools_ops
from ..core import CheckContext, Severity, checks_for_context, contexts, runner
from ..core.runner import Status
from ..scene_manager import ipc
from ..scene_manager import library as sm_library
from ..scene_manager import operators as sm
from ..core import CheckContext, Severity, checks_for_context, contexts, runner
from ..core.runner import Status
from .operators import EXPANDED
STATUS_ICONS = {
@@ -244,7 +244,18 @@ class _RenamePanel:
class WTD_UL_shots(bpy.types.UIList):
"""Camera markers of the scene, in time order."""
def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0):
def draw_item(
self,
context,
layout,
data,
item,
icon,
active_data,
active_property,
index=0,
flt_flag=0,
):
start, end = shot_viewer.shot_range(item)
split = layout.split(factor=0.4)
split.label(text=item.name, icon="MARKER_HLT")
@@ -256,7 +267,10 @@ class WTD_UL_shots(bpy.types.UIList):
markers = getattr(data, propname)
ordered = [marker.as_pointer() for marker in shot_viewer.shots(data)]
position = {pointer: index for index, pointer in enumerate(ordered)}
flags = [self.bitflag_filter_item if marker.as_pointer() in position else 0 for marker in markers]
flags = [
self.bitflag_filter_item if marker.as_pointer() in position else 0
for marker in markers
]
if self.filter_name:
by_name = bpy.types.UI_UL_list.filter_items_by_name(
self.filter_name, self.bitflag_filter_item, markers, "name"
@@ -294,13 +308,33 @@ class _ShotViewerPanel:
row = layout.row(align=True)
row.scale_y = 1.4
row.enabled = has_shots
row.operator(shot_viewer.WTD_OT_shot_viewer_step.bl_idname, text="Previous", icon="TRIA_LEFT").step = -1
row.operator(shot_viewer.WTD_OT_shot_viewer_step.bl_idname, text="Next", icon="TRIA_RIGHT").step = 1
row.operator(
shot_viewer.WTD_OT_shot_viewer_step.bl_idname,
text="Previous",
icon="TRIA_LEFT",
).step = -1
row.operator(
shot_viewer.WTD_OT_shot_viewer_step.bl_idname,
text="Next",
icon="TRIA_RIGHT",
).step = 1
row = layout.row()
row.template_list("WTD_UL_shots", "", scene, "timeline_markers", settings, "shot_index", rows=5)
row.template_list(
"WTD_UL_shots",
"",
scene,
"timeline_markers",
settings,
"shot_index",
rows=5,
)
col = row.column(align=True)
col.operator(shot_viewer.WTD_OT_shot_viewer_new_shot.bl_idname, text="", icon="ADD")
col.operator(shot_viewer.WTD_OT_shot_viewer_delete_shot.bl_idname, text="", icon="REMOVE")
col.operator(
shot_viewer.WTD_OT_shot_viewer_new_shot.bl_idname, text="", icon="ADD"
)
col.operator(
shot_viewer.WTD_OT_shot_viewer_delete_shot.bl_idname, text="", icon="REMOVE"
)
if not has_shots:
layout.label(text="No camera marker yet: add a shot with +", icon="INFO")
layout.prop(settings, "loop_shot")
@@ -355,8 +389,14 @@ class _SceneManagerPanel:
layout.prop(scene.wtd_scene_manager, "context_oid", text="", icon="SEQUENCE")
row = layout.row(align=True)
row.operator(sm.WTD_OT_scene_use_current_oid.bl_idname, text="Libreflow Oid", icon="IMPORT")
row.operator(sm.WTD_OT_scene_pick_shot.bl_idname, text="Pick Shot", icon="VIEWZOOM")
row.operator(
sm.WTD_OT_scene_use_current_oid.bl_idname,
text="Libreflow Oid",
icon="IMPORT",
)
row.operator(
sm.WTD_OT_scene_pick_shot.bl_idname, text="Pick Shot", icon="VIEWZOOM"
)
text, valid = sm.context_label(sm.context_oid(scene))
if text:
row = layout.row()
@@ -386,11 +426,17 @@ class _SceneCastingPanel:
row.scale_y = 1.3
row.operator(sm.WTD_OT_scene_load.bl_idname, text="Load Missing", icon="IMPORT")
if sm.missing_files(context.scene):
row.operator(sm.WTD_OT_scene_request.bl_idname, text="Request Missing", icon="URL")
row.operator(
sm.WTD_OT_scene_request.bl_idname, text="Request Missing", icon="URL"
)
row.operator(sm.WTD_OT_scene_refresh.bl_idname, text="", icon="FILE_REFRESH")
casting = sm.STATE.casting.get(oid)
if casting is None:
text = "Not read yet: refresh" if sm.is_shot_context(oid) else "Set a shot as context"
text = (
"Not read yet: refresh"
if sm.is_shot_context(oid)
else "Set a shot as context"
)
layout.label(text=text, icon="INFO")
return
if not casting["assets"]:
@@ -399,36 +445,64 @@ class _SceneCastingPanel:
found = sm_library.all_instances()
col = layout.column(align=True)
for asset in casting["assets"]:
library = sm_library.find_library(asset["file"]["file_oid"]) if asset.get("file") else None
library = (
sm_library.find_library(asset["file"]["file_oid"])
if asset.get("file")
else None
)
have = len(found.get(library.name, [])) if library else 0
want = asset.get("nb_occurences") or 1
error = asset.get("error")
file = sm.file_data(asset["file"]["file_oid"], asset["file"]) if asset.get("file") else None
file = (
sm.file_data(asset["file"]["file_oid"], asset["file"])
if asset.get("file")
else None
)
latest = (file or {}).get("latest") or {}
requested = bool(error) and latest.get("sync_status") == "Requested"
if requested:
error = f"{latest['name']} requested, waiting for the download"
icon = "SORTTIME" if requested else "ERROR" if error else "CHECKMARK" if have >= want else "IMPORT"
icon = (
"SORTTIME"
if requested
else "ERROR"
if error
else "CHECKMARK"
if have >= want
else "IMPORT"
)
row = col.row(align=True)
row.alert = bool(error) and not requested
row.label(text=f"{asset['name']} ({asset.get('asset_type', '')})", icon=icon)
row.label(
text=f"{asset['name']} ({asset.get('asset_type', '')})", icon=icon
)
row.label(text=f"{have}/{want}")
if not error and have < want:
row.operator(sm.WTD_OT_scene_load.bl_idname, text="", icon="IMPORT").asset_oid = asset["oid"]
row.operator(
sm.WTD_OT_scene_load.bl_idname, text="", icon="IMPORT"
).asset_oid = asset["oid"]
if sm.to_request(file):
row.operator(sm.WTD_OT_scene_request.bl_idname, text="", icon="URL").file_oid = file["file_oid"]
row.operator(
sm.WTD_OT_scene_request.bl_idname, text="", icon="URL"
).file_oid = file["file_oid"]
if error:
sub = col.row()
sub.alert = not requested
_wrapped_label(sub, context, error, icon="BLANK1")
cast = {asset["oid"] for asset in casting["assets"]}
extra = [library for library in sm_library.managed_libraries() if library["wtd_asset_oid"] not in cast]
extra = [
library
for library in sm_library.managed_libraries()
if library["wtd_asset_oid"] not in cast
]
if extra:
box = layout.box()
box.label(text="Loaded, no longer cast:", icon="ERROR")
for library in extra:
box.label(text=library.get("wtd_asset_name", library.name), icon="BLANK1")
box.label(
text=library.get("wtd_asset_name", library.name), icon="BLANK1"
)
class _SceneLoadedPanel:
@@ -439,13 +513,17 @@ class _SceneLoadedPanel:
def draw(self, context) -> None:
layout = self.layout
layout.operator(sm.WTD_OT_scene_browse.bl_idname, text="Load Publish...", icon="VIEWZOOM")
layout.operator(
sm.WTD_OT_scene_browse.bl_idname, text="Load...", icon="VIEWZOOM"
)
libraries = sm_library.managed_libraries()
if not libraries:
layout.label(text="Nothing loaded by the Scene Manager", icon="INFO")
return
row = layout.row(align=True)
row.operator(sm.WTD_OT_scene_update.bl_idname, text="Update All", icon="FILE_REFRESH")
row.operator(
sm.WTD_OT_scene_update.bl_idname, text="Update All", icon="FILE_REFRESH"
)
row.operator(sm.WTD_OT_scene_refresh.bl_idname, text="", icon="VIEWZOOM")
found = sm_library.all_instances()
@@ -460,16 +538,29 @@ class _SceneLoadedPanel:
emboss=False,
icon="DOWNARROW_HLT" if expanded else "RIGHTARROW",
).library = library.name
row.label(text=library.get("wtd_asset_name", library.name), icon="LIBRARY_DATA_DIRECT")
row.label(
text=library.get("wtd_asset_name", library.name),
icon="LIBRARY_DATA_DIRECT",
)
version = row.row(align=True)
version.alert = outdated
version.label(text=library.get("wtd_revision", "?"))
if state == "UPDATE":
row.operator(sm.WTD_OT_scene_update.bl_idname, text="", icon="FILE_REFRESH").library = library.name
elif state == "PENDING" and sm.to_request(sm.file_data(library["wtd_file_oid"])):
row.operator(sm.WTD_OT_scene_request.bl_idname, text="", icon="URL").file_oid = library["wtd_file_oid"]
row.operator(sm.WTD_OT_scene_pick_revision.bl_idname, text="", icon="COLLAPSEMENU").library = library.name
row.operator(sm.WTD_OT_scene_remove.bl_idname, text="", icon="X").library = library.name
row.operator(
sm.WTD_OT_scene_update.bl_idname, text="", icon="FILE_REFRESH"
).library = library.name
elif state == "PENDING" and sm.to_request(
sm.file_data(library["wtd_file_oid"])
):
row.operator(
sm.WTD_OT_scene_request.bl_idname, text="", icon="URL"
).file_oid = library["wtd_file_oid"]
row.operator(
sm.WTD_OT_scene_pick_revision.bl_idname, text="", icon="COLLAPSEMENU"
).library = library.name
row.operator(
sm.WTD_OT_scene_remove.bl_idname, text="", icon="X"
).library = library.name
if outdated:
sub = layout.row()
sub.alert = True
@@ -480,23 +571,44 @@ class _SceneLoadedPanel:
for instance in found.get(library.name, []):
is_object = isinstance(instance, bpy.types.Object)
sub = col.row(align=True)
sub.label(text=instance.name, icon="OUTLINER_OB_EMPTY" if is_object else "OUTLINER_COLLECTION")
op = sub.operator(sm.WTD_OT_scene_select_instance.bl_idname, text="", icon="RESTRICT_SELECT_OFF")
sub.label(
text=instance.name,
icon="OUTLINER_OB_EMPTY" if is_object else "OUTLINER_COLLECTION",
)
op = sub.operator(
sm.WTD_OT_scene_select_instance.bl_idname,
text="",
icon="RESTRICT_SELECT_OFF",
)
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 name in lods:
op = sub.operator(sm.WTD_OT_scene_set_lod.bl_idname, text=name, depress=name == current)
op.lod, op.library, op.instance = name, library.name, instance.name
op = sub.operator(
sm.WTD_OT_scene_set_lod.bl_idname,
text=name,
depress=name == current,
)
op.lod, op.library, op.instance = (
name,
library.name,
instance.name,
)
layout.separator()
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_remove.bl_idname, text="Remove 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
selected = [
(library, instance)
for library, instance in sm_library.selected_instances(context.view_layer)
@@ -506,10 +618,21 @@ class _SceneLoadedPanel:
col = layout.column(align=True)
col.label(text="LOD of Selected")
row = col.row(align=True)
shown = {sm_library.current_lod(library, instance) for library, instance in selected}
names = {name for library, instance in selected for name in sm_library.lods(library, instance)}
shown = {
sm_library.current_lod(library, instance)
for library, instance in selected
}
names = {
name
for library, instance in selected
for name in sm_library.lods(library, instance)
}
for name in sorted(names, key=sm_library.lod_order):
op = row.operator(sm.WTD_OT_scene_set_lod.bl_idname, text=name, depress=shown == {name})
op = row.operator(
sm.WTD_OT_scene_set_lod.bl_idname,
text=name,
depress=shown == {name},
)
op.lod, op.selected = name, True
@@ -523,9 +646,16 @@ class _SceneAssetizePanel:
def draw(self, context) -> None:
layout = self.layout
_wrapped_label(
layout, context, "Publish a local collection of this scene to Libreflow as an asset.", icon="INFO"
layout,
context,
"Publish a local collection of this scene to Libreflow as an asset.",
icon="INFO",
)
layout.operator(
sm.WTD_OT_scene_assetize.bl_idname,
text="Assetize Collection...",
icon="EXPORT",
)
layout.operator(sm.WTD_OT_scene_assetize.bl_idname, text="Assetize Collection...", icon="EXPORT")
class WTD_PT_sanity_view3d(_SanityPanel, bpy.types.Panel):