-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsetup_lookdev_scene.py
More file actions
4257 lines (3922 loc) · 237 KB
/
Copy pathsetup_lookdev_scene.py
File metadata and controls
4257 lines (3922 loc) · 237 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# ============================================================================
# GENERATED MIGRATION -- produced by make_migration.py
# ============================================================================
# LOOKDEV_STUDIO_ORIGINAL_520.blend -> LOOKDEV_STUDIO_MODIFIED_520.blend
#
# TOOLCHAIN STAMP b8c496af2283f0b215b51a0d25876b42ad5788641dbe8df8b072c96b74bbfbe9
# make_migration a4085a2cd989 snap_original fb05702dddf1 snap_modified 279600a92686 switcher 4142e178151d workspace c1bd8304c174
#
# SHA-256 over everything that went into this file. tools/new-release.ps1
# recomputes it and refuses to release when it disagrees -- which means this
# file was not regenerated after the toolchain, the snapshots, the add-on or
# the interface changed. Do not edit it by hand; regenerate.
#
# Converts the original scene into the reworked layout. Read before running:
# this is generated code, and the TODO list at the bottom shows everything the
# generator would not guess at.
#
# USAGE: open the original scene, Text Editor -> Run Script.
# Safe to run twice -- every step checks before it acts.
#
# This script removes itself from the .blend when it is done: it is a one-shot
# job, and only the tool should stay behind. Save under a NEW name afterwards.
# ============================================================================
import bpy
import os
_changes = []
def log(msg):
_changes.append(msg)
print(" + %s" % msg)
# ----------------------------------------------------------------------------
# Compositor helpers
# ----------------------------------------------------------------------------
def compositor_tree(scene):
"""The compositor node tree, whatever this Blender calls it.
Scene.node_tree up to 4.x, Scene.compositing_node_group in 5.x.
"""
for attr in ("compositing_node_group", "node_tree", "compositor_node_group"):
tree = getattr(scene, attr, None)
if tree is not None and hasattr(tree, "nodes"):
return tree
return None
def find_node_group(name):
"""A node group by name: already in the file, or from Blender's own assets.
No hard-coded path: Blender is asked where its datafiles live, and the
bundled asset .blend files there are searched. That keeps this working
across versions and installations.
"""
group = bpy.data.node_groups.get(name)
if group is not None:
return group
try:
assets_dir = bpy.utils.system_resource('DATAFILES', path="assets")
except Exception:
assets_dir = None
if not assets_dir or not os.path.isdir(assets_dir):
return None
for root, _dirs, files in os.walk(assets_dir):
for filename in files:
if not filename.lower().endswith(".blend"):
continue
path = os.path.join(root, filename)
try:
with bpy.data.libraries.load(path, link=False) as (src, dst):
if name not in src.node_groups:
continue
dst.node_groups = [name]
except Exception:
continue
group = bpy.data.node_groups.get(name)
if group is not None:
print(" (appended '%s' from %s)" % (name, filename))
return group
return None
def set_socket(node, identifier, value):
"""Set an input socket by its identifier, tolerating type mismatches."""
for socket in node.inputs:
if socket.identifier != identifier:
continue
try:
socket.default_value = value
except (AttributeError, TypeError, ValueError) as exc:
print(" ! %s.%s: %s" % (node.name, identifier, exc))
return True
return False
def socket_by_id(sockets, identifier):
for socket in sockets:
if socket.identifier == identifier:
return socket
return None
def relink(tree, wanted):
"""Rebuild the tree's links exactly as given.
Links are a set, not a sequence of settable properties -- the only way to
reproduce them is to clear and rewire. So look first: rewiring a tree that
is already wired this way is not a change, and reporting it as one is what
kept a second run from reaching "0 changes".
Returns the number of links made, or None when there was nothing to do.
"""
have = set()
for link in tree.links:
try:
have.add((link.from_node.name, link.from_socket.identifier,
link.to_node.name, link.to_socket.identifier))
except Exception:
have = None
break
if have is not None:
want = set((f[0], f[1], t[0], t[1]) for f, t in wanted)
if have == want:
return None
for link in list(tree.links):
tree.links.remove(link)
made = 0
for (from_name, from_id), (to_name, to_id) in wanted:
from_node = tree.nodes.get(from_name)
to_node = tree.nodes.get(to_name)
if from_node is None or to_node is None:
print(" ! link skipped, missing node: %s -> %s"
% (from_name, to_name))
continue
out_socket = socket_by_id(from_node.outputs, from_id)
in_socket = socket_by_id(to_node.inputs, to_id)
if out_socket is None or in_socket is None:
print(" ! link skipped, missing socket: %s.%s -> %s.%s"
% (from_name, from_id, to_name, to_id))
continue
tree.links.new(out_socket, in_socket)
made += 1
return made
# ============================================================================
# EMBEDDED TOOL -- lookdev_switcher.py
# ============================================================================
# Installed into the .blend as a text block with "Register" enabled, so it
# comes back every time the file is opened. For that to work, the user needs
# Edit > Preferences > Save & Load > Auto Run Python Scripts enabled -- once.
# ============================================================================
TOOL_NAME = 'lookdev_switcher.py'
TOOL_SOURCE = r'''# ============================================================================
# LOOKDEV SWITCHER v1.3.4
# ============================================================================
# by Prof. Michael Klein
# professor@virtualrepublic.org
#
# The version above must match bl_info["version"] below -- new-release.ps1
# refuses to release when they disagree. It used to read "v1.2" while bl_info
# said (1, 2, 3), which made a current file look stale to anyone reading the
# first line. A date used to stand here too and rotted the same way; the
# CHANGELOG and the git history carry that.
#
# Copyright (C) 2026 Prof. Michael Klein
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License along with
# this program. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# The licence above covers this script only. The Studio Lookdev scene is not
# part of it and stays under its author's own terms -- download it yourself:
#
# albin. (2021, November 10). Studio Lookdev [3D model]. CGTrader.
# https://www.cgtrader.com/free-3d-models/architectural/other/studio-lookdev
# ----------------------------------------------------------------------------
# Buttons that each activate one collection (including all of its contents)
# and set the matching camera as the scene camera, plus "Align & Link Model"
# for a turntable setup of all models.
#
# INSTALLATION:
# 1. In Blender open a Text Editor -> Open -> lookdev_switcher.py
# (or paste the content into a new text block).
# 2. Run Script (play icon). In the N-panel (press N) the "Lookdev" tab
# appears with the buttons.
# 3. To load it automatically the next time the file is opened:
# enable "Register" in the Text Editor header and, once,
# Edit -> Preferences -> Save & Load -> enable "Auto Run Python Scripts".
#
# CUSTOMIZE: Names are defined in the CONFIGS list below.
# The button colors follow the collection color tags in the
# outliner, so set a color there and the panel follows.
# A collection without a color tag (e.g. MODEL) stays neutral.
# The number "01".."08" in CONFIGS is only used to seed a
# collection that has no color tag yet
# (01 red, 02 orange, 03 yellow, 04 green, 05 blue,
# 06 violet, 07 pink, 08 brown).
# ============================================================================
bl_info = {
"name": "Lookdev Switcher",
"author": "Prof. Michael Klein <professor@virtualrepublic.org>",
"version": (1, 3, 4),
"blender": (5, 2, 0),
"location": "View3D > Sidebar (N-Panel) > Lookdev",
"description": "Collection/camera switcher and turntable setup for lookdev",
"doc_url": "https://www.cgtrader.com/free-3d-models/architectural/other/studio-lookdev",
"category": "3D View",
}
import bpy
import mathutils
# (collection name, camera object name, color number "01".."08")
CONFIGS = [
("MACRO", "macro", "01"), # red
("SMALL", "small", "02"), # orange
("MEDIUM", "medium", "03"), # yellow
("LARGE", "large", "04"), # green
]
COLLECTIONS = [c[0] for c in CONFIGS]
# --- FRAME button -------------------------------------------------------------
FRAME_COLLECTION = "FRAME" # collection for the framing setup
FRAME_CAMERA = "frame" # camera used for framing
FRAME_LENS = 150.0 # focal length in mm
DOF_EMPTY = "DOF" # empty in FRAME used as "Focus on Object"
# Slider range for the DOF empty depth, in meters (-200 cm .. 200 cm).
# These are soft limits: you can still type any value into the field.
DOF_DEPTH_MIN = -2.0
DOF_DEPTH_MAX = 2.0
# The models rotate on the turntable, so the visible extent is measured at
# these frames and the framing fits the union of all of them.
FRAME_CHECK_FRAMES = (0, 75)
# How much of the frame the model fills. 1.0 = maximum crop (the silhouette
# touches the frame edge); below 1.0 dollies the camera back and leaves a
# margin all around -- a "safe action" border. 0.9 = 5 % on each side.
FRAME_FILL = 0.9
# All collections that switch each other off
ALL_COLLECTIONS = COLLECTIONS + [FRAME_COLLECTION]
# All cameras driven by the DOF / F-Stop settings
ALL_CAMERAS = [c[1] for c in CONFIGS] + [FRAME_CAMERA]
# --- Set Render Path button ---------------------------------------------------
# The output path is set relative to the .blend ("//") so renders always land
# next to the file, never in a machine-specific absolute folder. The folder and
# image prefix follow the SAVED .blend's name (not the scene name). Layout:
# //Render/<blend file name>/<blend file name>_
# Blender appends the frame number (4 digits) and the extension, e.g. for
# MyProject.blend:
# Render/MyProject/MyProject_0001.exr
RENDER_SUBDIR = "Render" # top-level output folder next to the .blend
# --- Align & Link Model button ------------------------------------------------
MODEL_COLLECTION = "MODEL" # collection holding the imported models
EMPTY_NAME = "LINKED_ROTATION" # name of the created empty
ROTATION_TARGET = "ROTATION_LINK" # target object for the Child Of constraint
GEO_TYPES = {'MESH', 'CURVE', 'SURFACE', 'FONT', 'META'}
# --- Auto-collect imported objects into MODEL --------------------------------
# A lightweight timer watches for things that appear (an import, or Add) and
# moves them into MODEL so a freshly imported model lands on the turntable
# without a manual drag.
#
# Two cases, handled differently:
# * a whole imported COLLECTION is re-parented under MODEL as a child -- the
# collection and its contents move together (earlier this stripped the
# objects out and left the imported collection behind, empty);
# * loose OBJECTS imported without a collection are linked into MODEL directly.
# Only geometry and empties are moved as loose objects; cameras, lights and the
# tool's own rotation empty are left where they are.
AUTO_MODEL_POLL = 0.5 # seconds between checks for new datablocks
_seen_object_names = set() # object names known at the previous check
_seen_collection_names = set() # collection names known at the previous check
# Collections the auto-collect must never pull into MODEL: the lookdev rig's own
# collections, MODEL itself, and RENDER. (They exist at load time, so they are
# never "new" anyway -- this is a belt-and-braces guard.)
PROTECTED_COLLECTIONS = set(ALL_COLLECTIONS) | {MODEL_COLLECTION, "RENDER"}
def _current_object_names():
return {o.name for o in bpy.data.objects}
def _current_collection_names():
return {c.name for c in bpy.data.collections}
def _collection_parents(coll):
"""Return the collections (and scene master collections) that link `coll`.
Blender has no `collection.parent`; a collection's parents are whoever lists
it in their `children`. The scene master collections count too, so a
freshly appended collection linked at the scene root is found here.
"""
parents = []
for parent in bpy.data.collections:
if parent != coll and parent.children.get(coll.name) is not None:
parents.append(parent)
for scene in bpy.data.scenes:
if scene.collection.children.get(coll.name) is not None:
parents.append(scene.collection)
return parents
def _relink_collection_into_model(coll, model_coll):
"""Link `coll` under MODEL and unlink it from its previous parents.
Moves the whole collection (with its objects and sub-collections) rather
than emptying it. Returns True if it ended up under MODEL.
"""
parents = _collection_parents(coll)
if model_coll.children.get(coll.name) is None:
try:
model_coll.children.link(coll)
except RuntimeError:
return False # would create a cycle -- leave it be
for parent in parents:
if parent != model_coll:
try:
parent.children.unlink(coll)
except RuntimeError:
pass
return True
def auto_collect_into_model():
"""Move things that appeared since the last check into MODEL.
A whole imported collection is re-parented under MODEL (contents and all);
loose imported objects are linked into MODEL individually. Returns the list
of names that were moved (collections shown with a trailing '/'). Objects
already in MODEL, non-geometry (cameras, lights) and the rotation empty are
left alone.
"""
global _seen_object_names, _seen_collection_names
model_coll = bpy.data.collections.get(MODEL_COLLECTION)
current_objs = _current_object_names()
current_colls = _current_collection_names()
moved = []
if model_coll is not None:
# 1. Whole imported collections -> re-parent the collection under MODEL,
# so it keeps its objects instead of being emptied out.
new_coll_names = current_colls - _seen_collection_names
new_colls = [bpy.data.collections.get(n) for n in sorted(new_coll_names)]
new_colls = [c for c in new_colls if c is not None]
new_set = set(new_colls)
for coll in new_colls:
if coll == model_coll or coll.name in PROTECTED_COLLECTIONS:
continue
if model_coll.children.get(coll.name) is not None:
continue # already under MODEL
parents = _collection_parents(coll)
if not parents:
continue # not in the scene tree (e.g. only instanced)
if any(p in new_set for p in parents):
continue # nested inside another new collection -- moves with it
if _relink_collection_into_model(coll, model_coll):
moved.append(coll.name + "/")
# 2. Loose objects imported without a collection -> link into MODEL.
# Objects that arrived inside a collection moved above are already in
# MODEL now, so they fall through the `in model_objs` check.
model_objs = set(model_coll.all_objects)
for name in sorted(current_objs - _seen_object_names):
obj = bpy.data.objects.get(name)
if obj is None or obj.name == EMPTY_NAME:
continue
if obj.type not in GEO_TYPES and obj.type != 'EMPTY':
continue
if obj in model_objs:
continue
# Leave objects that belong to a newly imported collection we chose
# not to move (instanced-only, protected): that collection is the
# import unit, don't strip its contents out.
if any(c.name in new_coll_names for c in obj.users_collection):
continue
for coll in list(obj.users_collection):
coll.objects.unlink(obj)
model_coll.objects.link(obj)
moved.append(name)
_seen_object_names = current_objs
_seen_collection_names = current_colls
return moved
def _is_superseded():
"""True when a newer run of this script has replaced this module.
Running the text block again hands Blender a FRESH module: new function
objects, `_is_registered` back to False. So `register()` cannot reach the
previous run's state, and `bpy.app.timers.is_registered()` compares by
IDENTITY -- it does not recognise the earlier timer and registers a second
one. Every re-run added another, each polling twice a second, and none of
them reachable to stop: bpy.app.timers cannot be enumerated, so there is no
way to find a function whose only reference lives in a module nobody holds
any more. Opening an unrelated file did not help either -- `_teardown()`
unregisters by identity too, so it only ever reached the newest one.
A stale timer therefore has to notice by itself. `register()` removes every
load_post handler named like ours before appending its own, so a module
whose handler is no longer in that list is a module that has been replaced.
That list is the one thing here that IS enumerable, and it is already the
mechanism the file-load guard depends on.
"""
return _lookdev_load_post not in bpy.app.handlers.load_post
def _auto_model_timer():
"""Timer callback: collect new objects when the panel toggle is on.
Restricted to OBJECT mode so nothing is relinked mid edit. When the toggle
is off the baseline is still refreshed, so switching it on later only
affects things imported from that point on, not everything already there.
"""
global _seen_object_names, _seen_collection_names
if _is_superseded():
return None # a newer run is in charge -- retire quietly
scene = getattr(bpy.context, "scene", None)
on = bool(getattr(scene, "lookdev_auto_model", False)) if scene else False
if on and getattr(bpy.context, "mode", 'OBJECT') == 'OBJECT':
auto_collect_into_model()
else:
_seen_object_names = _current_object_names()
_seen_collection_names = _current_collection_names()
return AUTO_MODEL_POLL
def find_layer_collection(layer_coll, name):
"""Recursively find a layer collection by its name."""
if layer_coll.collection.name == name:
return layer_coll
for child in layer_coll.children:
found = find_layer_collection(child, name)
if found:
return found
return None
def activate_subtree(lc):
"""Activate a layer collection including ALL sub-collections and objects."""
lc.exclude = False
lc.hide_viewport = False # clear temporary hide
lc.collection.hide_viewport = False
lc.collection.hide_render = False
for obj in lc.collection.objects:
obj.hide_viewport = False # eye icon in the outliner
obj.hide_render = False # camera icon (render)
obj.hide_set(False) # H / local hide
for child in lc.children:
activate_subtree(child)
def apply_color_tags():
"""Seed the outliner colors from CONFIGS, but never override a manual choice."""
for coll_name, _cam, num in CONFIGS:
coll = bpy.data.collections.get(coll_name)
if coll and coll.color_tag == 'NONE':
coll.color_tag = 'COLOR_' + num
def collection_icon(coll_name):
"""Return the icon matching the collection's color tag in the outliner.
Collections without a color tag (e.g. MODEL) get the neutral icon.
"""
coll = bpy.data.collections.get(coll_name)
if coll and coll.color_tag != 'NONE':
return 'COLLECTION_' + coll.color_tag # -> COLLECTION_COLOR_01 ... _08
return 'OUTLINER_COLLECTION' # neutral
def get_dof_empty():
"""Return the DOF empty from the FRAME collection (falls back to any object)."""
coll = bpy.data.collections.get(FRAME_COLLECTION)
if coll:
obj = coll.all_objects.get(DOF_EMPTY)
if obj:
return obj
return bpy.data.objects.get(DOF_EMPTY)
def apply_dof_settings(scene):
"""Push the panel DOF settings onto every lookdev camera.
FRAME speciality: the frame camera focuses on the DOF empty, and that empty
is moved along Y by the depth slider.
"""
for cam_name in ALL_CAMERAS:
cam = bpy.data.objects.get(cam_name)
if cam and cam.type == 'CAMERA':
cam.data.dof.use_dof = scene.lookdev_dof
cam.data.dof.aperture_fstop = scene.lookdev_fstop
empty = get_dof_empty()
if empty:
empty.location.y = scene.lookdev_dof_depth # depth slider
frame_cam = bpy.data.objects.get(FRAME_CAMERA)
if frame_cam and frame_cam.type == 'CAMERA':
frame_cam.data.dof.focus_object = empty # Focus on Object
def _update_dof_settings(self, context):
"""Panel callback: self is the scene."""
apply_dof_settings(self)
def switch_config(context, coll_name, cam_name):
"""Activate one collection, hide all other lookdev collections, set the camera.
Returns (camera object or None, list of missing collection names).
"""
root = context.view_layer.layer_collection
missing = []
for name in ALL_COLLECTIONS:
lc = find_layer_collection(root, name)
if not lc:
missing.append(name)
continue
if name == coll_name:
activate_subtree(lc) # fully activate (incl. contents)
else:
lc.exclude = True # fully hide
cam = bpy.data.objects.get(cam_name)
if cam:
context.scene.camera = cam
return cam, missing
class SCENE_OT_set_config(bpy.types.Operator):
bl_idname = "scene.set_config"
bl_label = "Set Config"
bl_options = {'REGISTER', 'UNDO'}
collection: bpy.props.StringProperty()
camera: bpy.props.StringProperty()
def execute(self, context):
cam, missing = switch_config(context, self.collection, self.camera)
for name in missing:
self.report({'WARNING'}, "Collection '%s' not found" % name)
if cam is None:
self.report({'WARNING'}, "Camera '%s' not found" % self.camera)
return {'FINISHED'}
def collect_bbox_corners(objects, depsgraph=None):
"""Return all world-space bounding box corners of the given objects.
Geo objects are measured directly (evaluated through the depsgraph when one is
given, so modifiers, constraints and animation at the current frame count). A
collection-instance empty carries no geometry of its own -- its meshes exist
only as depsgraph instances -- so those are expanded to their evaluated world
matrices too. That also covers nested and library-linked collections.
"""
coords = []
instancers = set()
for obj in objects:
if obj.type in GEO_TYPES:
src = obj.evaluated_get(depsgraph) if depsgraph else obj
for corner in src.bound_box:
coords.append(src.matrix_world @ mathutils.Vector(corner))
elif obj.type == 'EMPTY' and obj.instance_collection is not None:
instancers.add(obj)
# Expand collection instances. object_instances yields every instanced object
# with its evaluated world matrix; keep the ones whose instancer sits in MODEL.
if instancers and depsgraph is not None:
for inst in depsgraph.object_instances:
if not inst.is_instance:
continue
parent = inst.parent
if parent is None or parent.original not in instancers:
continue
ob = inst.object
if ob is None or ob.type not in GEO_TYPES:
continue
mw = inst.matrix_world
for corner in ob.bound_box:
coords.append(mw @ mathutils.Vector(corner))
return coords
def visible_geo_objects(objects):
"""Return only the geo objects that are actually visible in the view layer.
visible_get() covers the eye icon, the monitor icon, local hide (H) and
excluded collections in one go.
"""
return [o for o in objects if o.type in GEO_TYPES and o.visible_get()]
def visible_instance_empties(objects):
"""Return visible empties that instance a collection.
A collection linked or instanced from elsewhere appears as an EMPTY with an
instance_collection, not as real meshes in MODEL, so it is invisible to a
plain geo scan and has to be gathered separately for measuring.
"""
return [o for o in objects
if o.type == 'EMPTY' and o.instance_collection is not None
and o.visible_get()]
def compute_bbox_center_floor(objects, depsgraph=None):
"""Return (X center, Y center, Z floor) of the world bounding box of all geo objects."""
coords = collect_bbox_corners(objects, depsgraph)
if not coords:
return None
xs = [v.x for v in coords]
ys = [v.y for v in coords]
zs = [v.z for v in coords]
return mathutils.Vector((
(min(xs) + max(xs)) / 2.0, # X centered
(min(ys) + max(ys)) / 2.0, # Y centered
min(zs), # Z at the floor
))
def camera_sensor_tangents(cam_data, scene):
"""Return (tan of half horizontal FOV, tan of half vertical FOV).
Takes the render resolution, pixel aspect and the camera sensor fit into account.
"""
render = scene.render
width = render.resolution_x * render.pixel_aspect_x
height = render.resolution_y * render.pixel_aspect_y
aspect = width / height
if cam_data.sensor_fit == 'VERTICAL':
sensor_y = cam_data.sensor_height
sensor_x = sensor_y * aspect
elif cam_data.sensor_fit == 'HORIZONTAL':
sensor_x = cam_data.sensor_width
sensor_y = sensor_x / aspect
else: # AUTO: sensor_width applies to the longer image axis
if aspect >= 1.0:
sensor_x = cam_data.sensor_width
sensor_y = sensor_x / aspect
else:
sensor_y = cam_data.sensor_width
sensor_x = sensor_y * aspect
return (sensor_x * 0.5) / cam_data.lens, (sensor_y * 0.5) / cam_data.lens
def fit_camera_to_points(cam_obj, points, scene, fill=1.0):
"""Keep the camera rotation, move it so all points are centered and fill the frame.
The limiting axis decides the distance, so the models are framed either to
width or to height depending on the bounding box aspect ratio. ``fill`` is
the fraction of the frame the model should occupy: 1.0 is maximum crop,
below 1.0 pulls the camera back and leaves a safe-action margin around it.
"""
rot = cam_obj.matrix_world.to_3x3().normalized()
rot_inv = rot.inverted()
local = [rot_inv @ p for p in points] # points in camera axes
xs = [v.x for v in local]
ys = [v.y for v in local]
cx = (min(xs) + max(xs)) / 2.0 # centered horizontally
cy = (min(ys) + max(ys)) / 2.0 # centered vertically
tan_x, tan_y = camera_sensor_tangents(cam_obj.data, scene)
# Shrink the effective field of view so the model fills only ``fill`` of it;
# the camera then sits further back and a margin appears around the subject.
tan_x *= fill
tan_y *= fill
# The camera looks along its local -Z. For every corner the camera must be at
# least this far back so the corner still fits; the maximum wins.
cam_z = max(v.z + max(abs(v.x - cx) / tan_x, abs(v.y - cy) / tan_y) for v in local)
mw = cam_obj.matrix_world.copy()
mw.translation = rot @ mathutils.Vector((cx, cy, cam_z))
cam_obj.matrix_world = mw
def fit_camera_clip_end(cam_obj, points, margin=2.0):
"""Push the camera's far clip out so a large model is not clipped away.
Only ever grows clip_end -- to the distance from the camera to the farthest
point times a margin -- so small models keep the studio camera's original
near/far range while big ones get enough depth to render in full.
"""
if not points:
return
cam_pos = cam_obj.matrix_world.translation
far = max((p - cam_pos).length for p in points)
needed = far * margin
if needed > cam_obj.data.clip_end:
cam_obj.data.clip_end = needed
def get_framing_objects(context, model_coll):
"""Pick the objects to frame and describe where they came from.
Priority:
1. selected geo objects that live inside MODEL (most specific)
2. any selected geo objects
3. everything in MODEL (nothing selected)
"""
selected = [o for o in context.selected_objects
if o.type in GEO_TYPES
or (o.type == 'EMPTY' and o.instance_collection is not None)]
if selected:
in_model = set(model_coll.all_objects)
inside = [o for o in selected if o in in_model]
if inside:
return inside, "%d selected object(s) in '%s'" % (len(inside), MODEL_COLLECTION)
return selected, "%d selected object(s)" % len(selected)
return list(model_coll.all_objects), "all of '%s'" % MODEL_COLLECTION
class SCENE_OT_frame_model(bpy.types.Operator):
bl_idname = "scene.frame_model"
bl_label = "FRAME"
bl_description = ("Activate '%s', switch to camera '%s', set %d mm and fit the "
"frame to the selection (or all of '%s' if nothing is "
"selected) as seen at frames %s"
% (FRAME_COLLECTION, FRAME_CAMERA, int(FRAME_LENS),
MODEL_COLLECTION,
", ".join(str(f) for f in FRAME_CHECK_FRAMES)))
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
scene = context.scene
model_coll = bpy.data.collections.get(MODEL_COLLECTION)
if not model_coll:
self.report({'ERROR'}, "Collection '%s' not found" % MODEL_COLLECTION)
return {'CANCELLED'}
# Read the selection BEFORE switching collections: excluding a collection
# drops its objects from the view layer and thus from the selection.
targets, source = get_framing_objects(context, model_coll)
if not targets:
self.report({'ERROR'}, "Nothing to frame")
return {'CANCELLED'}
cam_obj, missing = switch_config(context, FRAME_COLLECTION, FRAME_CAMERA)
for name in missing:
self.report({'WARNING'}, "Collection '%s' not found" % name)
if cam_obj is None or cam_obj.type != 'CAMERA':
self.report({'ERROR'}, "Camera '%s' not found" % FRAME_CAMERA)
return {'CANCELLED'}
original_frame = scene.frame_current
cam_obj.data.lens = FRAME_LENS # set focal length before fitting
apply_dof_settings(scene) # DOF / F-Stop also apply to this camera
# The models rotate, so sample the bounding box at every check frame and
# fit the union: the widest silhouette decides the distance.
corners = []
for frame in FRAME_CHECK_FRAMES:
scene.frame_set(frame)
depsgraph = context.evaluated_depsgraph_get()
corners.extend(collect_bbox_corners(targets, depsgraph))
if not corners:
self.report({'ERROR'}, "No geometry found to frame")
scene.frame_set(original_frame)
return {'CANCELLED'}
# Fit with a deterministic camera orientation (first check frame),
# leaving a safe-action margin so the silhouette does not touch the edge.
scene.frame_set(FRAME_CHECK_FRAMES[0])
fit_camera_to_points(cam_obj, corners, scene, FRAME_FILL)
fit_camera_clip_end(cam_obj, corners) # grow far clip for large models
context.view_layer.update()
scene.frame_set(original_frame) # restore the original frame
self.report({'INFO'}, "Framed %s with camera '%s' at %d mm (frames %s)"
% (source, FRAME_CAMERA, int(FRAME_LENS),
", ".join(str(f) for f in FRAME_CHECK_FRAMES)))
return {'FINISHED'}
class SCENE_OT_link_model(bpy.types.Operator):
bl_idname = "scene.link_model"
bl_label = "Align & Link Model"
bl_description = ("At frame 0: center empty '%s' on the floor midpoint of the "
"models in '%s', group the models, move to '%s' and bind "
"via Child Of"
% (EMPTY_NAME, MODEL_COLLECTION, ROTATION_TARGET))
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
model_coll = bpy.data.collections.get(MODEL_COLLECTION)
if not model_coll:
self.report({'ERROR'}, "Collection '%s' not found" % MODEL_COLLECTION)
return {'CANCELLED'}
# Always align and bind at frame 0 (rest pose of ROTATION_LINK)
scene = context.scene
original_frame = scene.frame_current
scene.frame_set(0)
# Remember top-level groups across ALL sub-collections
# (all_objects also covers nested collections like 10497_Galaxy_Explorer)
roots = [o for o in model_coll.all_objects
if o.parent is None and o.name != EMPTY_NAME]
if not roots:
self.report({'WARNING'}, "No model groups found in '%s'" % MODEL_COLLECTION)
# 1. Floor midpoint of the overall bounding box (X/Y centered, Z at the floor).
# Only visible meshes are measured, so hidden parts cannot skew the result.
# A collection instance (a collection linked from elsewhere) has no meshes
# of its own, so gather its instancer empties too and let the depsgraph
# expand them.
depsgraph = context.evaluated_depsgraph_get()
model_objs = model_coll.all_objects
measurable = (visible_geo_objects(model_objs)
+ visible_instance_empties(model_objs))
center = compute_bbox_center_floor(measurable, depsgraph)
if center is None:
self.report({'ERROR'}, "No visible geometry found to measure")
scene.frame_set(original_frame)
return {'CANCELLED'}
# Create the empty (or reuse an existing one)
empty = bpy.data.objects.get(EMPTY_NAME)
if empty is None or empty.type != 'EMPTY':
empty = bpy.data.objects.new(EMPTY_NAME, None)
empty.empty_display_type = 'PLAIN_AXES'
if empty.name not in model_coll.objects:
model_coll.objects.link(empty)
empty.location = center
empty.rotation_euler = (0.0, 0.0, 0.0)
context.view_layer.update() # refresh matrix_world
# 2. Parent all top-level groups under the empty (keep transform)
inv = empty.matrix_world.inverted()
for obj in roots:
obj.parent = empty
obj.matrix_parent_inverse = inv
# 3. Move the empty (with all sub-groups) to the position of ROTATION_LINK,
# then bind via Child Of
target = bpy.data.objects.get(ROTATION_TARGET)
if target:
empty.location = target.matrix_world.translation.copy() # moves everything along
context.view_layer.update()
con = empty.constraints.get("Child Of Rotation")
if con is None:
con = empty.constraints.new('CHILD_OF')
con.name = "Child Of Rotation"
con.target = target
context.view_layer.update()
con.inverse_matrix = target.matrix_world.inverted() # = "Set Inverse"
else:
self.report({'WARNING'}, "Target '%s' not found - constraint skipped"
% ROTATION_TARGET)
scene.frame_set(original_frame) # restore the original frame
self.report({'INFO'}, "'%s' created and linked at frame 0" % EMPTY_NAME)
return {'FINISHED'}
class SCENE_OT_set_render_path(bpy.types.Operator):
bl_idname = "scene.set_render_path"
bl_label = "Set Render Path"
bl_description = ("Set the output path relative to this .blend to "
"//%s/<blend name>/<blend name>_ so frames render into a "
"folder named after the saved project file, e.g. "
"%s/MyProject/MyProject_0001.exr"
% (RENDER_SUBDIR, RENDER_SUBDIR))
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
scene = context.scene
# Folder and image prefix follow the SAVED .blend's name, not the scene
# name. A "//" output path needs a saved file anyway, so require one and
# report clearly rather than writing next to an unnamed file.
blend_path = bpy.data.filepath
if not blend_path:
self.report({'ERROR'}, "Save the .blend first -- the render path "
"follows the project file name")
return {'CANCELLED'}
name = bpy.path.display_name_from_filepath(blend_path) # .blend name, no extension
# "//" keeps the path relative to the .blend; forward slashes are
# accepted by Blender on every OS. The trailing "_" is the image name
# prefix -- Blender appends the 4-digit frame number and the extension.
path = "//%s/%s/%s_" % (RENDER_SUBDIR, name, name)
scene.render.filepath = path
self.report({'INFO'}, "Render path set to '%s' (+ frame number)" % path)
return {'FINISHED'}
class VIEW3D_PT_lookdev_switcher(bpy.types.Panel):
# Read from bl_info, so the panel cannot disagree with the file about which
# version is registered. Without this there is no way to tell from inside
# Blender which build a converted scene actually carries.
bl_label = "Lookdev Switcher %d.%d.%d" % bl_info["version"]
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Lookdev" # tab name in the N-panel
def draw(self, context):
layout = self.layout
active_cam = context.scene.camera.name if context.scene.camera else ""
col = layout.column(align=True)
for coll_name, cam_name, _num in CONFIGS:
op = col.operator(
"scene.set_config",
text=coll_name,
icon=collection_icon(coll_name), # color follows the outliner
depress=(active_cam == cam_name), # active button stays pressed
)
op.collection = coll_name
op.camera = cam_name
col.operator("scene.frame_model", text="FRAME",
icon=collection_icon(FRAME_COLLECTION),
depress=(active_cam == FRAME_CAMERA))
layout.separator()
layout.prop(context.scene, "lookdev_dof", toggle=True,
icon='CAMERA_DATA') # depth of field on/off (all cameras)
col = layout.column(align=True)
col.enabled = context.scene.lookdev_dof # only editable when DOF is on
col.prop(context.scene, "lookdev_fstop")
col.prop(context.scene, "lookdev_dof_depth", slider=True)
layout.separator()
layout.prop(context.scene, "lookdev_auto_model", toggle=True,
icon='IMPORT') # auto-move imports into MODEL
layout.operator("scene.link_model", text="Align & Link Model",
icon=collection_icon(MODEL_COLLECTION)) # neutral, matching MODEL
layout.separator()
layout.operator("scene.set_render_path", text="Set Render Path",
icon='OUTPUT') # //Render/<scene>/<scene>_
classes = (SCENE_OT_set_config, SCENE_OT_frame_model, SCENE_OT_link_model,
SCENE_OT_set_render_path, VIEW3D_PT_lookdev_switcher)
# The tool ships as this text block inside the .blend. Its name is the marker for
# "this is a Lookdev file": if the block is absent after a load, the scene is not
# ours and the panel must not follow it (see _lookdev_load_post).
SELF_TEXT_NAME = "lookdev_switcher.py"
_is_registered = False
@bpy.app.handlers.persistent
def _lookdev_load_post(_dummy):
"""After every File > New / Open, drop the panel unless this is a Lookdev
file. Class registration is per Blender session, not per .blend, so without
this the panel would linger in a new scene until Blender is restarted."""
if bpy.data.texts.get(SELF_TEXT_NAME) is None:
_teardown()
def _teardown():
"""Remove the panel, its Scene properties and the background timer. Leaves the
load_post guard in place so it keeps watching later loads. Safe to call when
nothing is registered."""
global _is_registered
if not _is_registered:
return
if bpy.app.timers.is_registered(_auto_model_timer):
bpy.app.timers.unregister(_auto_model_timer)
for prop in ("lookdev_auto_model", "lookdev_dof_depth",
"lookdev_fstop", "lookdev_dof"):
if hasattr(bpy.types.Scene, prop):
delattr(bpy.types.Scene, prop)
for cls in reversed(classes):
try:
bpy.utils.unregister_class(cls)
except Exception:
pass
_is_registered = False