From 57cf4955a1b8dcee930bc4a210395ab4d1eb3b8a Mon Sep 17 00:00:00 2001 From: Onek8 Date: Tue, 21 Jul 2026 20:57:36 -0700 Subject: [PATCH] Live patch - Light properties, object visibility and world --- leenkx/blender/lnx/live_patch.py | 280 ++++++++++++++++++++++++++----- 1 file changed, 238 insertions(+), 42 deletions(-) diff --git a/leenkx/blender/lnx/live_patch.py b/leenkx/blender/lnx/live_patch.py index 3e32f33d..c59e4f65 100644 --- a/leenkx/blender/lnx/live_patch.py +++ b/leenkx/blender/lnx/live_patch.py @@ -1,5 +1,6 @@ import os import shutil +import math from typing import Any, Type import bpy @@ -52,6 +53,24 @@ def start(): for light_type in (bpy.types.AreaLight, bpy.types.PointLight, bpy.types.SpotLight, bpy.types.SunLight): listen(light_type, "color", "light_color") listen(light_type, "energy", "light_energy") + listen(light_type, "use_shadow", "light_shadow") + listen(light_type, "lnx_clip_start", "light_near") + listen(light_type, "lnx_clip_end", "light_far") + listen(light_type, "lnx_fov", "light_fov") + listen(light_type, "lnx_shadows_bias", "light_bias") + + listen(bpy.types.SpotLight, "spot_size", "light_spot_size") + listen(bpy.types.SpotLight, "spot_blend", "light_spot_blend") + listen(bpy.types.PointLight, "shadow_soft_size", "light_size") + listen(bpy.types.SpotLight, "shadow_soft_size", "light_size") + listen(bpy.types.AreaLight, "size", "light_area_size") + listen(bpy.types.AreaLight, "size_y", "light_area_size") + + listen(bpy.types.Camera, "lens", "cam_props") + listen(bpy.types.Camera, "sensor_width", "cam_props") + listen(bpy.types.Camera, "clip_start", "cam_props") + listen(bpy.types.Camera, "clip_end", "cam_props") + listen(bpy.types.Camera, "lnx_frustum_culling", "cam_props") global __running __running = True @@ -78,9 +97,59 @@ def get_obj_transforms(obj): def get_light_props(obj): light = obj.data - color = (round(light.color[0], 6), round(light.color[1], 6), round(light.color[2], 6)) - energy = round(light.energy, 6) - return color, energy + props = { + 'light_color': (round(light.color[0], 6), round(light.color[1], 6), round(light.color[2], 6)), + 'light_energy': round(light.energy, 6), + 'light_shadow': bool(light.use_shadow), + 'light_near': round(light.lnx_clip_start, 6), + 'light_far': round(light.lnx_clip_end, 6), + 'light_fov': round(light.lnx_fov, 6), + 'light_bias': round(light.lnx_shadows_bias * 0.0001, 6), + } + if light.type == 'SPOT': + half_angle = light.spot_size * 0.5 + outer_cos = math.cos(half_angle) + blend = max(0.0, min(1.0, light.spot_blend)) + inner_angle = math.atan(math.tan(half_angle) * (1.0 - blend)) + props['light_spot_size'] = round(outer_cos, 6) + props['light_spot_blend'] = round(max(0.0001, math.cos(inner_angle) - outer_cos), 6) + if light.type in ('POINT', 'SPOT'): + props['light_size'] = round(light.shadow_soft_size * 10, 6) + if light.type == 'AREA': + props['light_area_size'] = (round(light.size, 6), round(light.size_y, 6)) + return props + + +def get_camera_props(obj): + cam = obj.data + return { + 'cam_props': ( + round(cam.lens, 6), + round(cam.sensor_width, 6), + round(cam.clip_start, 6), + round(cam.clip_end, 6), + bool(cam.lnx_frustum_culling), + ), + } + + +def get_world_clear_color(): + scene = bpy.context.scene + if scene.world is None: + return (0.051, 0.051, 0.051, 1.0), 1.0 + if scene.world.node_tree is None: + c = scene.world.color + return (round(c[0], 6), round(c[1], 6), round(c[2], 6), 1.0), 1.0 + if 'Background' in scene.world.node_tree.nodes: + bg = scene.world.node_tree.nodes['Background'] + col = bg.inputs[0].default_value + strength = bg.inputs[1].default_value + r = max(min(col[0], 1.0), 0.0) + g = max(min(col[1], 1.0), 0.0) + b = max(min(col[2], 1.0), 0.0) + a = max(min(col[3], 1.0), 0.0) + return (round(r, 6), round(g, 6), round(b, 6), round(a, 6)), round(strength, 6) + return (0.051, 0.051, 0.051, 1.0), 1.0 DEPSGRAPH_HANDLERS = { 'is_updated_transform': [ @@ -107,20 +176,54 @@ def on_depsgraph_update(updates): for event_id, extractor in DEPSGRAPH_HANDLERS['is_updated_transform']: current = extractor(uid) cache_key = (event_id, name) - if current != scene_cache.get(cache_key): + if cache_key not in scene_cache: scene_cache[cache_key] = current - send_event(event_id) + elif current != scene_cache[cache_key]: + scene_cache[cache_key] = current + send_event(event_id, name) changed = True if update.is_updated_shading and isinstance(uid.data, bpy.types.Light): - color, energy = get_light_props(uid) - for event_id, current in (('light_color', color), ('light_energy', energy)): + for event_id, current in get_light_props(uid).items(): cache_key = (event_id, uid.data.name) - if current != scene_cache.get(cache_key): + if cache_key not in scene_cache: scene_cache[cache_key] = current - send_event(event_id) + elif current != scene_cache[cache_key]: + scene_cache[cache_key] = current + send_event(event_id, name) changed = True + if update.is_updated_shading and isinstance(uid.data, bpy.types.Camera): + for event_id, current in get_camera_props(uid).items(): + cache_key = (event_id, uid.data.name) + if cache_key not in scene_cache: + scene_cache[cache_key] = current + elif current != scene_cache[cache_key]: + scene_cache[cache_key] = current + send_event(event_id, name) + changed = True + + elif isinstance(uid, bpy.types.Scene): + for obj in bpy.data.objects: + visibility_key = ('obj_visibility', obj.name) + current = not obj.hide_get() + if visibility_key not in scene_cache: + scene_cache[visibility_key] = current + elif current != scene_cache[visibility_key]: + scene_cache[visibility_key] = current + send_event('obj_visibility', obj.name) + changed = True + + elif isinstance(uid, bpy.types.World): + clear_color_key = ('world_clear_color', uid.name) + current = get_world_clear_color() + if clear_color_key not in scene_cache: + scene_cache[clear_color_key] = current + elif current != scene_cache[clear_color_key]: + scene_cache[clear_color_key] = current + send_event('world_clear_color') + changed = True + return changed @@ -189,47 +292,134 @@ def send_event(event_id: str, opt_data: Any = None): if not __running: return - if hasattr(bpy.context, 'object') and bpy.context.object is not None: - obj = bpy.context.object.name + obj_name = opt_data if isinstance(opt_data, str) else None + if obj_name is not None: + obj_data = bpy.data.objects.get(obj_name) + elif hasattr(bpy.context, 'object') and bpy.context.object is not None: + obj_data = bpy.context.object + obj_name = obj_data.name + else: + obj_data = None - if bpy.context.object.mode == "OBJECT": - if event_id == "obj_location": - vec = bpy.context.object.location - js = f'var o = iron.Scene.active.getChild("{obj}"); o.transform.loc.set({vec[0]}, {vec[1]}, {vec[2]}); o.transform.dirty = true;' - write_patch(js) + if obj_data is not None and obj_data.mode == "OBJECT": + if event_id == "obj_location": + vec = obj_data.location + js = f'var o = iron.Scene.active.getChild("{obj_name}"); if (o) {{ o.transform.loc.set({vec[0]}, {vec[1]}, {vec[2]}); o.transform.dirty = true; }}' + write_patch(js) - elif event_id == 'obj_scale': - vec = bpy.context.object.scale - js = f'var o = iron.Scene.active.getChild("{obj}"); o.transform.scale.set({vec[0]}, {vec[1]}, {vec[2]}); o.transform.dirty = true;' - write_patch(js) + elif event_id == 'obj_scale': + vec = obj_data.scale + js = f'var o = iron.Scene.active.getChild("{obj_name}"); if (o) {{ o.transform.scale.set({vec[0]}, {vec[1]}, {vec[2]}); o.transform.dirty = true; }}' + write_patch(js) - elif event_id == 'obj_rotation': - vec = bpy.context.object.rotation_euler.to_quaternion() - js = f'var o = iron.Scene.active.getChild("{obj}"); o.transform.rot.set({vec[1]}, {vec[2]}, {vec[3]}, {vec[0]}); o.transform.dirty = true;' - write_patch(js) + elif event_id == 'obj_rotation': + vec = obj_data.rotation_euler.to_quaternion() + js = f'var o = iron.Scene.active.getChild("{obj_name}"); if (o) {{ o.transform.rot.set({vec[1]}, {vec[2]}, {vec[3]}, {vec[0]}); o.transform.dirty = true; }}' + write_patch(js) - elif event_id == 'light_color': - light: bpy.types.Light = bpy.context.object.data - vec = light.color - js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.color[0]={vec[0]}; lRaw.color[1]={vec[1]}; lRaw.color[2]={vec[2]};' - write_patch(js) + elif event_id == 'light_color': + light: bpy.types.Light = obj_data.data + vec = light.color + js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.color[0]={vec[0]}; lRaw.color[1]={vec[1]}; lRaw.color[2]={vec[2]};' + write_patch(js) - elif event_id == 'light_energy': - light: bpy.types.Light = bpy.context.object.data + elif event_id == 'light_energy': + light: bpy.types.Light = obj_data.data + # Align strength to Leenkx, see exporter.export_light() + # TODO: Use exporter.export_light() and simply reload all raw light data in Iron? + strength_fac = 1.0 + if light.type == 'SUN': + strength_fac = 0.325 + elif light.type in ('POINT', 'SPOT', 'AREA'): + strength_fac = 0.01 + js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.strength={light.energy * strength_fac};' + write_patch(js) - # Align strength to Leenkx, see exporter.export_light() - # TODO: Use exporter.export_light() and simply reload all raw light data in Iron? - strength_fac = 1.0 - if light.type == 'SUN': - strength_fac = 0.325 - elif light.type in ('POINT', 'SPOT', 'AREA'): - strength_fac = 0.01 + elif event_id == 'light_shadow': + light: bpy.types.Light = obj_data.data + js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.cast_shadow={"true" if light.use_shadow else "false"};' + write_patch(js) - js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.strength={light.energy * strength_fac};' - write_patch(js) + elif event_id == 'light_near': + light: bpy.types.Light = obj_data.data + js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.near_plane={light.lnx_clip_start};' + write_patch(js) - else: - patch_export() + elif event_id == 'light_far': + light: bpy.types.Light = obj_data.data + js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.far_plane={light.lnx_clip_end};' + write_patch(js) + + elif event_id == 'light_fov': + light: bpy.types.Light = obj_data.data + js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.fov={light.lnx_fov};' + write_patch(js) + + elif event_id == 'light_bias': + light: bpy.types.Light = obj_data.data + js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.shadows_bias={light.lnx_shadows_bias * 0.0001};' + write_patch(js) + + elif event_id == 'light_spot_size': + light: bpy.types.Light = obj_data.data + half_angle = light.spot_size * 0.5 + outer_cos = math.cos(half_angle) + js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.spot_size={outer_cos};' + write_patch(js) + + elif event_id == 'light_spot_blend': + light: bpy.types.Light = obj_data.data + half_angle = light.spot_size * 0.5 + outer_cos = math.cos(half_angle) + blend = max(0.0, min(1.0, light.spot_blend)) + inner_angle = math.atan(math.tan(half_angle) * (1.0 - blend)) + spot_blend = max(0.0001, math.cos(inner_angle) - outer_cos) + js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.spot_blend={spot_blend};' + write_patch(js) + + elif event_id == 'light_size': + light: bpy.types.Light = obj_data.data + js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.light_size={light.shadow_soft_size * 10};' + write_patch(js) + + elif event_id == 'light_area_size': + light: bpy.types.Light = obj_data.data + js = f'var lRaw = iron.Scene.active.getLight("{light.name}").data.raw; lRaw.size={light.size}; lRaw.size_y={light.size_y};' + write_patch(js) + + elif event_id == 'cam_props': + cam: bpy.types.Camera = obj_data.data + render = bpy.context.scene.render + projection = obj_data.calc_matrix_camera( + bpy.context.evaluated_depsgraph_get(), + x=render.resolution_x, + y=render.resolution_y, + scale_x=render.pixel_aspect_x, + scale_y=render.pixel_aspect_y) + focal_y = projection[1][1] + depth_z = projection[2][2] + depth_w = projection[2][3] + far_near_ratio = (depth_z - 1.0) / (depth_z + 1.0) + fov = 2.0 * math.atan(1.0 / focal_y) + near = (depth_w * (1.0 - far_near_ratio)) / (2.0 * far_near_ratio) + far = far_near_ratio * near + cam_obj = f'iron.Scene.active.getCamera("{cam.name}")' + js = f'var c = {cam_obj}; var cRaw = c.data.raw; cRaw.fov={fov}; cRaw.near_plane={near}; cRaw.far_plane={far}; cRaw.frustum_culling={"true" if cam.lnx_frustum_culling else "false"}; c.buildProjection();' + write_patch(js) + + elif event_id == 'obj_visibility': + visible = not obj_data.hide_get() + js = f'var o = iron.Scene.active.getChild("{obj_name}"); if (o) o.visible = {"true" if visible else "false"};' + write_patch(js) + + elif obj_data is not None: + patch_export() + + if event_id == 'world_clear_color': + color, strength = get_world_clear_color() + bg_int = (int(color[3] * 255) << 24) + (int(color[0] * 255) << 16) + (int(color[1] * 255) << 8) + int(color[2] * 255) + js = f'var w = iron.Scene.active.world; if (w) {{ w.raw.background_color={bg_int}; if (w.probe) w.probe.raw.strength={strength}; }}' + write_patch(js) if event_id == 'ln_insert_link': node: LnxLogicTreeNode @@ -414,6 +604,12 @@ def on_operator(operator_id: str): send_event('obj_rotation') elif operator_id == 'TRANSFORM_OT_resize': send_event('obj_scale') + elif operator_id in ('OBJECT_OT_hide_view_set', 'OBJECT_OT_hide_view_clear', 'OBJECT_OT_visible_all', 'OUTLINER_OT_hide'): + visible = operator_id != 'OBJECT_OT_hide_view_set' + if hasattr(bpy.context, 'object') and bpy.context.object is not None: + obj_name = bpy.context.object.name + js = f'var o = iron.Scene.active.getChild("{obj_name}"); if (o) o.visible = {"true" if visible else "false"};' + write_patch(js) # Rebuild else: