diff --git a/CLAUDE.md b/CLAUDE.md index 9a7a52d..eb251c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 -- ` 2. `python dev/fake_libreflow.py ` (keep running; restart for a clean state) 3. `blender -b --factory-startup --python-exit-code 1 --python dev/test_scene_manager.py -- ` - (58 checks, "All checks passed"). `/_test/publish` and `/_test/download` simulate a new + (60 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. @@ -72,7 +72,8 @@ 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 `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{n}`). Overrides +- 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 diff --git a/README.md b/README.md index 67d83a0..165211b 100644 --- a/README.md +++ b/README.md @@ -360,7 +360,7 @@ Lists the assets cast in the shot, with how many instances are loaded out of how - **Collection:** from that file, `COL_`, else ``, 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**. - **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__LOD0`, `COL__LOD1`…), LOD 0 is shown (the lowest LOD the file has, if not LOD 0). See [Levels of detail](#levels-of-detail). +- **Level of detail:** when the asset collection holds LOD collections (`COL__LODhigh`, `COL__LOD0`…), only the first one is shown. See [Levels of detail](#levels-of-detail). All of these can be changed in the [Configuration](#configuration). @@ -369,32 +369,34 @@ All of these can be changed in the [Configuration](#configuration). 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"). -- **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. +- **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. - **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. - **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. -- **Selected LOD** (shown when a selected instance has LODs): switch the instances of the selected objects to that LOD. +- **LOD of Selected** (shown when a selected instance has LODs): switch the instances of the selected objects to that LOD. - The magnifier re-checks the versions. -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. +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 the first one). **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__LOD`. One LOD is shown at a time: +Rigs and models can hold their levels of detail as **child collections of the asset collection**, named `COL__LOD`. The name can be anything: `high`, `mid`, `low`, or numbers `0`, `1`, `2`. 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 +├─ COL_bob_LODhigh shown +├─ COL_bob_LODmid hidden +└─ COL_bob_LODlow 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). +Each instance has its own LOD: two instances of the same asset can show different LODs. The choice is saved with the file. + +The buttons follow `SCENE_MANAGER_LODS` (`high`, `mid`, `low`), and the first of these the file has is shown on load. LODs not in that list come after it: numbers in order (`0`, `1`, `2`… `10`), then other names A-Z. So a file with `COL_bob_LOD0`, `COL_bob_LOD1` shows LOD `0` on load. The naming is `SCENE_MANAGER_LOD_COLLECTION` (`COL_{asset}_LOD{lod}`): keep a fixed part like `LOD` in it, since every child collection matching it counts as a LOD. Both are in the [Configuration](#configuration). ### Assetize @@ -454,7 +456,7 @@ Name formats use Python's `str.format`: `"sq{:04d}"` turns `10` into `sq0010`. ` | 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) | | 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_LOD_COLLECTION`, `SCENE_MANAGER_DEFAULT_LOD`, `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_LODS`, `SCENE_MANAGER_TYPE_COLLECTION`, `SCENE_MANAGER_ASSETIZE_TASK` | --- diff --git a/config.py b/config.py index f8f7746..b3d47ae 100644 --- a/config.py +++ b/config.py @@ -299,11 +299,13 @@ SCENE_MANAGER_LINK_MODES = { "*": "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. +# asset name, {lod} = LOD name, any name: COL_bob_LOD0, COL_bob_LODhigh...). An override +# shows one and hides the others; a collection instance instances it. Keep a fixed part +# around {lod} ("LOD"): every child collection matching the pattern counts as a LOD. 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 +# Order of the LOD names; the first one the file has is shown when an asset is loaded. +# LODs not listed come after them: numbers in order (0, 1, 2...), then names A-Z. +SCENE_MANAGER_LODS = ["high", "mid", "low"] # Collection gathering the loaded assets of a type, under the scene collection. SCENE_MANAGER_TYPE_COLLECTION = "COL_{asset_type}" # Default task of the assetize dialog. diff --git a/dev/make_test_assets.py b/dev/make_test_assets.py index 34b8cee..0b6c4c5 100644 --- a/dev/make_test_assets.py +++ b/dev/make_test_assets.py @@ -5,7 +5,7 @@ bob: a rig (COL_bob: armature + meshes), v001 has 1 mesh, v002 has 2. tree: COL_tree holding only LOD collections (COL_tree_LOD0, COL_tree_LOD1), plus an 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. +car: a rig, COL_car: armature + named LODs COL_car_LODlow, _LODhigh, _LODmid. 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). """ @@ -24,7 +24,7 @@ def mesh_object(name): return bpy.data.objects.new(name, mesh) -def write(path, collection_name, meshes=1, rig=False, extra=False, node_group=False, lods=0): +def write(path, collection_name, meshes=1, rig=False, extra=False, node_group=False, lods=()): bpy.ops.wm.read_homefile(use_empty=True) scene = bpy.context.scene collection = bpy.data.collections.new(collection_name) @@ -42,7 +42,7 @@ def write(path, collection_name, meshes=1, rig=False, extra=False, node_group=Fa ob = mesh_object(f"GEO_{collection_name}_{index}") ob.parent = parent collection.objects.link(ob) - for lod in range(lods): + for lod in 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}") @@ -62,9 +62,9 @@ def write(path, collection_name, meshes=1, rig=False, extra=False, node_group=Fa 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", "v002", "rig.blend"), "COL_bob", meshes=2, rig=True, 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", "v001", "model.blend"), "COL_tree", meshes=0, extra=True, lods=["0", "1"]) +write(os.path.join(lib, "tree", "v002", "model.blend"), "COL_tree", meshes=1, extra=True, lods=["0", "1"]) 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, "car", "v001", "car.blend"), "COL_car", meshes=0, rig=True, lods=3) +write(os.path.join(lib, "car", "v001", "car.blend"), "COL_car", meshes=0, rig=True, lods=["low", "high", "mid"]) print("Test assets written in", lib) diff --git a/dev/test_scene_manager.py b/dev/test_scene_manager.py index 7e9a986..58cda78 100644 --- a/dev/test_scene_manager.py +++ b/dev/test_scene_manager.py @@ -132,40 +132,42 @@ check("car loaded once downloaded", loaded().get("car", (None,))[0] == "v001", m 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) +check("numbered LODs: instance shows the first", lib.current_lod(tree_lib, tree) == "0" and tree.instance_collection.name == "COL_tree_LOD0") +check("named LODs in config order", list(lib.lods(car_lib, car)) == ["high", "mid", "low"], str(list(lib.lods(car_lib, car)))) +check("named LODs: override shows the first in config order", lib.current_lod(car_lib, car) == "high") 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)}, + hidden == {"COL_car_LODhigh": (False, False), "COL_car_LODmid": (True, True), "COL_car_LODlow": (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) +check("hidden LODs not evaluated", "GEO_COL_car_LODhigh" in evaluated and not evaluated & {"GEO_COL_car_LODmid", "GEO_COL_car_LODlow"}) +bpy.ops.wtd_scene.set_lod(lod="low", library="car", instance=car.name) +check("override switched to low", lib.current_lod(car_lib, car) == "low") 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"}) +check("LOD of selected", bpy.ops.wtd_scene.set_lod(lod="1", selected=True) == {"FINISHED"}) +check("only the selected instances having it switched", (lib.current_lod(tree_lib, tree), lib.current_lod(car_lib, car)) == ("1", "low")) +check("missing LOD refused", bpy.ops.wtd_scene.set_lod(lod="5", library="tree", instance="tree") == {"CANCELLED"}) +check("LOD order: list, numbers, names", sorted(["b", "10", "low", "2", "a", "high"], key=lib.lod_order) == ["high", "low", "2", "10", "a", "b"]) 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, + list(lib.lods(car_lib, second)) == ["high", "mid", "low"] and lib.current_lod(car_lib, second) == "high", 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"}) +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) +check("LODs back: default LOD", lib.current_lod(bpy.data.libraries["tree"], tree) == "0") print("Versions") check("bob is the latest", sm.version_status(bpy.data.libraries["bob"])[0] == "LATEST") @@ -256,7 +258,7 @@ bpy.ops.wm.open_mainfile(filepath=path) sm._on_load_post() 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) +check("LOD kept after reopening", lib.current_lod(car_lib, lib.instances(car_lib)[0]) == "low") sm.connect(bpy.context.scene) pump() check("versions checked again", sm.version_status(bpy.data.libraries["bob"])[0] == "UPDATE") diff --git a/scene_manager/library.py b/scene_manager/library.py index c13ce8d..fa4565a 100644 --- a/scene_manager/library.py +++ b/scene_manager/library.py @@ -177,12 +177,24 @@ def add_instance(scene, view_layer, library, parent=None): # --------------------------------------------------------------------------- # 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)) + "$") + return re.compile( + re.escape(before.format(asset=asset_name)) + r"(.+?)" + re.escape(after.format(asset=asset_name)) + r"(?:\.\d{3,})?$" + ) + + +def lod_order(name): + """Sort key of a LOD name: config SCENE_MANAGER_LODS order, then numbers, then names A-Z.""" + listed = [str(n) for n in config.SCENE_MANAGER_LODS] + if name in listed: + return (0, listed.index(name), "") + if name.isdigit(): + return (1, int(name), "") + return (2, 0, name.lower()) 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.""" + """{LOD name: collection} in config order: 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 {} @@ -193,8 +205,8 @@ def lods(library, instance): 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())) + found[match.group(1)] = child + return {name: found[name] for name in sorted(found, key=lod_order)} def current_lod(library, instance): @@ -211,7 +223,7 @@ def set_lod(library, instance, lod): 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" + available = ", ".join(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) @@ -223,23 +235,22 @@ def set_lod(library, instance, lod): ) instance.instance_collection = found[lod] return - for number, collection in found.items(): - collection.hide_viewport = collection.hide_render = number != lod + for name, collection in found.items(): + collection.hide_viewport = collection.hide_render = name != 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.""" + """After a load or an update: show the first LOD in config order 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: + if not found or current_lod(library, instance) is not None: return try: - set_lod(library, instance, default if default in found else min(found)) + set_lod(library, instance, next(iter(found))) except LoadError: pass # a collection instance with content outside its LODs keeps the whole collection diff --git a/scene_manager/operators.py b/scene_manager/operators.py index 3a14f1d..5d89ab4 100644 --- a/scene_manager/operators.py +++ b/scene_manager/operators.py @@ -13,7 +13,7 @@ import time import bpy from bpy.app.handlers import persistent -from bpy.props import BoolProperty, EnumProperty, IntProperty, PointerProperty, StringProperty +from bpy.props import BoolProperty, EnumProperty, PointerProperty, StringProperty from .. import config from . import ipc @@ -737,7 +737,7 @@ class WTD_OT_scene_set_lod(bpy.types.Operator): bl_description = "Show this level of detail (one of the asset's LOD collections)" bl_options = {"REGISTER", "UNDO"} - lod: IntProperty(name="LOD", min=0) + lod: StringProperty(name="LOD", description="LOD name (the {lod} part of the LOD collection name)") 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"}) diff --git a/ui/panel.py b/ui/panel.py index 72215a0..3827b28 100644 --- a/ui/panel.py +++ b/ui/panel.py @@ -488,9 +488,9 @@ class _SceneLoadedPanel: 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 + 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 layout.separator() row = layout.row(align=True) @@ -502,13 +502,14 @@ class _SceneLoadedPanel: 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) + 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} - 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 + 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.lod, op.selected = name, True class _SceneAssetizePanel: