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

648 lines
26 KiB
Python

import os
import shutil
import math
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"""
scene_cache = {}
"""Keys: (event_id, object_name) -> cached value tuple"""
# 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")
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)
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
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:
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.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():
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
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)
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)
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
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)
# 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',
)