Files
LNXSDK/leenkx/blender/lnx/live_patch.py

648 lines
26 KiB
Python
Raw Normal View History

2025-01-22 16:18:30 +01:00
import os
import shutil
import math
2025-01-22 16:18:30 +01:00
from typing import Any, Type
import bpy
import lnx.assets
from lnx import log, make
from lnx.exporter import LeenkxExporter
from lnx.logicnode.lnx_nodes import LnxLogicTreeNode
import lnx.make_state as state
import lnx.node_utils
import lnx.utils
if lnx.is_reload(__name__):
lnx.assets = lnx.reload_module(lnx.assets)
lnx.exporter = lnx.reload_module(lnx.exporter)
from lnx.exporter import LeenkxExporter
log = lnx.reload_module(log)
lnx.logicnode.lnx_nodes = lnx.reload_module(lnx.logicnode.lnx_nodes)
from lnx.logicnode.lnx_nodes import LnxLogicTreeNode
make = lnx.reload_module(make)
state = lnx.reload_module(state)
lnx.node_utils = lnx.reload_module(lnx.node_utils)
lnx.utils = lnx.reload_module(lnx.utils)
else:
lnx.enable_reload(__name__)
patch_id = 0
"""Current patch id"""
__running = False
"""Whether live patch is currently active"""
2026-07-18 19:49:25 -07:00
scene_cache = {}
"""Keys: (event_id, object_name) -> cached value tuple"""
2025-01-22 16:18:30 +01:00
# Any object can act as a message bus owner
msgbus_owner = object()
def start():
"""Start the live patch session."""
log.debug("Live patch session started")
listen(bpy.types.Object, "location", "obj_location")
listen(bpy.types.Object, "rotation_euler", "obj_rotation")
listen(bpy.types.Object, "scale", "obj_scale")
# 'energy' is defined in sub classes only, also workaround for
# https://developer.blender.org/T88408
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")
2025-01-22 16:18:30 +01:00
global __running
__running = True
def stop():
"""Stop the live patch session."""
global __running, patch_id
if __running:
__running = False
patch_id = 0
log.debug("Live patch session stopped")
bpy.msgbus.clear_by_owner(msgbus_owner)
2026-07-18 19:49:25 -07:00
def get_obj_transforms(obj):
loc = (round(obj.location.x, 6), round(obj.location.y, 6), round(obj.location.z, 6))
rot = obj.rotation_euler.to_quaternion()
rot = (round(rot.x, 6), round(rot.y, 6), round(rot.z, 6), round(rot.w, 6))
scale = (round(obj.scale.x, 6), round(obj.scale.y, 6), round(obj.scale.z, 6))
return loc, rot, scale
def get_light_props(obj):
light = obj.data
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
2026-07-18 19:49:25 -07:00
DEPSGRAPH_HANDLERS = {
'is_updated_transform': [
('obj_location', lambda obj: get_obj_transforms(obj)[0]),
('obj_rotation', lambda obj: get_obj_transforms(obj)[1]),
('obj_scale', lambda obj: get_obj_transforms(obj)[2]),
],
}
def on_depsgraph_update(updates):
if not __running:
return False
changed = False
for update in updates:
uid = update.id
if isinstance(uid, bpy.types.Object):
name = uid.name
if update.is_updated_transform:
for event_id, extractor in DEPSGRAPH_HANDLERS['is_updated_transform']:
current = extractor(uid)
cache_key = (event_id, name)
if cache_key not in scene_cache:
2026-07-18 19:49:25 -07:00
scene_cache[cache_key] = current
elif current != scene_cache[cache_key]:
scene_cache[cache_key] = current
send_event(event_id, name)
2026-07-18 19:49:25 -07:00
changed = True
if update.is_updated_shading and isinstance(uid.data, bpy.types.Light):
for event_id, current in get_light_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
if update.is_updated_shading and isinstance(uid.data, bpy.types.Camera):
for event_id, current in get_camera_props(uid).items():
2026-07-18 19:49:25 -07:00
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]:
2026-07-18 19:49:25 -07:00
scene_cache[cache_key] = current
send_event(event_id, name)
2026-07-18 19:49:25 -07:00
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
2026-07-18 19:49:25 -07:00
return changed
2025-01-22 16:18:30 +01:00
def patch_export():
"""Re-export the current scene and update the game accordingly."""
if not __running or state.proc_build is not None:
return
lnx.assets.invalidate_enabled = False
with lnx.utils.WorkingDir(lnx.utils.get_fp()):
asset_path = lnx.utils.get_fp_build() + '/compiled/Assets/' + lnx.utils.safestr(bpy.context.scene.name) + '.lnx'
LeenkxExporter.export_scene(bpy.context, asset_path, scene=bpy.context.scene)
dir_std_shaders_dst = os.path.join(lnx.utils.build_dir(), 'compiled', 'Shaders', 'std')
if not os.path.isdir(dir_std_shaders_dst):
dir_std_shaders_src = os.path.join(lnx.utils.get_sdk_path(), 'leenkx', 'Shaders', 'std')
shutil.copytree(dir_std_shaders_src, dir_std_shaders_dst)
node_path = lnx.utils.get_node_path()
khamake_path = lnx.utils.get_khamake_path()
cmd = [
node_path, khamake_path, 'krom',
'--shaderversion', '330',
'--parallelAssetConversion', '4',
'--to', lnx.utils.build_dir() + '/debug',
'--nohaxe',
'--noproject'
]
lnx.assets.invalidate_enabled = True
state.proc_build = make.run_proc(cmd, patch_done)
def patch_done():
"""Signal Iron to reload the running scene after a re-export."""
js = 'iron.Scene.patch();'
write_patch(js)
state.proc_build = None
def write_patch(js: str):
"""Write the given javascript code to 'krom.patch'."""
global patch_id
with open(lnx.utils.get_fp_build() + '/debug/krom/krom.patch', 'w', encoding='utf-8') as f:
patch_id += 1
f.write(str(patch_id) + '\n')
f.write(js)
def listen(rna_type: Type[bpy.types.bpy_struct], prop: str, event_id: str):
"""Subscribe to '<rna_type>.<prop>'. The event_id can be choosen
freely but must match with the id used in send_event().
"""
bpy.msgbus.subscribe_rna(
key=(rna_type, prop),
owner=msgbus_owner,
args=(event_id, ),
notify=send_event
# options={"PERSISTENT"}
)
def send_event(event_id: str, opt_data: Any = None):
"""Send the result of the given event to Krom."""
if not __running:
return
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 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 = 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 = 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 = 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 = 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)
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)
2025-01-22 16:18:30 +01:00
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)
2025-01-22 16:18:30 +01:00
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)
2025-01-22 16:18:30 +01:00
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)
2025-01-22 16:18:30 +01:00
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)
2025-01-22 16:18:30 +01:00
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)
2025-01-22 16:18:30 +01:00
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)
2025-01-22 16:18:30 +01:00
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)
2025-01-22 16:18:30 +01:00
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)
2025-01-22 16:18:30 +01:00
if event_id == 'ln_insert_link':
node: LnxLogicTreeNode
link: bpy.types.NodeLink
node, link = opt_data
# This event is called twice for a connection but we only need
# send it once
if node == link.from_node:
tree_name = lnx.node_utils.get_export_tree_name(node.get_tree())
# [1:] is used here because make_logic already uses that for
# node names if lnx_debug is used
from_node_name = lnx.node_utils.get_export_node_name(node)[1:]
to_node_name = lnx.node_utils.get_export_node_name(link.to_node)[1:]
from_index = lnx.node_utils.get_socket_index(node.outputs, link.from_socket)
to_index = lnx.node_utils.get_socket_index(link.to_node.inputs, link.to_socket)
js = f'LivePatch.patchCreateNodeLink("{tree_name}", "{from_node_name}", "{to_node_name}", "{from_index}", "{to_index}");'
write_patch(js)
elif event_id == 'ln_update_prop':
node: LnxLogicTreeNode
prop_name: str
node, prop_name = opt_data
tree_name = lnx.node_utils.get_export_tree_name(node.get_tree())
node_name = lnx.node_utils.get_export_node_name(node)[1:]
value = lnx.node_utils.haxe_format_prop_value(node, prop_name)
if prop_name.endswith('_get'):
# Hack because some nodes use a different Python property
# name than they use in Haxe
prop_name = prop_name[:-4]
js = f'LivePatch.patchUpdateNodeProp("{tree_name}", "{node_name}", "{prop_name}", {value});'
write_patch(js)
elif event_id == 'ln_socket_val':
node: LnxLogicTreeNode
socket: bpy.types.NodeSocket
node, socket = opt_data
socket_index = lnx.node_utils.get_socket_index(node.inputs, socket)
if socket_index != -1:
tree_name = lnx.node_utils.get_export_tree_name(node.get_tree())
node_name = lnx.node_utils.get_export_node_name(node)[1:]
value = socket.get_default_value()
inp_type = socket.lnx_socket_type
if inp_type in ('VECTOR', 'RGB'):
value = f'new iron.Vec4({lnx.node_utils.haxe_format_socket_val(value, array_outer_brackets=False)}, 1.0)'
elif inp_type == 'RGBA':
value = f'new iron.Vec4({lnx.node_utils.haxe_format_socket_val(value, array_outer_brackets=False)})'
elif inp_type == 'ROTATION':
value = f'new iron.Quat({lnx.node_utils.haxe_format_socket_val(value, array_outer_brackets=False)})'
elif inp_type == 'OBJECT':
value = f'iron.Scene.active.getChild("{value}")' if value != '' else 'null'
else:
value = lnx.node_utils.haxe_format_socket_val(value)
js = f'LivePatch.patchUpdateNodeInputVal("{tree_name}", "{node_name}", {socket_index}, {value});'
write_patch(js)
elif event_id == 'ln_create':
node: LnxLogicTreeNode = opt_data
tree_name = lnx.node_utils.get_export_tree_name(node.get_tree())
node_name = lnx.node_utils.get_export_node_name(node)[1:]
node_type = 'leenkx.logicnode.' + node.bl_idname[2:]
prop_names = list(lnx.node_utils.get_haxe_property_names(node))
prop_py_names, prop_hx_names = zip(*prop_names) if len(prop_names) > 0 else ([], [])
prop_values = (getattr(node, prop_name) for prop_name in prop_py_names)
prop_datas = lnx.node_utils.haxe_format_socket_val(list(zip(prop_hx_names, prop_values)))
inp_data = [(inp.lnx_socket_type, inp.get_default_value()) for inp in node.inputs]
inp_data = lnx.node_utils.haxe_format_socket_val(inp_data)
out_data = [(out.lnx_socket_type, out.get_default_value()) for out in node.outputs]
out_data = lnx.node_utils.haxe_format_socket_val(out_data)
js = f'LivePatch.patchNodeCreate("{tree_name}", "{node_name}", "{node_type}", {prop_datas}, {inp_data}, {out_data});'
write_patch(js)
elif event_id == 'ln_delete':
node: LnxLogicTreeNode = opt_data
tree_name = lnx.node_utils.get_export_tree_name(node.get_tree())
node_name = lnx.node_utils.get_export_node_name(node)[1:]
js = f'LivePatch.patchNodeDelete("{tree_name}", "{node_name}");'
write_patch(js)
elif event_id == 'ln_copy':
newnode: LnxLogicTreeNode
node: LnxLogicTreeNode
newnode, node = opt_data
# Use newnode to get the tree, node has no id_data at this moment
tree_name = lnx.node_utils.get_export_tree_name(newnode.get_tree())
newnode_name = lnx.node_utils.get_export_node_name(newnode)[1:]
node_name = lnx.node_utils.get_export_node_name(node)[1:]
props_list = '[' + ','.join(f'"{p}"' for _, p in lnx.node_utils.get_haxe_property_names(node)) + ']'
inp_data = [(inp.lnx_socket_type, inp.get_default_value()) for inp in newnode.inputs]
inp_data = lnx.node_utils.haxe_format_socket_val(inp_data)
out_data = [(out.lnx_socket_type, out.get_default_value()) for out in newnode.outputs]
out_data = lnx.node_utils.haxe_format_socket_val(out_data)
js = f'LivePatch.patchNodeCopy("{tree_name}", "{node_name}", "{newnode_name}", {props_list}, {inp_data}, {out_data});'
write_patch(js)
elif event_id == 'ln_update_sockets':
node: LnxLogicTreeNode = opt_data
tree_name = lnx.node_utils.get_export_tree_name(node.get_tree())
node_name = lnx.node_utils.get_export_node_name(node)[1:]
inp_data = '['
for idx, inp in enumerate(node.inputs):
inp_data += '{'
# is_linked can be true even if there are no links if the
# user starts dragging a connection away before releasing
# the mouse
if inp.is_linked and len(inp.links) > 0:
inp_data += 'isLinked: true,'
inp_data += f'fromNode: "{lnx.node_utils.get_export_node_name(inp.links[0].from_node)[1:]}",'
inp_data += f'fromIndex: {lnx.node_utils.get_socket_index(inp.links[0].from_node.outputs, inp.links[0].from_socket)},'
else:
inp_data += 'isLinked: false,'
inp_data += f'socketType: "{inp.lnx_socket_type}",'
inp_data += f'socketValue: {lnx.node_utils.haxe_format_socket_val(inp.get_default_value())},'
inp_data += f'toIndex: {idx}'
inp_data += '},'
inp_data += ']'
out_data = '['
for idx, out in enumerate(node.outputs):
out_data += '['
for link in out.links:
out_data += '{'
if out.is_linked:
out_data += 'isLinked: true,'
out_data += f'toNode: "{lnx.node_utils.get_export_node_name(link.to_node)[1:]}",'
out_data += f'toIndex: {lnx.node_utils.get_socket_index(link.to_node.inputs, link.to_socket)},'
else:
out_data += 'isLinked: false,'
out_data += f'socketType: "{out.lnx_socket_type}",'
out_data += f'socketValue: {lnx.node_utils.haxe_format_socket_val(out.get_default_value())},'
out_data += f'fromIndex: {idx}'
out_data += '},'
out_data += '],'
out_data += ']'
js = f'LivePatch.patchSetNodeLinks("{tree_name}", "{node_name}", {inp_data}, {out_data});'
write_patch(js)
def on_operator(operator_id: str):
"""As long as bpy.msgbus doesn't listen to changes made by
operators (*), additionally notify the callback manually.
(*) https://developer.blender.org/T72109
"""
if not __running:
return
if operator_id in IGNORE_OPERATORS:
return
if operator_id == 'TRANSFORM_OT_translate':
send_event('obj_location')
elif operator_id in ('TRANSFORM_OT_rotate', 'TRANSFORM_OT_trackball'):
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)
2025-01-22 16:18:30 +01:00
# Rebuild
else:
patch_export()
# Don't re-export the scene for the following operators
IGNORE_OPERATORS = (
'LNX_OT_node_add_input',
'LNX_OT_node_add_input_output',
'LNX_OT_node_add_input_value',
'LNX_OT_node_add_output',
'LNX_OT_node_call_func',
'LNX_OT_node_remove_input',
'LNX_OT_node_remove_input_output',
'LNX_OT_node_remove_input_value',
'LNX_OT_node_remove_output',
'LNX_OT_node_search',
'NODE_OT_delete',
'NODE_OT_duplicate_move',
'NODE_OT_hide_toggle',
'NODE_OT_link',
'NODE_OT_move_detach_links',
'NODE_OT_select',
'NODE_OT_translate_attach',
'NODE_OT_translate_attach_remove_on_cancel',
'OBJECT_OT_editmode_toggle',
'OUTLINER_OT_item_activate',
'UI_OT_button_string_clear',
'UI_OT_eyedropper_id',
'VIEW3D_OT_select',
'VIEW3D_OT_select_box',
)