@@ -98,6 +98,176 @@ def _select_keyframe_indices(keyframes_data: list, stride: int) -> list[int]:
9898 return sorted (selected )
9999
100100
101+ def _inject_locality_banners (banners_path : str , cam_obj , scene ) -> None : # noqa: ANN001
102+ """Create world-space locality name banners below the tracking point.
103+
104+ Each banner is placed at a fixed world position computed at spawn time to sit
105+ below the camera's look-at point in screen space. It stays there for its
106+ entire lifetime (does not follow the camera).
107+
108+ Shape: a thin slab in the XZ plane (width=X, height=Z, depth=Y).
109+ TRACK_TO keeps the front face pointing at the camera as the camera moves.
110+ A LOC_DIFF scale driver keeps the apparent angular size constant.
111+
112+ Animated alpha (ShaderNodeValue keyframes) drives the fade-in / fade-out.
113+ """
114+ import bpy # noqa: PLC0415
115+
116+ if hasattr (scene , "eevee" ) and hasattr (scene .eevee , "use_bloom" ):
117+ scene .eevee .use_bloom = False
118+
119+ try :
120+ with open (banners_path ) as fh :
121+ banners = json .load (fh )
122+ except Exception as exc :
123+ print (f"[georeel] Could not load banners JSON: { exc } " , file = sys .stderr )
124+ return
125+
126+ for i , b in enumerate (banners ):
127+ label = b .get ("name" , f"Banner_{ i } " )
128+ bx , by , bz = float (b ["x" ]), float (b ["y" ]), float (b ["z" ])
129+ width_m = float (b ["width_m" ])
130+ height_m = float (b ["height_m" ])
131+ fs = int (b ["frame_start" ])
132+ fe = int (b ["frame_end" ])
133+ ff = int (b ["fade_frames" ])
134+ tex_path = str (b ["texture" ])
135+ scale_f = float (b .get ("scale_factor" , 0.08 ))
136+
137+ # Normalised aspect ratio (mesh width = 1.0, height = aspect)
138+ aspect = height_m / max (width_m , 1e-6 )
139+ d = 0.06 # slab depth (Y)
140+
141+ # ── Mesh: slab in XZ plane ───────────────────────────────────────── #
142+ # Front face (+Y) has CCW winding → normal = +Y.
143+ # TRACK_NEGATIVE_Y points that normal at the camera.
144+ # Width in X, height in Z; origin at slab centre-bottom (Z=0).
145+ hw = 0.5
146+ verts = [
147+ # Front face (y=+d/2), CCW from +Y: BL BR TR TL
148+ (- hw , d / 2 , 0.0 ), # 0
149+ ( hw , d / 2 , 0.0 ), # 1
150+ ( hw , d / 2 , aspect ), # 2
151+ (- hw , d / 2 , aspect ), # 3
152+ # Back face (y=-d/2)
153+ (- hw , - d / 2 , 0.0 ), # 4
154+ ( hw , - d / 2 , 0.0 ), # 5
155+ ( hw , - d / 2 , aspect ), # 6
156+ (- hw , - d / 2 , aspect ), # 7
157+ ]
158+ faces = [
159+ (0 , 1 , 2 , 3 ), # 0 front ← textured
160+ (7 , 6 , 5 , 4 ), # 1 back
161+ (3 , 2 , 6 , 7 ), # 2 top
162+ (0 , 4 , 5 , 1 ), # 3 bottom
163+ (0 , 3 , 7 , 4 ), # 4 left
164+ (2 , 1 , 5 , 6 ), # 5 right
165+ ]
166+
167+ mesh = bpy .data .meshes .new (f"BannerMesh_{ i :04d} " )
168+ mesh .from_pydata (verts , [], faces )
169+ mesh .update ()
170+
171+ obj = bpy .data .objects .new (f"LocalityBanner_{ i :04d} " , mesh )
172+ obj .location = (bx , by , bz )
173+ scene .collection .objects .link (obj )
174+
175+ # ── Material: object-coordinate texture projection ───────────────── #
176+ # U = local_X + 0.5 (X ∈ [-0.5, 0.5] → [0, 1])
177+ # V = clamp(local_Z / aspect) (Z ∈ [0, aspect] → [0, 1])
178+ mat = bpy .data .materials .new (f"BannerMat_{ i :04d} " )
179+ mat .use_nodes = True
180+ # Transparent alpha blending: EEVEE Next (4.2+) uses surface_render_method;
181+ # legacy EEVEE uses blend_method. Try both so the script works across versions.
182+ try :
183+ mat .surface_render_method = "BLENDED" # type: ignore[attr-defined] # Blender 4.2+
184+ except AttributeError :
185+ mat .blend_method = "BLEND" # type: ignore[attr-defined] # Blender < 4.2
186+ try :
187+ mat .show_transparent_back = False
188+ except AttributeError :
189+ pass
190+ nt = mat .node_tree
191+ nt .nodes .clear ()
192+
193+ out_node = nt .nodes .new ("ShaderNodeOutputMaterial" ); out_node .location = (900 , 0 )
194+ mix_node = nt .nodes .new ("ShaderNodeMixShader" ); mix_node .location = (700 , 0 )
195+ transp = nt .nodes .new ("ShaderNodeBsdfTransparent" ); transp .location = (500 , - 120 )
196+ emit = nt .nodes .new ("ShaderNodeEmission" ); emit .location = (500 , 100 )
197+ emit .inputs ["Strength" ].default_value = 1.0
198+ tex_node = nt .nodes .new ("ShaderNodeTexImage" ); tex_node .location = (200 , 100 )
199+ alpha_node = nt .nodes .new ("ShaderNodeValue" ); alpha_node .location = (200 , - 100 )
200+ alpha_node .name = "BannerAlpha"
201+ mul_node = nt .nodes .new ("ShaderNodeMath" ); mul_node .location = (400 , - 60 )
202+ mul_node .operation = "MULTIPLY"
203+
204+ coord_node = nt .nodes .new ("ShaderNodeTexCoord" ); coord_node .location = (- 600 , 100 )
205+ sep_node = nt .nodes .new ("ShaderNodeSeparateXYZ" ); sep_node .location = (- 400 , 100 )
206+ add_u = nt .nodes .new ("ShaderNodeMath" ); add_u .location = (- 200 , 200 )
207+ add_u .operation = "ADD"
208+ add_u .inputs [1 ].default_value = 0.5 # U = local_X + 0.5
209+ div_v = nt .nodes .new ("ShaderNodeMath" ); div_v .location = (- 200 , 0 )
210+ div_v .operation = "DIVIDE"
211+ div_v .inputs [1 ].default_value = max (aspect , 1e-6 ) # V = local_Z / aspect
212+ clamp_v = nt .nodes .new ("ShaderNodeClamp" ); clamp_v .location = (- 50 , 0 )
213+ clamp_v .inputs ["Min" ].default_value = 0.02
214+ clamp_v .inputs ["Max" ].default_value = 0.98
215+ comb_node = nt .nodes .new ("ShaderNodeCombineXYZ" ); comb_node .location = (50 , 100 )
216+
217+ bpy_img = bpy .data .images .load (tex_path )
218+ tex_node .image = bpy_img
219+
220+ # X → U, Z → V
221+ nt .links .new (coord_node .outputs ["Object" ], sep_node .inputs ["Vector" ])
222+ nt .links .new (sep_node .outputs ["X" ], add_u .inputs [0 ])
223+ nt .links .new (sep_node .outputs ["Z" ], div_v .inputs [0 ])
224+ nt .links .new (div_v .outputs [0 ], clamp_v .inputs ["Value" ])
225+ nt .links .new (add_u .outputs [0 ], comb_node .inputs ["X" ])
226+ nt .links .new (clamp_v .outputs ["Result" ], comb_node .inputs ["Y" ])
227+ nt .links .new (comb_node .outputs ["Vector" ], tex_node .inputs ["Vector" ])
228+ nt .links .new (tex_node .outputs ["Color" ], emit .inputs ["Color" ])
229+ nt .links .new (tex_node .outputs ["Alpha" ], mul_node .inputs [0 ])
230+ nt .links .new (alpha_node .outputs [0 ], mul_node .inputs [1 ])
231+ nt .links .new (mul_node .outputs [0 ], mix_node .inputs [0 ])
232+ nt .links .new (transp .outputs ["BSDF" ], mix_node .inputs [1 ])
233+ nt .links .new (emit .outputs ["Emission" ], mix_node .inputs [2 ])
234+ nt .links .new (mix_node .outputs ["Shader" ], out_node .inputs ["Surface" ])
235+ mesh .materials .append (mat )
236+
237+ # ── Animate BannerAlpha ───────────────────────────────────────────── #
238+ fade_peak = min (fs + ff , fe )
239+ fade_out = max (fe - ff , fade_peak )
240+ for frm , val in ((fs , 0.0 ), (fade_peak , 1.0 ), (fade_out , 1.0 ), (fe , 0.0 )):
241+ alpha_node .outputs [0 ].default_value = val
242+ alpha_node .outputs [0 ].keyframe_insert ("default_value" , frame = frm )
243+
244+ if nt .animation_data and nt .animation_data .action :
245+ for fc in nt .animation_data .action .fcurves :
246+ fc .extrapolation = "CONSTANT"
247+ for kp in fc .keyframe_points :
248+ kp .interpolation = "LINEAR"
249+
250+ # ── Scale driver: constant angular size (scale = dist × scale_f) ─── #
251+ for axis_i in range (3 ):
252+ fc = obj .driver_add ("scale" , axis_i )
253+ drv = fc .driver
254+ drv .type = "SCRIPTED"
255+ drv .expression = f"dist * { scale_f } "
256+ var = drv .variables .new ()
257+ var .name = "dist"
258+ var .type = "LOC_DIFF"
259+ var .targets [0 ].id = cam_obj
260+ var .targets [1 ].id = obj
261+
262+ # ── TRACK_TO: front face always points at the camera ─────────────── #
263+ con = obj .constraints .new ("TRACK_TO" )
264+ con .target = cam_obj
265+ con .track_axis = "TRACK_NEGATIVE_Y"
266+ con .up_axis = "UP_Z"
267+
268+ print (f"[georeel] Banner '{ label } ' frames { fs } –{ fe } " )
269+
270+
101271def main () -> None :
102272 import bpy
103273 from mathutils import Matrix , Quaternion , Vector
@@ -117,6 +287,7 @@ def main() -> None:
117287 tex_scale = float (argv [8 ]) if len (argv ) > 8 else 1.0
118288 png_compression = int (argv [9 ]) if len (argv ) > 9 else 1 # zlib level 0–9
119289 compression_port = int (argv [10 ]) if len (argv ) > 10 else 0 # 0 = no server
290+ banners_path = (argv [11 ] or None ) if len (argv ) > 11 else None # "" → None
120291
121292 with open (keyframes_path ) as f :
122293 keyframes_data = json .load (f )
@@ -343,6 +514,12 @@ def main() -> None:
343514 # "######" in the filepath → 6-digit zero-padded frame number. #
344515 # ------------------------------------------------------------------ #
345516
517+ # ------------------------------------------------------------------ #
518+ # Locality banner objects (3D banner mode only) #
519+ # ------------------------------------------------------------------ #
520+ if banners_path :
521+ _inject_locality_banners (banners_path , cam_obj , scene )
522+
346523 scene .frame_start = frame_start_arg if frame_start_arg is not None else 1
347524 scene .frame_end = frame_end_arg if frame_end_arg is not None else total
348525 scene .render .filepath = f"{ output_dir } /######"
0 commit comments