Skip to content

Commit 15f9c81

Browse files
committed
Add 3D locality map-pin banners and unify composite pipeline
Blender-side billboard objects (TRACK_TO + LOC_DIFF scale driver + animated alpha) render locality names as world-space pins during the frame-render pass when show_3d_banner is enabled. Locality settings are now computed before the render step so 3D pins are baked into terrain frames. A new run_composite_stage() helper unifies photo + plain-text compositing for both the preview and final-render paths, ensuring photo frames are never modified by the locality text compositor. The plain-text and 3D modes are now independent toggles in the UI. Settings JSON is written beside the output file rather than in OS temp to avoid the stale-file sweep. Adds --reload flag to the server CLI for development convenience.
1 parent 4805d87 commit 15f9c81

12 files changed

Lines changed: 641 additions & 108 deletions

src/georeel/core/blender_scripts/render_frames.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
101271
def 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

Comments
 (0)