Files
wtd-libreflow-addon/ui/panel.py
T
NDemaerschalkandClaude Opus 5.5 bac7a6f5da 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>
2026-10-27 16:42:24 +01:00

602 lines
23 KiB
Python

import textwrap
import bpy
from .. import config, rename, shot_viewer, tools_ops
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 = {
Status.NOT_RUN: "DOT",
Status.PASSED: "CHECKMARK",
Status.INFO: "INFO",
Status.WARNING: "ERROR",
Status.ERROR: "CANCEL",
Status.SKIPPED: "REMOVE",
Status.CRASHED: "GHOST_ENABLED",
}
SEVERITY_ICONS = {
Severity.INFO: "INFO",
Severity.WARNING: "ERROR",
Severity.ERROR: "CANCEL",
}
def _wrapped_label(layout, context, text, icon="NONE", margin=0):
"""Label wrapped to the region width. ``margin`` is the room (in characters) taken by buttons."""
char_width = 6.5 * context.preferences.system.ui_scale
width = max(16, int(context.region.width / char_width) - 6 - margin)
col = layout.column(align=True)
for index, line in enumerate(textwrap.wrap(text, width) or [""]):
col.label(text=line, icon=icon if index == 0 else "BLANK1")
def draw_sanity_checks_panel(self, context):
layout = self.layout
scene = context.scene
settings = scene.wtd_sanity
report = runner.LAST_REPORT
if report is not None and report.context_id != settings.context:
report = None
layout.prop(settings, "context", text="")
if settings.context in contexts.ASSET:
row = layout.row(align=True)
row.prop(settings, "asset_name", text="Asset")
if not settings.asset_name:
guessed = CheckContext(scene, settings.context).asset_name
row.label(text=guessed or "(from file name)")
row = layout.row(align=True)
row.scale_y = 1.4
row.operator("wtd_sanity.run_checks", text="Run Checks", icon="PLAY")
row.operator("wtd_sanity.fix_all", text="Fix All", icon="TOOL_SETTINGS")
row.operator("wtd_sanity.write_report", text="", icon="TEXT")
if settings.context == contexts.CHAR_MOD:
layout.operator("wtd_sanity.export_char_reference", icon="EXPORT")
if report is not None:
counts = report.counts()
row = layout.row()
for status in (
Status.PASSED,
Status.WARNING,
Status.ERROR,
Status.SKIPPED,
Status.CRASHED,
):
if counts[status]:
row.label(text=str(counts[status]), icon=STATUS_ICONS[status])
disabled = runner.disabled_check_ids(scene)
box = None
category = None
for check in checks_for_context(settings.context):
if check.category != category:
category = check.category
box = layout.box()
box.label(text=category)
_draw_check(box, context, check, report, check.id in disabled)
def _draw_check(layout, context, check, report, disabled):
result = report.results.get(check.id) if report else None
status = result.status if result else Status.NOT_RUN
expanded = check.id in EXPANDED
row = layout.row(align=True)
row.operator(
"wtd_sanity.toggle_check",
text="",
emboss=False,
icon="CHECKBOX_DEHLT" if disabled else "CHECKBOX_HLT",
).check_id = check.id
row.operator(
"wtd_sanity.toggle_expand",
text="",
emboss=False,
icon="DOWNARROW_HLT" if expanded else "RIGHTARROW",
).check_id = check.id
label_row = row.row(align=True)
label_row.enabled = not disabled
label_row.alert = status in {Status.ERROR, Status.CRASHED}
count = f" ({len(result.issues)})" if result and result.issues else ""
label_row.label(text=check.label + count, icon=STATUS_ICONS[status])
buttons = row.row(align=True)
if result and any(issue.id_name or issue.elements for issue in result.issues):
buttons.operator(
"wtd_sanity.select_issues", text="", icon="RESTRICT_SELECT_OFF"
).check_id = check.id
if result and result.failed and check.can_fix():
buttons.operator(
"wtd_sanity.fix_check", text="", icon="TOOL_SETTINGS"
).check_id = check.id
buttons.operator(
"wtd_sanity.run_checks", text="", icon="FILE_REFRESH"
).check_id = check.id
if not expanded:
return
col = layout.column()
col.separator(factor=0.3)
_wrapped_label(col, context, check.description, icon="QUESTION")
if result is None:
return
if result.message:
_wrapped_label(
col,
context,
result.message.strip().splitlines()[-1],
icon=STATUS_ICONS[status],
)
for index, issue in enumerate(result.issues[: config.MAX_ISSUES_DRAWN]):
severity = issue.severity or check.severity
issue_row = col.row(align=True)
selectable = bool(issue.id_name or issue.elements)
_wrapped_label(
issue_row,
context,
issue.message,
icon=SEVERITY_ICONS[severity],
margin=7 if selectable else 0,
)
if selectable:
op = issue_row.operator(
"wtd_sanity.select_issues",
text="",
icon="RESTRICT_SELECT_OFF",
emboss=False,
)
op.check_id = check.id
op.issue_index = index
if check.editable_names:
id_block = issue.get_id()
if id_block is not None and id_block.library is None:
col.prop(id_block, "name", text="", icon="BLANK1")
hidden = len(result.issues) - config.MAX_ISSUES_DRAWN
if hidden > 0:
col.label(text=f"... {hidden} more (see Write Report)", icon="BLANK1")
col.separator(factor=0.5)
class _SanityPanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Sanity Check"
bl_options = {"DEFAULT_CLOSED"} # panel starts collapsed
def draw(self, context):
draw_sanity_checks_panel(self, context)
class _ToolsPanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Tools"
def draw(self, context) -> None:
layout = self.layout
layout.operator(
tools_ops.WTDGetOidForPath.bl_idname,
text=tools_ops.WTDGetOidForPath.bl_label,
)
# Adds a separator line
layout.separator()
class _RenamePanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Rename"
bl_options = {"DEFAULT_CLOSED"} # panel starts collapsed
PREVIEW_COUNT = 10
def draw(self, context) -> None:
layout = self.layout
settings = context.scene.wtd_rename
layout.row().prop(settings, "target", expand=True)
col = layout.column()
col.use_property_split = True
col.use_property_decorate = False
col.prop(settings, "base_name")
col.prop(settings, "prefix_mode")
if settings.prefix_mode == "CUSTOM":
col.prop(settings, "prefix_custom", text=" ")
col.prop(settings, "side")
col.prop(settings, "suffix_mode")
if settings.suffix_mode == "CUSTOM":
col.prop(settings, "suffix_custom", text=" ")
col.prop(settings, "sort")
if settings.target == "OBJECTS":
col.prop(settings, "rename_data")
plan, skipped = rename.plan_renames(context, settings)
row = layout.row()
row.scale_y = 1.4
row.operator(
rename.WTD_OT_batch_rename.bl_idname,
text=f"Rename {len(plan)} Item(s)" if plan else "Rename",
icon="SORTALPHA",
)
if not plan and not skipped:
return
box = layout.box()
col = box.column(align=True)
for id_block, name in plan[: self.PREVIEW_COUNT]:
col.label(text=f"{id_block.name} → {name}")
hidden = len(plan) - self.PREVIEW_COUNT
if hidden > 0:
col.label(text=f"... {hidden} more")
for name in skipped:
col.label(text=f"{name}: no base name, set a name", icon="ERROR")
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):
start, end = shot_viewer.shot_range(item)
split = layout.split(factor=0.4)
split.label(text=item.name, icon="MARKER_HLT")
split = split.split(factor=0.6)
split.label(text=item.camera.name)
split.label(text=f"{start}-{end}")
def filter_items(self, context, data, propname):
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]
if self.filter_name:
by_name = bpy.types.UI_UL_list.filter_items_by_name(
self.filter_name, self.bitflag_filter_item, markers, "name"
)
flags = [flag & name_flag for flag, name_flag in zip(flags, by_name)]
# Shots in time order, the markers without camera after them.
order = []
hidden = len(ordered)
for marker in markers:
pointer = marker.as_pointer()
if pointer in position:
order.append(position[pointer])
else:
order.append(hidden)
hidden += 1
return flags, order
class _ShotViewerPanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Shot Manager"
bl_options = {"DEFAULT_CLOSED"} # panel starts collapsed
def draw(self, context) -> None:
layout = self.layout
settings = context.window_manager.wtd_shot_viewer
scene = context.scene
has_shots = bool(shot_viewer.shots(scene))
row = layout.row(align=True)
row.prop(scene, "wtd_sequence")
row.label(text=shot_viewer.sequence_name(scene))
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 = layout.row()
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")
if not has_shots:
layout.label(text="No camera marker yet: add a shot with +", icon="INFO")
layout.prop(settings, "loop_shot")
groups = shot_viewer.rig_groups(context.view_layer)
if not groups:
layout.label(text="No rig in the scene", icon="INFO")
for label, rigs in groups.items():
box = layout.box()
box.label(text=label, icon="ARMATURE_DATA")
col = box.column(align=True)
for rig in rigs:
col.operator(
shot_viewer.WTD_OT_shot_viewer_select_rig.bl_idname,
text=rig.name,
depress=rig.select_get(),
).name = rig.name
MESSAGE_ICONS = {"INFO": "INFO", "WARNING": "ERROR", "ERROR": "CANCEL"}
class _SceneManagerPanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Scene Manager"
def draw(self, context) -> None:
layout = self.layout
state = sm.STATE
scene = context.scene
row = layout.row(align=True)
if sm.connected():
session = sm.current_session()
text = f"{session.get('session_name', '?')} ({session.get('cluster_name', '')})"
if state.ping.get("user"):
text += f" {state.ping['user']} @ {state.ping.get('site', '')}"
row.label(text=text, icon="LINKED")
else:
row.label(text=state.connection_error, icon="UNLINKED")
row.operator(sm.WTD_OT_scene_connect.bl_idname, text="", icon="FILE_REFRESH")
if len(state.sessions) > 1:
col = layout.column(align=True)
chosen = context.window_manager.wtd_libreflow.session_uid
for uid, info in state.sessions.items():
col.operator(
sm.WTD_OT_scene_select_session.bl_idname,
text=f"{info.get('session_name', uid)}: {info.get('current_oid') or ''}",
depress=uid == chosen,
).uid = uid
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")
text, valid = sm.context_label(sm.context_oid(scene))
if text:
row = layout.row()
row.alert = not valid
_wrapped_label(row, context, text, icon="CHECKMARK" if valid else "ERROR")
busy = ipc.busy_label()
if busy:
layout.label(text=f"{busy}...", icon="SORTTIME")
message, level = state.message
if message:
row = layout.row()
row.alert = level == "ERROR"
_wrapped_label(row, context, message, icon=MESSAGE_ICONS[level])
class _SceneCastingPanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Casting"
bl_parent_id = "WTD_PT_scene_manager_view3d"
def draw(self, context) -> None:
layout = self.layout
oid = sm.context_oid(context.scene)
row = layout.row(align=True)
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_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"
layout.label(text=text, icon="INFO")
return
if not casting["assets"]:
layout.label(text="Nothing cast in this shot", icon="INFO")
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
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
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"
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"{have}/{want}")
if not error and have < want:
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"]
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]
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")
class _SceneLoadedPanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Loaded"
bl_parent_id = "WTD_PT_scene_manager_view3d"
def draw(self, context) -> None:
layout = self.layout
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_refresh.bl_idname, text="", icon="VIEWZOOM")
found = sm_library.all_instances()
for library in libraries:
state, text = sm.version_status(library)
outdated = state in {"UPDATE", "PENDING"}
expanded = library.name in sm.EXPANDED
row = layout.row(align=True)
row.operator(
sm.WTD_OT_scene_toggle_expand.bl_idname,
text="",
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")
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
if outdated:
sub = layout.row()
sub.alert = True
_wrapped_label(sub, context, text, icon="BLANK1")
if not expanded:
continue
col = layout.column(align=True)
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")
op.library, op.instance = library.name, 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()
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
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:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Assetize"
bl_parent_id = "WTD_PT_scene_manager_view3d"
bl_options = {"DEFAULT_CLOSED"}
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.operator(sm.WTD_OT_scene_assetize.bl_idname, text="Assetize Collection...", icon="EXPORT")
class WTD_PT_sanity_view3d(_SanityPanel, bpy.types.Panel):
bl_idname = "WTD_PT_sanity_view3d"
bl_space_type = "VIEW_3D"
class WTD_PT_tools_view3d(_ToolsPanel, bpy.types.Panel):
bl_idname = "WTD_PT_tools_view3d"
bl_space_type = "VIEW_3D"
class WTD_PT_rename_view3d(_RenamePanel, bpy.types.Panel):
bl_idname = "WTD_PT_rename_view3d"
bl_space_type = "VIEW_3D"
class WTD_PT_rename_node_editor(_RenamePanel, bpy.types.Panel):
bl_idname = "WTD_PT_rename_node_editor"
bl_space_type = "NODE_EDITOR"
class WTD_PT_shot_viewer_view3d(_ShotViewerPanel, bpy.types.Panel):
bl_idname = "WTD_PT_shot_viewer_view3d"
bl_space_type = "VIEW_3D"
class WTD_PT_scene_manager_view3d(_SceneManagerPanel, bpy.types.Panel):
bl_idname = "WTD_PT_scene_manager_view3d"
bl_space_type = "VIEW_3D"
class WTD_PT_scene_casting_view3d(_SceneCastingPanel, bpy.types.Panel):
bl_idname = "WTD_PT_scene_casting_view3d"
bl_space_type = "VIEW_3D"
class WTD_PT_scene_loaded_view3d(_SceneLoadedPanel, bpy.types.Panel):
bl_idname = "WTD_PT_scene_loaded_view3d"
bl_space_type = "VIEW_3D"
class WTD_PT_scene_assetize_view3d(_SceneAssetizePanel, bpy.types.Panel):
bl_idname = "WTD_PT_scene_assetize_view3d"
bl_space_type = "VIEW_3D"
class WTD_PT_sanity_sequencer(_SanityPanel, bpy.types.Panel):
bl_idname = "WTD_PT_sanity_sequencer"
bl_space_type = "SEQUENCE_EDITOR"
classes = (
WTD_UL_shots,
WTD_PT_sanity_view3d,
WTD_PT_tools_view3d,
WTD_PT_shot_viewer_view3d,
WTD_PT_scene_manager_view3d,
WTD_PT_scene_casting_view3d,
WTD_PT_scene_loaded_view3d,
WTD_PT_scene_assetize_view3d,
WTD_PT_rename_view3d,
WTD_PT_rename_node_editor,
WTD_PT_sanity_sequencer,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)