From 1019179b8d4e2e401cad3b8b63c460ab62247d8a Mon Sep 17 00:00:00 2001 From: Rowland <975945824@qq.com> Date: Mon, 25 May 2026 14:07:58 +0800 Subject: [PATCH] =?UTF-8?q?LUI=E6=B7=BB=E5=8A=A03D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RenderPipelineFile/effects/lui_world_ui.yaml | 28 + imgui.ini | 24 +- main.py | 10 + ssbo_component/ssbo_editor.py | 4 + ui/LUI/lui_function_components.py | 8 + ui/LUI/lui_manager_editor.py | 261 ++- ui/LUI/lui_manager_interaction.py | 80 +- ui/LUI/lui_world_space.py | 1608 ++++++++++++++++++ ui/lui_manager.py | 231 +++ 9 files changed, 2231 insertions(+), 23 deletions(-) create mode 100644 RenderPipelineFile/effects/lui_world_ui.yaml create mode 100644 ui/LUI/lui_world_space.py diff --git a/RenderPipelineFile/effects/lui_world_ui.yaml b/RenderPipelineFile/effects/lui_world_ui.yaml new file mode 100644 index 00000000..2fb21939 --- /dev/null +++ b/RenderPipelineFile/effects/lui_world_ui.yaml @@ -0,0 +1,28 @@ +# Scene-space effect for world-space LUI canvas textures. + +fragment: + defines: | + #define DONT_FETCH_DEFAULT_TEXTURES 1 + #define DONT_SET_MATERIAL_PROPERTIES 1 + + inout: | + uniform sampler2D p3d_Texture0; + + material: | + vec4 lui_color = texture(p3d_Texture0, texcoord); + if (lui_color.a <= 0.001) { + discard; + } + + #if IN_FORWARD_SHADER + color_result = lui_color; + return; + #else + m.shading_model = SHADING_MODEL_DEFAULT; + m.basecolor = lui_color.rgb; + m.normal = normalize(vOutput.normal); + m.roughness = 0.65; + m.specular_ior = 1.45; + m.metallic = 0.0; + m.shading_model_param0 = 0.0; + #endif diff --git a/imgui.ini b/imgui.ini index 6ed26f3e..1ff39d32 100644 --- a/imgui.ini +++ b/imgui.ini @@ -31,25 +31,25 @@ DockId=0x0000000D,0 [Window][场景树] Pos=0,20 -Size=339,996 +Size=339,1023 Collapsed=0 DockId=0x00000007,0 [Window][属性面板] -Pos=1504,20 -Size=346,498 +Pos=1574,20 +Size=346,512 Collapsed=0 DockId=0x00000003,0 [Window][控制台] -Pos=341,617 -Size=1161,399 +Pos=341,644 +Size=1231,399 Collapsed=0 DockId=0x00000006,1 [Window][脚本管理] -Pos=1504,520 -Size=346,496 +Pos=1574,534 +Size=346,509 Collapsed=0 DockId=0x00000004,0 @@ -60,7 +60,7 @@ Collapsed=0 [Window][WindowOverViewport_11111111] Pos=0,20 -Size=1850,996 +Size=1920,1023 Collapsed=0 [Window][测试窗口1] @@ -99,8 +99,8 @@ Size=600,500 Collapsed=0 [Window][资源管理器] -Pos=341,617 -Size=1161,399 +Pos=341,644 +Size=1231,399 Collapsed=0 DockId=0x00000006,0 @@ -150,7 +150,7 @@ Size=101,226 Collapsed=0 [Window][LUI编辑器] -Pos=160,44 +Pos=20,40 Size=346,1331 Collapsed=0 @@ -225,7 +225,7 @@ Size=460,260 Collapsed=0 [Docking][Data] -DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1850,996 Split=X +DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1920,1023 Split=X DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=2212,1012 Split=X DockNode ID=0x00000007 Parent=0x00000001 SizeRef=339,1084 Selected=0xE0015051 DockNode ID=0x00000008 Parent=0x00000001 SizeRef=1871,1084 Split=Y diff --git a/main.py b/main.py index 86f28261..7c54d0b2 100644 --- a/main.py +++ b/main.py @@ -836,6 +836,10 @@ class MyWorld(PanelDelegates, CoreWorld): if imgui_captured: print("❌ ImGui处理了该事件,跳过3D场景选择") return + + if hasattr(self, 'lui_manager') and self.lui_manager.handle_world_space_pointer_press(): + print("✓ LUI 3D UI 处理了该事件,跳过3D场景选择") + return # 调用事件处理器进行射线检测和选择 if hasattr(self, 'event_handler'): @@ -867,6 +871,12 @@ class MyWorld(PanelDelegates, CoreWorld): winWidth, winHeight = self.win.getSize() window_x = (mouse_x + 1) * 0.5 * winWidth window_y = (1 - mouse_y) * 0.5 * winHeight + + if hasattr(self, 'lui_manager') and ( + self.lui_manager.is_world_space_pointer_over_ui() + or self.lui_manager.has_world_space_pointer_capture() + ): + return # 调用事件处理器 if hasattr(self, 'event_handler'): diff --git a/ssbo_component/ssbo_editor.py b/ssbo_component/ssbo_editor.py index 34ac4d62..bdd79bf3 100644 --- a/ssbo_component/ssbo_editor.py +++ b/ssbo_component/ssbo_editor.py @@ -2584,6 +2584,10 @@ class SSBOEditor: process_imgui_click = getattr(self.base, "processImGuiMouseClick", None) if callable(process_imgui_click) and process_imgui_click(window_x, window_y): return + lui_manager = getattr(self.base, "lui_manager", None) + handle_lui_world = getattr(lui_manager, "handle_world_space_pointer_press", None) + if callable(handle_lui_world) and handle_lui_world(): + return except Exception: pass self._sync_pick_model_transform() diff --git a/ui/LUI/lui_function_components.py b/ui/LUI/lui_function_components.py index 5d289b26..747e090f 100644 --- a/ui/LUI/lui_function_components.py +++ b/ui/LUI/lui_function_components.py @@ -81,6 +81,14 @@ class LUIFunctionComponentsMixin: 'panel': canvas_panel, 'visible': True, 'background': bg, + 'render_mode': 'screen', + 'world_position': (0.0, 8.0, 2.0), + 'world_hpr': (25.0, 0.0, 0.0), + 'world_scale': (1.0, 1.0, 1.0), + 'world_pixels_per_unit': 280.0, + 'world_depth_test': True, + 'world_interactive': True, + 'world_two_sided': False, 'fill_mode': True, # 默认开启填充模式 'margins': { 'left': 240, diff --git a/ui/LUI/lui_manager_editor.py b/ui/LUI/lui_manager_editor.py index 79e009f3..5adc99f9 100644 --- a/ui/LUI/lui_manager_editor.py +++ b/ui/LUI/lui_manager_editor.py @@ -102,9 +102,74 @@ class LUIManagerEditorMixin: self._lui_structure_edit_sessions = {} return self._lui_structure_edit_sessions + def _triplet_or_default(self, value, fallback): + if not isinstance(value, (list, tuple)) or len(value) < 3: + return tuple(fallback) + out = [] + for idx, default in enumerate(fallback): + try: + out.append(float(value[idx])) + except Exception: + out.append(float(default)) + return tuple(out) + + def _ensure_canvas_world_defaults(self, canvas_data): + if not canvas_data: + return + canvas_data["render_mode"] = str(canvas_data.get("render_mode") or "screen").lower().strip() + if canvas_data["render_mode"] != "world": + canvas_data["render_mode"] = "screen" + canvas_data.setdefault("world_position", (0.0, 8.0, 2.0)) + canvas_data.setdefault("world_hpr", (25.0, 0.0, 0.0)) + canvas_data.setdefault("world_scale", (1.0, 1.0, 1.0)) + canvas_data.setdefault("world_pixels_per_unit", 280.0) + canvas_data.setdefault("world_depth_test", True) + canvas_data.setdefault("world_interactive", True) + canvas_data.setdefault("world_two_sided", False) + + def _copy_canvas_node_transform_to_canvas(self, canvas_data): + node = (canvas_data or {}).get("node") + if node is None: + return + try: + if hasattr(node, "isEmpty") and node.isEmpty(): + return + pos = node.getPos() + hpr = node.getHpr() + scale = node.getScale() + canvas_data["world_position"] = (float(pos.x), float(pos.y), float(pos.z)) + canvas_data["world_hpr"] = (float(hpr.x), float(hpr.y), float(hpr.z)) + canvas_data["world_scale"] = (float(scale.x), float(scale.y), float(scale.z)) + except Exception: + pass + + def _apply_canvas_world_transform(self, canvas_data): + node = (canvas_data or {}).get("node") + if node is None: + return + try: + if hasattr(node, "isEmpty") and node.isEmpty(): + return + node.setPos(*self._triplet_or_default(canvas_data.get("world_position"), (0.0, 8.0, 2.0))) + node.setHpr(*self._triplet_or_default(canvas_data.get("world_hpr"), (25.0, 0.0, 0.0))) + node.setScale(*self._triplet_or_default(canvas_data.get("world_scale"), (1.0, 1.0, 1.0))) + if canvas_data.get("render_mode") == "world": + node.setTag("is_lui_canvas", "1") + node.setTag("is_scene_element", "1") + node.setTag("tree_item_type", "SCENE_NODE") + else: + for tag_name in ("is_lui_canvas", "is_scene_element", "tree_item_type"): + if node.hasTag(tag_name): + node.clearTag(tag_name) + except Exception: + pass + def _capture_canvas_snapshot(self, canvas_data): if not canvas_data: return None + self._ensure_canvas_world_defaults(canvas_data) + if canvas_data.get("render_mode") == "world": + self._copy_canvas_node_transform_to_canvas(canvas_data) panel = canvas_data.get("panel") background = canvas_data.get("background") @@ -121,6 +186,10 @@ class LUIManagerEditorMixin: top = float(pos.y) except Exception: pass + try: + world_pixels_per_unit = float(canvas_data.get("world_pixels_per_unit", 280.0) or 280.0) + except Exception: + world_pixels_per_unit = 280.0 return { "name": canvas_data.get("name"), @@ -128,6 +197,14 @@ class LUIManagerEditorMixin: "fill_mode": bool(canvas_data.get("fill_mode", False)), "fixed": bool(canvas_data.get("fixed", False)), "margins": copy.deepcopy(canvas_data.get("margins", {})), + "render_mode": canvas_data.get("render_mode", "screen"), + "world_position": self._triplet_or_default(canvas_data.get("world_position"), (0.0, 8.0, 2.0)), + "world_hpr": self._triplet_or_default(canvas_data.get("world_hpr"), (25.0, 0.0, 0.0)), + "world_scale": self._triplet_or_default(canvas_data.get("world_scale"), (1.0, 1.0, 1.0)), + "world_pixels_per_unit": world_pixels_per_unit, + "world_depth_test": bool(canvas_data.get("world_depth_test", True)), + "world_interactive": bool(canvas_data.get("world_interactive", True)), + "world_two_sided": bool(canvas_data.get("world_two_sided", False)), "left": left, "top": top, "width": width, @@ -229,6 +306,12 @@ class LUIManagerEditorMixin: self._hide_resize_handles() except Exception: pass + runtime = getattr(self, "world_space_runtime", None) + if runtime is not None: + try: + runtime.clear() + except Exception: + pass if hasattr(self, "debug_outlines") and isinstance(self.debug_outlines, dict): for borders in list(self.debug_outlines.values()): @@ -315,11 +398,42 @@ class LUIManagerEditorMixin: canvas_data["fixed"] = bool(snapshot.get("fixed", canvas_data.get("fixed", False))) canvas_data["visible"] = bool(snapshot.get("visible", canvas_data.get("visible", True))) canvas_data["margins"] = copy.deepcopy(snapshot.get("margins", canvas_data.get("margins", {}))) + canvas_data["render_mode"] = str(snapshot.get("render_mode", canvas_data.get("render_mode", "screen")) or "screen").lower().strip() + if canvas_data["render_mode"] != "world": + canvas_data["render_mode"] = "screen" + canvas_data["world_position"] = self._triplet_or_default( + snapshot.get("world_position", canvas_data.get("world_position")), + (0.0, 8.0, 2.0), + ) + canvas_data["world_hpr"] = self._triplet_or_default( + snapshot.get("world_hpr", canvas_data.get("world_hpr")), + (25.0, 0.0, 0.0), + ) + canvas_data["world_scale"] = self._triplet_or_default( + snapshot.get("world_scale", canvas_data.get("world_scale")), + (1.0, 1.0, 1.0), + ) + try: + canvas_data["world_pixels_per_unit"] = float( + snapshot.get("world_pixels_per_unit", canvas_data.get("world_pixels_per_unit", 280.0)) or 280.0 + ) + except Exception: + canvas_data["world_pixels_per_unit"] = 280.0 + canvas_data["world_depth_test"] = bool( + snapshot.get("world_depth_test", canvas_data.get("world_depth_test", True)) + ) + canvas_data["world_interactive"] = bool( + snapshot.get("world_interactive", canvas_data.get("world_interactive", True)) + ) + canvas_data["world_two_sided"] = bool( + snapshot.get("world_two_sided", canvas_data.get("world_two_sided", False)) + ) panel = canvas_data.get("panel") background = canvas_data.get("background") title_label = canvas_data.get("title_label") canvas_node = canvas_data.get("node") + self._apply_canvas_world_transform(canvas_data) if canvas_node is not None and hasattr(canvas_node, "setName"): try: @@ -366,9 +480,12 @@ class LUIManagerEditorMixin: pass try: - if canvas_data["visible"] and hasattr(panel, "show"): + screen_visible = canvas_data["visible"] and canvas_data.get("render_mode") != "world" + if hasattr(self, "_set_lui_canvas_screen_visible"): + self._set_lui_canvas_screen_visible(canvas_data, screen_visible) + elif screen_visible and hasattr(panel, "show"): panel.show() - elif not canvas_data["visible"] and hasattr(panel, "hide"): + elif (not screen_visible) and hasattr(panel, "hide"): panel.hide() except Exception: pass @@ -402,6 +519,9 @@ class LUIManagerEditorMixin: except Exception: pass + if hasattr(self, "invalidate_world_space_canvas"): + self.invalidate_world_space_canvas(canvas_index) + def _compute_lui_structure_absolute_position(self, index, component_snapshots, memo=None): if memo is None: memo = {} @@ -1554,6 +1674,136 @@ class LUIManagerEditorMixin: # Recurse for grandchildren self._sync_canvas_children(child_idx) + def _set_canvas_render_mode(self, canvas, render_mode): + if not canvas: + return + mode = "world" if str(render_mode).lower().strip() == "world" else "screen" + self._ensure_canvas_world_defaults(canvas) + canvas["render_mode"] = mode + if mode == "world": + current_hpr = self._triplet_or_default(canvas.get("world_hpr"), (0.0, 0.0, 0.0)) + if not canvas.get("_world_hpr_user_set") and all(abs(float(v)) < 1e-6 for v in current_hpr): + canvas["world_hpr"] = (25.0, 0.0, 0.0) + self._apply_canvas_world_transform(canvas) + if hasattr(self, "invalidate_world_space_canvas"): + try: + self.invalidate_world_space_canvas(self.canvases.index(canvas)) + except Exception: + self.invalidate_world_space_canvas() + if 0 <= getattr(self, "current_canvas_index", -1) < len(getattr(self, "canvases", [])): + self.switch_canvas(self.current_canvas_index) + runtime = getattr(self, "world_space_runtime", None) + if runtime is not None: + try: + runtime.sync() + except Exception: + pass + if hasattr(self.world, "updateSceneTree"): + try: + self.world.updateSceneTree() + except Exception: + pass + + def _draw_canvas_render_mode_controls(self, canvas): + self._ensure_canvas_world_defaults(canvas) + mode_labels = ["屏幕 (Screen)", "场景3D (World)"] + current_mode = canvas.get("render_mode", "screen") + current_mode_index = 1 if current_mode == "world" else 0 + changed, new_mode_index = imgui.combo("渲染模式", current_mode_index, mode_labels) + if changed: + new_mode = "world" if new_mode_index == 1 else "screen" + self._apply_lui_structure_change_with_history( + lambda target_mode=new_mode: self._set_canvas_render_mode(canvas, target_mode) + ) + + if canvas.get("render_mode") == "world": + self._draw_world_canvas_controls(canvas) + + def _draw_world_canvas_controls(self, canvas): + self._copy_canvas_node_transform_to_canvas(canvas) + imgui.separator() + imgui.text("场景3D设置") + + pos = self._triplet_or_default(canvas.get("world_position"), (0.0, 8.0, 2.0)) + changed, new_pos = imgui.drag_float3("3D位置", pos, 0.05, -10000.0, 10000.0) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_world_position") + if changed: + canvas["world_position"] = tuple(new_pos) + self._apply_canvas_world_transform(canvas) + if hasattr(self, "invalidate_world_space_canvas"): + self.invalidate_world_space_canvas(self.current_canvas_index) + self._finish_lui_structure_snapshot_session("canvas_world_position") + + hpr = self._triplet_or_default(canvas.get("world_hpr"), (25.0, 0.0, 0.0)) + changed, new_hpr = imgui.drag_float3("3D旋转(HPR)", hpr, 0.5, -360.0, 360.0) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_world_hpr") + if changed: + canvas["world_hpr"] = tuple(new_hpr) + canvas["_world_hpr_user_set"] = True + self._apply_canvas_world_transform(canvas) + if hasattr(self, "invalidate_world_space_canvas"): + self.invalidate_world_space_canvas(self.current_canvas_index) + self._finish_lui_structure_snapshot_session("canvas_world_hpr") + + scale = self._triplet_or_default(canvas.get("world_scale"), (1.0, 1.0, 1.0)) + changed, new_scale = imgui.drag_float3("3D缩放", scale, 0.01, 0.01, 100.0) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_world_scale") + if changed: + canvas["world_scale"] = tuple(max(0.01, float(v)) for v in new_scale) + self._apply_canvas_world_transform(canvas) + if hasattr(self, "invalidate_world_space_canvas"): + self.invalidate_world_space_canvas(self.current_canvas_index) + self._finish_lui_structure_snapshot_session("canvas_world_scale") + + ppu = float(canvas.get("world_pixels_per_unit", 280.0) or 280.0) + changed, new_ppu = imgui.drag_float("像素/单位", ppu, 1.0, 10.0, 4000.0) + if imgui.is_item_activated(): + self._begin_lui_structure_snapshot_session("canvas_world_ppu") + if changed: + canvas["world_pixels_per_unit"] = max(10.0, float(new_ppu)) + if hasattr(self, "invalidate_world_space_canvas"): + self.invalidate_world_space_canvas(self.current_canvas_index) + self._finish_lui_structure_snapshot_session("canvas_world_ppu") + + depth_test = bool(canvas.get("world_depth_test", True)) + changed, new_depth_test = imgui.checkbox("启用深度测试", depth_test) + if changed: + self._apply_lui_structure_change_with_history( + lambda value=new_depth_test: canvas.__setitem__("world_depth_test", bool(value)) + ) + if hasattr(self, "invalidate_world_space_canvas"): + self.invalidate_world_space_canvas(self.current_canvas_index) + + interactive = bool(canvas.get("world_interactive", True)) + changed, new_interactive = imgui.checkbox("启用3D交互", interactive) + if changed: + self._apply_lui_structure_change_with_history( + lambda value=new_interactive: canvas.__setitem__("world_interactive", bool(value)) + ) + + two_sided = bool(canvas.get("world_two_sided", False)) + changed, new_two_sided = imgui.checkbox("双面显示", two_sided) + if changed: + self._apply_lui_structure_change_with_history( + lambda value=new_two_sided: canvas.__setitem__("world_two_sided", bool(value)) + ) + if hasattr(self, "invalidate_world_space_canvas"): + self.invalidate_world_space_canvas(self.current_canvas_index) + + debug_world_lui = bool(getattr(self, "debug_world_lui", False)) + changed, new_debug_world_lui = imgui.checkbox("输出3D LUI调试日志", debug_world_lui) + if changed: + self.debug_world_lui = bool(new_debug_world_lui) + runtime = getattr(self, "world_space_runtime", None) + if runtime is not None: + try: + runtime._debug_force_next = True + except Exception: + pass + def draw_editor(self): """Draw the LUI Editor Window""" if hasattr(self, "world") and hasattr(self.world, "showLUIEditor"): @@ -1651,10 +1901,15 @@ class LUIManagerEditorMixin: self._apply_lui_structure_change_with_history( lambda: ( canvas.__setitem__('visible', visible), - panel.show() if visible else panel.hide(), + self._set_lui_canvas_screen_visible( + canvas, + visible and self._canvas_render_mode(canvas) != "world", + ) if hasattr(self, "_set_lui_canvas_screen_visible") + else (panel.show() if visible else panel.hide()), ) ) + self._draw_canvas_render_mode_controls(canvas) # Fill Mode & Margins is_fill_mode = canvas.get('fill_mode', False) diff --git a/ui/LUI/lui_manager_interaction.py b/ui/LUI/lui_manager_interaction.py index 9cd8dea9..8a16b568 100644 --- a/ui/LUI/lui_manager_interaction.py +++ b/ui/LUI/lui_manager_interaction.py @@ -69,14 +69,29 @@ class LUIManagerInteractionMixin: self.current_canvas_index = index - # 显示选中的Canvas,隐藏其他Canvas + # 显示选中的Canvas,隐藏其他Canvas。场景3D Canvas 只显示场景镜像, + # 原始 overlay 仍作为编辑数据源,但不直接绘制在屏幕上。 for i, canvas in enumerate(self.canvases): + render_mode = "screen" + try: + render_mode = self._canvas_render_mode(canvas) + except Exception: + render_mode = str(canvas.get("render_mode") or "screen").lower().strip() + screen_visible = (i == index and render_mode != "world") if i == index: - canvas['panel'].show() canvas['visible'] = True else: - canvas['panel'].hide() canvas['visible'] = False + if hasattr(self, "_set_lui_canvas_screen_visible"): + self._set_lui_canvas_screen_visible(canvas, screen_visible) + else: + try: + if screen_visible: + canvas['panel'].show() + else: + canvas['panel'].hide() + except Exception: + pass # 显示/隐藏属于不同Canvas的组件(包括子组件) visible_count = 0 @@ -87,24 +102,43 @@ class LUIManagerInteractionMixin: comp_obj = comp['object'] comp_name = comp.get('name', 'Unknown') - should_be_visible = (comp_canvas_index == index) + canvas_render_mode = "screen" + try: + if comp_canvas_index is not None and 0 <= comp_canvas_index < len(self.canvases): + canvas_render_mode = self._canvas_render_mode(self.canvases[comp_canvas_index]) + except Exception: + canvas_render_mode = "screen" + + should_be_visible = (comp_canvas_index == index and canvas_render_mode != "world") + if hasattr(self, "_set_lui_component_screen_visible"): + self._set_lui_component_screen_visible(comp, should_be_visible) + if should_be_visible: + visible_count += 1 + else: + hidden_count += 1 + + if hasattr(self, "_set_lui_component_screen_visible"): + continue if should_be_visible: if hasattr(comp_obj, 'show'): comp_obj.show() elif hasattr(comp_obj, 'visible'): comp_obj.visible = True - visible_count += 1 else: if hasattr(comp_obj, 'hide'): comp_obj.hide() elif hasattr(comp_obj, 'visible'): comp_obj.visible = False - hidden_count += 1 # 更新当前根节点 if index >= 0: self.root = self.canvases[index]['panel'] + try: + if self._canvas_render_mode(self.canvases[index]) == "world" and hasattr(self, "_hide_resize_handles"): + self._hide_resize_handles() + except Exception: + pass # 如果选中的组件不属于当前Canvas,取消选中 if self.selected_index >= 0 and self.selected_index < len(self.components): @@ -118,6 +152,24 @@ class LUIManagerInteractionMixin: print(f"✓ Switched to Canvas: {self.canvases[index]['name']}") print(f" 显示组件: {visible_count}, 隐藏组件: {hidden_count}") + try: + if getattr(self, "debug_world_lui", False): + active_canvas = self.canvases[index] + active_mode = self._canvas_render_mode(active_canvas) + active_panel = active_canvas.get("panel") + parent_label = ( + self._debug_lui_parent_label(getattr(active_panel, "parent", None)) + if hasattr(self, "_debug_lui_parent_label") + else str(getattr(active_panel, "parent", None)) + ) + print( + "[LUI3D Debug] switch_canvas " + f"idx={index} name='{active_canvas.get('name')}' mode={active_mode} " + f"screen_visible={active_mode != 'world'} panel_parent={parent_label} " + f"visible_components={visible_count} hidden_components={hidden_count}" + ) + except Exception: + pass def _update_canvas_geometry(self, canvas_data): """根据填充模式和边距更新Canvas几何信息""" @@ -831,6 +883,14 @@ class LUIManagerInteractionMixin: comp_data = self.components[self.selected_index] comp_type = comp_data['type'] + comp_canvas_index = comp_data.get('canvas_index') + try: + if comp_canvas_index is not None and 0 <= comp_canvas_index < len(self.canvases): + if self._canvas_render_mode(self.canvases[comp_canvas_index]) == "world": + self._hide_resize_handles() + return task.cont + except Exception: + pass # 只对Frame、Button、Slider、InputField、Plane、Image显示resize handles if comp_type not in ['Frame', 'Button', 'Slider', 'InputField', 'Plane', 'Image', 'Checkbox', 'Text', 'Label', 'Video', 'Progressbar', 'Selectbox', 'ScrollableRegion', 'TabbedFrame', 'VerticalLayout', 'HorizontalLayout', 'HttpText']: @@ -908,8 +968,12 @@ class LUIManagerInteractionMixin: continue canvas = self.canvases[canvas_index] - if not canvas.get('visible', True): - # Hide outlines for components on hidden canvases + try: + is_world_canvas = self._canvas_render_mode(canvas) == "world" + except Exception: + is_world_canvas = str(canvas.get("render_mode") or "screen").lower().strip() == "world" + if not canvas.get('visible', True) or is_world_canvas: + # Hide screen-space editor outlines for hidden or scene-space canvases. borders = self.debug_outlines.get(idx) if borders: for b in borders: diff --git a/ui/LUI/lui_world_space.py b/ui/LUI/lui_world_space.py new file mode 100644 index 00000000..650e8f5e --- /dev/null +++ b/ui/LUI/lui_world_space.py @@ -0,0 +1,1608 @@ +"""World-space rendering and interaction bridge for editor LUI data. + +The binary LUI backend is screen-space oriented. This module mirrors the same +canvas/component data into real Panda3D scene geometry and performs ray-to-UI +hit testing for runtime interaction. +""" + +from __future__ import annotations + +import json +import math +import os +import time +from typing import Any, Dict, List, Optional, Tuple + +import panda3d.core as p3d + +try: + from PIL import Image, ImageDraw, ImageFont +except Exception: + Image = None + ImageDraw = None + ImageFont = None + + +def _num(value: Any, fallback: float = 0.0) -> float: + try: + value = float(value) + except Exception: + return fallback + if not math.isfinite(value): + return fallback + return value + + +def _color(value: Any, fallback=(1.0, 1.0, 1.0, 1.0)) -> Tuple[float, float, float, float]: + if not isinstance(value, (list, tuple)): + return tuple(fallback) + vals = list(value[:4]) + while len(vals) < 4: + vals.append(1.0) + return tuple(max(0.0, min(1.0, _num(v, fallback[i]))) for i, v in enumerate(vals[:4])) + + +def _rgba255(value: Any, fallback=(1.0, 1.0, 1.0, 1.0)) -> Tuple[int, int, int, int]: + rgba = _color(value, fallback) + return tuple(max(0, min(255, int(round(v * 255.0)))) for v in rgba) + + +def _canvas_mode(canvas_data: Dict[str, Any]) -> str: + return str(canvas_data.get("render_mode") or "screen").lower().strip() + + +class WorldSpaceLUIRuntime: + """Mirror selected LUI canvases into the 3D scene and process pointer input.""" + + TASK_NAMES = ( + "lui_world_space_sync", + "lui_world_space_input", + "lui_world_space_keyboard", + ) + + def __init__(self, manager): + self.manager = manager + self.world = manager.world + self._visual_roots: Dict[int, p3d.NodePath] = {} + self._signatures: Dict[int, str] = {} + self._hover_hit: Optional[Dict[str, Any]] = None + self._active_hit: Optional[Dict[str, Any]] = None + self._active_slider_index: int = -1 + self._focused_input_index: int = -1 + self._last_mouse_down = False + self._last_keyboard_buttons: Dict[str, bool] = {} + self._font_cache: Dict[int, Any] = {} + self._debug_last_times: Dict[int, float] = {} + self._debug_last_static_states: Dict[int, Tuple[Any, ...]] = {} + self._debug_force_next = True + + if hasattr(self.world, "taskMgr"): + for task_name in self.TASK_NAMES: + try: + self.world.taskMgr.remove(task_name) + except Exception: + pass + self.world.taskMgr.add(self._sync_task, self.TASK_NAMES[0]) + self.world.taskMgr.add(self._input_task, self.TASK_NAMES[1]) + self.world.taskMgr.add(self._keyboard_task, self.TASK_NAMES[2]) + + def clear(self) -> None: + for canvas in list(getattr(self.manager, "canvases", []) or []): + self._unregister_canvas_scene_node(canvas) + for root in list(self._visual_roots.values()): + try: + if root and not root.isEmpty(): + root.removeNode() + except Exception: + pass + self._visual_roots.clear() + self._signatures.clear() + self._hover_hit = None + self._active_hit = None + self._active_slider_index = -1 + self._focused_input_index = -1 + + def is_pointer_over_ui(self) -> bool: + return self._hit_test_pointer() is not None + + def handle_pointer_press_from_event(self) -> bool: + hit = self._hit_test_pointer() + if not hit: + self._debug_log_input("pointer press miss") + return False + self._debug_log_input( + "pointer press hit " + f"canvas={hit.get('canvas_index')} component={hit.get('component_index')} " + f"px=({hit.get('x'):.1f},{hit.get('y'):.1f}) " + f"world={self._fmt_vec3(hit.get('world_point'))}" + ) + self._handle_press(hit) + self._last_mouse_down = True + return True + + def _sync_task(self, task): + try: + self.sync() + except Exception as exc: + print(f"LUI 3DUI sync failed: {exc}") + return task.cont + + def _input_task(self, task): + try: + self._process_pointer() + except Exception as exc: + print(f"LUI 3DUI input failed: {exc}") + return task.cont + + def _keyboard_task(self, task): + try: + self._process_keyboard() + except Exception: + pass + return task.cont + + def sync(self) -> None: + canvases = list(getattr(self.manager, "canvases", []) or []) + live_world_indices = set() + + for index, canvas in enumerate(canvases): + if _canvas_mode(canvas) != "world": + self._remove_canvas_visual(index) + self._unregister_canvas_scene_node(canvas) + self._sync_overlay_panel_visibility(index, canvas) + continue + + live_world_indices.add(index) + self._sync_overlay_panel_visibility(index, canvas) + self._sync_world_canvas_screen_component_visibility(index) + self._ensure_reasonable_world_canvas_scale(index, canvas) + self._disable_billboard_mode(index, canvas) + self._sync_canvas_node_transform(index, canvas) + signature = self._build_canvas_signature(index, canvas) + rebuilt = False + if self._signatures.get(index) != signature: + self._rebuild_canvas_visual(index, canvas) + self._signatures[index] = signature + rebuilt = True + self._debug_log_world_canvas_state(index, canvas, signature, rebuilt=rebuilt) + + for stale_index in list(self._visual_roots.keys()): + if stale_index not in live_world_indices: + self._remove_canvas_visual(stale_index) + + def _sync_overlay_panel_visibility(self, index: int, canvas: Dict[str, Any]) -> None: + should_show = ( + canvas.get("visible", True) + and index == getattr(self.manager, "current_canvas_index", -1) + and _canvas_mode(canvas) != "world" + ) + set_canvas_visible = getattr(self.manager, "_set_lui_canvas_screen_visible", None) + if callable(set_canvas_visible): + set_canvas_visible(canvas, should_show) + return + + panel = canvas.get("panel") + if panel is None: + return + try: + if should_show and hasattr(panel, "show"): + panel.show() + elif not should_show and hasattr(panel, "hide"): + panel.hide() + except Exception: + pass + + def _sync_world_canvas_screen_component_visibility(self, index: int) -> None: + set_canvas_visible = getattr(self.manager, "_set_lui_canvas_screen_visible", None) + if callable(set_canvas_visible): + try: + canvases = getattr(self.manager, "canvases", []) or [] + if 0 <= index < len(canvases): + set_canvas_visible(canvases[index], False) + except Exception: + pass + + hide_component = getattr(self.manager, "_set_lui_component_screen_visible", None) + if not callable(hide_component): + return + for comp in getattr(self.manager, "components", []) or []: + try: + if comp.get("canvas_index") == index: + hide_component(comp, False) + except Exception: + pass + + def _ensure_reasonable_world_canvas_scale(self, index: int, canvas: Dict[str, Any]) -> None: + if canvas.get("_world_default_ppu_checked"): + return + canvas["_world_default_ppu_checked"] = True + + width, height = self._canvas_size(canvas) + current_ppu = _num(canvas.get("world_pixels_per_unit"), 100.0) + if current_ppu > 120.0 or max(width, height) < 640.0: + return + + target_ppu = max(current_ppu, width / 3.2, height / 2.0, 280.0) + canvas["world_pixels_per_unit"] = target_ppu + self._signatures.pop(index, None) + + def _disable_billboard_mode(self, index: int, canvas: Dict[str, Any]) -> None: + if not canvas.get("world_billboard"): + return + canvas["world_billboard"] = False + self._signatures.pop(index, None) + self._debug_log(f"canvas[{index}] disabled stale world_billboard flag") + + def _remove_canvas_visual(self, index: int) -> None: + root = self._visual_roots.pop(index, None) + self._signatures.pop(index, None) + if root is not None: + try: + if not root.isEmpty(): + root.removeNode() + except Exception: + pass + + def _sync_canvas_node_transform(self, index: int, canvas: Dict[str, Any]) -> None: + node = canvas.get("node") + if node is None or node.isEmpty(): + return + self._ensure_canvas_scene_node(index, canvas, node) + self._clear_camera_facing_effects(node) + try: + if not canvas.get("_world_transform_initialized"): + pos = canvas.get("world_position", (0.0, 8.0, 2.0)) + hpr = canvas.get("world_hpr", (25.0, 0.0, 0.0)) + scale = canvas.get("world_scale", (1.0, 1.0, 1.0)) + node.setPos(*self._triplet(pos, (0.0, 8.0, 2.0))) + node.setHpr(*self._triplet(hpr, (25.0, 0.0, 0.0))) + node.setScale(*self._triplet(scale, (1.0, 1.0, 1.0))) + canvas["_world_transform_initialized"] = True + self._copy_node_transform_to_canvas(canvas, node) + except Exception: + pass + + def _clear_camera_facing_effects(self, node: p3d.NodePath) -> None: + """Keep 3D LUI fixed in scene space instead of billboarding to camera.""" + if node is None: + return + try: + if hasattr(node, "isEmpty") and node.isEmpty(): + return + except Exception: + return + + effects_before = self._count_camera_facing_effects(node) + targets = [node] + try: + targets.extend(list(node.findAllMatches("**"))) + except Exception: + pass + for target in targets: + try: + if hasattr(target, "clearBillboard"): + target.clearBillboard() + if hasattr(target, "clearCompass"): + target.clearCompass() + except Exception: + pass + if effects_before[0] > 0: + self._debug_log( + f"cleared camera-facing effects count={effects_before[0]} " + f"nodes={','.join(effects_before[1])}" + ) + + def _debug_enabled(self) -> bool: + return bool(getattr(self.manager, "debug_world_lui", False)) + + def _debug_log(self, message: str) -> None: + if self._debug_enabled(): + print(f"[LUI3D Debug] {message}") + + def _debug_log_input(self, message: str) -> None: + if self._debug_enabled(): + print(f"[LUI3D Debug] input {message}") + + def _fmt_vec3(self, value: Any) -> str: + if value is None: + return "None" + try: + return f"({float(value.x):.3f},{float(value.y):.3f},{float(value.z):.3f})" + except Exception: + pass + if isinstance(value, (list, tuple)) and len(value) >= 3: + try: + return f"({float(value[0]):.3f},{float(value[1]):.3f},{float(value[2]):.3f})" + except Exception: + pass + return str(value) + + def _np_path(self, node: Optional[p3d.NodePath]) -> str: + if node is None: + return "None" + try: + if node.isEmpty(): + return "Empty" + except Exception: + return "Invalid" + names = [] + current = node + for _ in range(16): + try: + if current.isEmpty(): + break + names.append(current.getName()) + parent = current.getParent() + if parent.isEmpty() or parent == current: + break + current = parent + except Exception: + break + return " <- ".join(names) if names else "Unknown" + + def _np_parent_contains_camera(self, node: Optional[p3d.NodePath]) -> bool: + path = self._np_path(node).lower() + return "camera" in path or "cam" in path + + def _lui_parent_label(self, obj: Any) -> str: + labeler = getattr(self.manager, "_debug_lui_parent_label", None) + if callable(labeler): + return labeler(obj) + if obj is None: + return "None" + return f"{obj.__class__.__name__}@{id(obj):x}" + + def _lui_visible_value(self, obj: Any) -> Any: + getter = getattr(self.manager, "_debug_lui_visible_value", None) + if callable(getter): + return getter(obj) + try: + return getattr(obj, "visible", None) + except Exception: + return "?" + + def _lui_parent_state(self, obj: Any) -> str: + try: + parent = getattr(obj, "parent", None) + except Exception: + parent = None + return "None" if parent is None else "parented" + + def _component_screen_summaries(self, canvas_index: int) -> Tuple[List[str], List[str]]: + summaries: List[str] = [] + leaks: List[str] = [] + for comp_index, comp in enumerate(getattr(self.manager, "components", []) or []): + if comp.get("canvas_index") != canvas_index: + continue + obj = comp.get("object") + parent_state = self._lui_parent_state(obj) + visible = self._lui_visible_value(obj) + comp_type = comp.get("type", "?") + summary = ( + f"#{comp_index}:{comp_type} " + f"parent={parent_state} visible={visible} " + f"saved_parent={self._lui_parent_label(comp.get('_screen_parent'))}" + ) + if len(summaries) < 8: + summaries.append(summary) + if parent_state != "None" or visible not in (False, None): + leaks.append(summary) + return summaries, leaks + + def _count_camera_facing_effects(self, node: Optional[p3d.NodePath]) -> Tuple[int, List[str]]: + if node is None: + return 0, [] + try: + if node.isEmpty(): + return 0, [] + except Exception: + return 0, [] + + count = 0 + names: List[str] = [] + targets = [node] + try: + targets.extend(list(node.findAllMatches("**"))) + except Exception: + pass + for target in targets: + has_effect = False + try: + state_text = str(target.getState()).lower() + has_effect = "billboard" in state_text or "compass" in state_text + except Exception: + has_effect = False + if has_effect: + count += 1 + if len(names) < 6: + try: + names.append(target.getName()) + except Exception: + names.append("?") + return count, names + + def _project_render_point(self, render_point: p3d.Point3) -> str: + render = getattr(self.world, "render", None) + cam = getattr(self.world, "cam", None) + if render is None or cam is None: + return "no-camera" + try: + lens = cam.node().getLens() + point_in_cam = cam.getRelativePoint(render, render_point) + projected = p3d.Point2() + if lens.project(point_in_cam, projected): + return f"({float(projected.x):.3f},{float(projected.y):.3f})" + return f"offscreen cam={self._fmt_vec3(point_in_cam)}" + except Exception as exc: + return f"project-error:{exc}" + + def _projection_summary(self, canvas: Dict[str, Any], width: float, height: float) -> str: + node = (canvas or {}).get("node") + render = getattr(self.world, "render", None) + if node is None or render is None: + return "unavailable" + try: + if hasattr(node, "isEmpty") and node.isEmpty(): + return "empty-node" + ppu = max(1.0, _num(canvas.get("world_pixels_per_unit"), 100.0)) + half_w = width * 0.5 / ppu + half_h = height * 0.5 / ppu + points = { + "center": p3d.Point3(0, 0, 0), + "tl": p3d.Point3(-half_w, 0, half_h), + "br": p3d.Point3(half_w, 0, -half_h), + } + parts = [] + for label, local_point in points.items(): + world_point = render.getRelativePoint(node, local_point) + parts.append(f"{label}={self._project_render_point(world_point)}") + return " ".join(parts) + except Exception as exc: + return f"summary-error:{exc}" + + def _debug_log_world_canvas_state( + self, + index: int, + canvas: Dict[str, Any], + signature: str, + rebuilt: bool = False, + ) -> None: + if not self._debug_enabled(): + return + + now = time.time() + node = canvas.get("node") + visual_root = self._visual_roots.get(index) + panel = canvas.get("panel") + render = getattr(self.world, "render", None) + cam = getattr(self.world, "cam", None) + width, height = self._canvas_size(canvas) + effects_count, effects_nodes = self._count_camera_facing_effects(node) + + try: + node_pos = node.getPos(render) if render is not None else node.getPos() + node_hpr = node.getHpr(render) if render is not None else node.getHpr() + node_scale = node.getScale(render) if render is not None else node.getScale() + except Exception: + node_pos = node_hpr = node_scale = None + + try: + cam_pos = cam.getPos(render) if cam is not None and render is not None else None + cam_hpr = cam.getHpr(render) if cam is not None and render is not None else None + node_hpr_from_cam = node.getHpr(cam) if cam is not None and node is not None else None + node_pos_from_cam = node.getPos(cam) if cam is not None and node is not None else None + except Exception: + cam_pos = cam_hpr = node_hpr_from_cam = node_pos_from_cam = None + + screen_parent = getattr(panel, "parent", None) + saved_parent = canvas.get("_screen_parent") + component_summaries, component_leaks = self._component_screen_summaries(index) + component_count = sum( + 1 + for comp in getattr(self.manager, "components", []) or [] + if comp.get("canvas_index") == index + ) + visual_child_count = 0 + try: + if visual_root is not None and not visual_root.isEmpty(): + visual_child_count = visual_root.getNumChildren() + except Exception: + visual_child_count = -1 + + static_state = ( + self._np_path(node), + self._np_path(visual_root), + self._fmt_vec3(node_pos), + self._fmt_vec3(node_hpr), + self._fmt_vec3(node_scale), + self._lui_parent_label(screen_parent), + self._lui_visible_value(panel), + effects_count, + visual_child_count, + bool(canvas.get("visible", True)), + _num(canvas.get("world_pixels_per_unit"), 100.0), + tuple(component_summaries), + ) + + last_time = self._debug_last_times.get(index, 0.0) + state_changed = self._debug_last_static_states.get(index) != static_state + should_print = ( + getattr(self, "_debug_force_next", False) + or rebuilt + or state_changed + or now - last_time >= 2.0 + ) + if not should_print: + return + + self._debug_last_times[index] = now + self._debug_last_static_states[index] = static_state + self._debug_force_next = False + warn_camera_parent = self._np_parent_contains_camera(node) + print( + "[LUI3D Debug] world canvas " + f"idx={index} name='{canvas.get('name')}' visible={canvas.get('visible', True)} " + f"rebuilt={rebuilt} sig={signature[:10]} comps={component_count} " + f"size=({width:.1f}x{height:.1f}) ppu={_num(canvas.get('world_pixels_per_unit'), 100.0):.1f} " + f"depth={bool(canvas.get('world_depth_test', True))} interactive={bool(canvas.get('world_interactive', True))} " + f"two_sided={bool(canvas.get('world_two_sided', False))}" + ) + print( + "[LUI3D Debug] node " + f"path={static_state[0]} parent_has_camera={warn_camera_parent} " + f"pos={static_state[2]} hpr={static_state[3]} scale={static_state[4]} " + f"cam_local_pos={self._fmt_vec3(node_pos_from_cam)} " + f"hpr_from_cam={self._fmt_vec3(node_hpr_from_cam)}" + ) + print( + "[LUI3D Debug] projection " + f"{self._projection_summary(canvas, width, height)}" + ) + print( + "[LUI3D Debug] camera " + f"pos={self._fmt_vec3(cam_pos)} hpr={self._fmt_vec3(cam_hpr)}" + ) + print( + "[LUI3D Debug] visual " + f"path={static_state[1]} children={visual_child_count} " + f"camera_facing_effects={effects_count} nodes={effects_nodes}" + ) + print( + "[LUI3D Debug] screen-copy " + f"panel_parent={self._lui_parent_label(screen_parent)} " + f"saved_parent={self._lui_parent_label(saved_parent)} " + f"panel_visible={self._lui_visible_value(panel)} " + f"background_visible={self._lui_visible_value(canvas.get('background'))} " + f"title_visible={self._lui_visible_value(canvas.get('title_label'))}" + ) + print( + "[LUI3D Debug] screen-components " + f"samples={component_summaries} leaks={component_leaks[:8]}" + ) + + def _ensure_canvas_scene_node(self, index: int, canvas: Dict[str, Any], node: p3d.NodePath) -> None: + try: + render = getattr(self.world, "render", None) + if render is not None and not render.isEmpty() and node.getParent() != render: + node.wrtReparentTo(render) + except Exception: + pass + + try: + node.setTag("is_lui_canvas", "1") + node.setTag("is_scene_element", "1") + node.setTag("tree_item_type", "SCENE_NODE") + node.setTag("lui_canvas_index", str(index)) + except Exception: + pass + + if canvas.get("_world_scene_registered"): + return + scene_manager = getattr(self.world, "scene_manager", None) + models = getattr(scene_manager, "models", None) if scene_manager is not None else None + try: + if isinstance(models, list) and node not in models: + models.append(node) + update_tree = getattr(self.world, "updateSceneTree", None) + if callable(update_tree): + update_tree() + except Exception: + pass + canvas["_world_scene_registered"] = True + + def _unregister_canvas_scene_node(self, canvas: Dict[str, Any]) -> None: + node = (canvas or {}).get("node") + scene_manager = getattr(self.world, "scene_manager", None) + models = getattr(scene_manager, "models", None) if scene_manager is not None else None + changed = False + try: + if isinstance(models, list) and node in models: + models.remove(node) + changed = True + except Exception: + pass + canvas["_world_scene_registered"] = False + try: + if node is not None and not node.isEmpty(): + for tag_name in ("is_lui_canvas", "is_scene_element", "tree_item_type", "lui_canvas_index"): + if node.hasTag(tag_name): + node.clearTag(tag_name) + except Exception: + pass + if changed: + update_tree = getattr(self.world, "updateSceneTree", None) + if callable(update_tree): + try: + update_tree() + except Exception: + pass + + def _copy_node_transform_to_canvas(self, canvas: Dict[str, Any], node: p3d.NodePath) -> None: + try: + pos = node.getPos() + hpr = node.getHpr() + scale = node.getScale() + canvas["world_position"] = (float(pos.x), float(pos.y), float(pos.z)) + canvas["world_hpr"] = (float(hpr.x), float(hpr.y), float(hpr.z)) + canvas["world_scale"] = (float(scale.x), float(scale.y), float(scale.z)) + except Exception: + pass + + def _triplet(self, value: Any, fallback: Tuple[float, float, float]) -> Tuple[float, float, float]: + if not isinstance(value, (list, tuple)) or len(value) < 3: + return tuple(fallback) + return ( + _num(value[0], fallback[0]), + _num(value[1], fallback[1]), + _num(value[2], fallback[2]), + ) + + def _canvas_size(self, canvas: Dict[str, Any]) -> Tuple[float, float]: + panel = canvas.get("panel") + width = _num(canvas.get("width"), 0.0) + height = _num(canvas.get("height"), 0.0) + if panel is not None: + try: + width = _num(getattr(panel, "width", width), width) + height = _num(getattr(panel, "height", height), height) + except Exception: + pass + try: + if width <= 0 and hasattr(panel, "get_width"): + width = _num(panel.get_width(), width) + if height <= 0 and hasattr(panel, "get_height"): + height = _num(panel.get_height(), height) + except Exception: + pass + return max(1.0, width or 800.0), max(1.0, height or 600.0) + + def _build_canvas_signature(self, canvas_index: int, canvas: Dict[str, Any]) -> str: + width, height = self._canvas_size(canvas) + components = [] + for index, comp in enumerate(getattr(self.manager, "components", []) or []): + if comp.get("canvas_index") != canvas_index: + continue + components.append(self._component_signature_entry(index, comp)) + payload = { + "canvas": { + "visible": bool(canvas.get("visible", True)), + "name": canvas.get("name"), + "width": width, + "height": height, + "background_color": self._canvas_background_color(canvas), + "world_pixels_per_unit": _num(canvas.get("world_pixels_per_unit"), 100.0), + "world_depth_test": bool(canvas.get("world_depth_test", True)), + "world_two_sided": bool(canvas.get("world_two_sided", False)), + "renderer": "texture_rgba8_gbuffer_scene_fixed_v4", + }, + "components": components, + "hover": self._hover_hit.get("component_index") if self._hover_hit else -1, + "active": self._active_hit.get("component_index") if self._active_hit else -1, + "focused": self._focused_input_index, + } + return json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str) + + def _component_signature_entry(self, index: int, comp: Dict[str, Any]) -> Dict[str, Any]: + keys = ( + "type", "name", "text", "value", "placeholder", "left", "top", "width", "height", + "color", "text_color", "font_size", "checked", "sort", "z_offset", "texture_path", + "video_path", "selected_option_id", "options", "min_value", "max_value", + "world_pressed", "world_hovered", + ) + return {"index": index, **{key: comp.get(key) for key in keys if key in comp}} + + def _canvas_background_color(self, canvas: Dict[str, Any]) -> Tuple[float, float, float, float]: + background = canvas.get("background") + try: + if background is not None and hasattr(background, "color"): + return _color(background.color, (0.1, 0.1, 0.1, 0.65)) + except Exception: + pass + return _color(canvas.get("background_color"), (0.1, 0.1, 0.1, 0.65)) + + def _get_pil_font(self, size: float): + if ImageFont is None: + return None + font_size = max(8, int(round(_num(size, 14.0)))) + cached = self._font_cache.get(font_size) + if cached is not None: + return cached + + candidates = [ + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "C:/Windows/Fonts/msyh.ttc", + "C:/Windows/Fonts/simhei.ttf", + ] + font = None + for path in candidates: + try: + if os.path.exists(path): + font = ImageFont.truetype(path, font_size) + break + except Exception: + font = None + if font is None: + try: + font = ImageFont.load_default() + except Exception: + font = None + self._font_cache[font_size] = font + return font + + def _draw_text_2d(self, draw, text: str, x: float, y: float, width: float, fill, font) -> None: + if draw is None: + return + text = "" if text is None else str(text) + if not text: + return + try: + draw.text((int(round(x)), int(round(y))), text, fill=fill, font=font) + except Exception: + try: + draw.text((int(round(x)), int(round(y))), text.encode("ascii", "ignore").decode("ascii"), fill=fill, font=font) + except Exception: + pass + + def _resolve_image_path(self, source: str) -> str: + source = str(source or "").strip() + if not source or source.lower() == "blank": + return "" + if source.lower().startswith(("http://", "https://", "data:", "blob:")): + return "" + candidates = [source] + try: + if not os.path.isabs(source): + cwd = os.getcwd() + project_path = getattr(getattr(self.world, "project_manager", None), "current_project_path", "") + candidates.extend([ + os.path.join(cwd, source), + os.path.join(cwd, "Resources", source), + ]) + if project_path: + candidates.extend([ + os.path.join(project_path, source), + os.path.join(project_path, "Resources", source), + ]) + except Exception: + pass + for path in candidates: + try: + if path and os.path.exists(path): + return os.path.normpath(path) + except Exception: + pass + return "" + + def _draw_canvas_component_2d(self, draw, image, index: int, comp: Dict[str, Any], canvas_w: float, canvas_h: float) -> None: + x, y = self._component_abs_pos(index) + width = max(1.0, _num(comp.get("width"), 100.0)) + height = max(1.0, _num(comp.get("height"), 30.0)) + x0 = int(round(x)) + y0 = int(round(y)) + x1 = int(round(x + width)) + y1 = int(round(y + height)) + comp_type = str(comp.get("type") or "").lower() + hovered = bool(comp.get("world_hovered")) + pressed = bool(comp.get("world_pressed")) + + if x1 <= 0 or y1 <= 0 or x0 >= canvas_w or y0 >= canvas_h: + return + + if comp_type in ("text", "label"): + font_size = _num(comp.get("font_size"), 14.0) + font = self._get_pil_font(font_size) + self._draw_text_2d(draw, comp.get("text", ""), x, y, width, _rgba255(comp.get("color"), (1, 1, 1, 1)), font) + return + + if comp_type == "button": + fill = (46, 64, 86, 235) + if hovered: + fill = (61, 83, 108, 242) + if pressed: + fill = (28, 41, 59, 250) + draw.rounded_rectangle([x0, y0, x1, y1], radius=4, fill=fill, outline=(255, 255, 255, 72)) + font = self._get_pil_font(14) + label = str(comp.get("text") or "Button") + try: + bbox = draw.textbbox((0, 0), label, font=font) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + except Exception: + tw = min(width, len(label) * 8) + th = 14 + self._draw_text_2d(draw, label, x + (width - tw) * 0.5, y + (height - th) * 0.5 - 1, width, (245, 247, 250, 245), font) + return + + if comp_type == "checkbox": + box = min(18.0, height) + by = y + (height - box) * 0.5 + draw.rectangle([int(x), int(by), int(x + box), int(by + box)], fill=(20, 27, 38, 245), outline=(255, 255, 255, 88)) + if comp.get("checked"): + font = self._get_pil_font(14) + self._draw_text_2d(draw, "X", x + 4, by, box, (125, 255, 205, 255), font) + font = self._get_pil_font(14) + self._draw_text_2d(draw, comp.get("text") or "Checkbox", x + box + 8, y + max(0.0, (height - 14) * 0.5), width - box - 8, (245, 247, 250, 245), font) + return + + if comp_type == "slider": + min_v = _num(comp.get("min_value", comp.get("min")), 0.0) + max_v = _num(comp.get("max_value", comp.get("max")), 100.0) + val = _num(comp.get("value"), (min_v + max_v) * 0.5) + ratio = 0.0 if max_v == min_v else max(0.0, min(1.0, (val - min_v) / (max_v - min_v))) + bar_h = max(3, int(round(height * 0.16))) + bar_y = int(round(y + height * 0.5 - bar_h * 0.5)) + draw.rectangle([x0, bar_y, x1, bar_y + bar_h], fill=(24, 32, 43, 245)) + draw.rectangle([x0, bar_y, int(round(x + width * ratio)), bar_y + bar_h], fill=(115, 210, 180, 245)) + knob = max(10, min(18, int(round(height + 2)))) + knob_x = int(round(x + width * ratio - knob * 0.5)) + knob_y = int(round(y + (height - knob) * 0.5)) + draw.rectangle([knob_x, knob_y, knob_x + knob, knob_y + knob], fill=(238, 244, 255, 255)) + return + + if comp_type == "inputfield": + outline = (125, 210, 185, 220) if index == self._focused_input_index else (255, 255, 255, 58) + draw.rectangle([x0, y0, x1, y1], fill=(10, 15, 22, 245), outline=outline) + text = comp.get("value") or comp.get("text") or comp.get("placeholder") or "" + has_value = bool(comp.get("value") or comp.get("text")) + font = self._get_pil_font(14) + self._draw_text_2d(draw, text, x + 8, y + max(3.0, (height - 14) * 0.5), width - 16, (245, 247, 250, 245) if has_value else (210, 215, 225, 140), font) + return + + if comp_type == "progressbar": + ratio = max(0.0, min(1.0, _num(comp.get("value"), 0.0) / 100.0)) + draw.rectangle([x0, y0, x1, y1], fill=(16, 21, 28, 245)) + draw.rectangle([x0, y0, int(round(x + width * ratio)), y1], fill=(82, 194, 158, 245)) + return + + if comp_type == "image": + image_path = self._resolve_image_path(str(comp.get("texture_path") or "")) + if image_path and Image is not None: + try: + with Image.open(image_path) as src: + src_rgba = src.convert("RGBA").resize((max(1, int(round(width))), max(1, int(round(height))))) + image.alpha_composite(src_rgba, (x0, y0)) + return + except Exception: + pass + draw.rectangle([x0, y0, x1, y1], fill=_rgba255(comp.get("color"), (0.16, 0.18, 0.22, 0.86))) + return + + if comp_type == "selectbox": + draw.rectangle([x0, y0, x1, y1], fill=(10, 15, 22, 245), outline=(255, 255, 255, 58)) + font = self._get_pil_font(14) + self._draw_text_2d(draw, self._selected_option_label(comp), x + 8, y + max(3.0, (height - 14) * 0.5), width - 28, (245, 247, 250, 245), font) + self._draw_text_2d(draw, "v", x + width - 18, y + max(3.0, (height - 14) * 0.5), 12, (245, 247, 250, 210), font) + return + + fill = _rgba255(comp.get("color"), (0.12, 0.15, 0.19, 0.86)) + draw.rectangle([x0, y0, x1, y1], fill=fill, outline=(255, 255, 255, 36)) + if comp_type == "httptext": + font = self._get_pil_font(13) + self._draw_text_2d(draw, comp.get("text") or comp.get("http_status") or "", x + 8, y + 8, width - 16, _rgba255(comp.get("text_color"), (0.92, 0.95, 1, 1)), font) + + def _build_canvas_texture(self, canvas_index: int, canvas: Dict[str, Any], width: float, height: float): + if Image is None or ImageDraw is None: + return None + tex_w = max(1, int(round(width))) + tex_h = max(1, int(round(height))) + try: + image = Image.new("RGBA", (tex_w, tex_h), _rgba255(self._canvas_background_color(canvas), (0.1, 0.1, 0.1, 0.68))) + draw = ImageDraw.Draw(image, "RGBA") + + if canvas.get("title_visible") and canvas.get("title_text"): + title_pos = canvas.get("title_pos") or (10, 10) + tx = _num(title_pos[0], 10.0) if isinstance(title_pos, (list, tuple)) and len(title_pos) >= 2 else 10.0 + ty = _num(title_pos[1], 10.0) if isinstance(title_pos, (list, tuple)) and len(title_pos) >= 2 else 10.0 + self._draw_text_2d(draw, canvas.get("title_text", ""), tx, ty, tex_w - tx, (220, 226, 235, 230), self._get_pil_font(16)) + + comps = [ + (index, comp) + for index, comp in enumerate(getattr(self.manager, "components", []) or []) + if comp.get("canvas_index") == canvas_index + ] + comps.sort(key=lambda item: (_num(item[1].get("sort"), 1.0) + _num(item[1].get("z_offset"), 0.0), item[0])) + for comp_index, comp in comps: + self._draw_canvas_component_2d(draw, image, comp_index, comp, tex_w, tex_h) + + tex = p3d.Texture(f"lui_world_canvas_{canvas_index}") + tex.setup2dTexture(tex_w, tex_h, p3d.Texture.T_unsigned_byte, p3d.Texture.F_rgba8) + tex.setMinfilter(p3d.SamplerState.FT_linear) + tex.setMagfilter(p3d.SamplerState.FT_linear) + tex.setWrapU(p3d.SamplerState.WM_clamp) + tex.setWrapV(p3d.SamplerState.WM_clamp) + tex.setRamImageAs(image.tobytes("raw", "RGBA"), "RGBA") + return tex + except Exception as exc: + print(f"LUI 3DUI texture build failed: {exc}") + return None + + def _add_canvas_texture( + self, + parent: p3d.NodePath, + canvas_index: int, + texture, + canvas_w: float, + canvas_h: float, + ppu: float, + depth_test: bool, + two_sided: bool = False, + ) -> Optional[p3d.NodePath]: + x0, z_top = self._px_to_local(0, 0, canvas_w, canvas_h, ppu) + x1, z_bottom = self._px_to_local(canvas_w, canvas_h, canvas_w, canvas_h, ppu) + card = p3d.CardMaker(f"lui_world_canvas_card_{canvas_index}") + card.setFrame(x0, x1, z_bottom, z_top) + node = parent.attachNewNode(card.generate()) + node.setPos(0, -0.002, 0) + node.setTexture(texture, 1) + node.setColor(1, 1, 1, 1) + node.setColorScale(1, 1, 1, 1) + node.setTransparency(p3d.TransparencyAttrib.MNone) + node.setTwoSided(bool(two_sided)) + node.setLightOff(1) + node.setDepthTest(depth_test) + node.setDepthWrite(bool(depth_test)) + node.setBin("default", 0) + self._apply_lui_scene_effect(node) + return node + + def _apply_lui_scene_effect(self, node: p3d.NodePath) -> None: + render_pipeline = getattr(self.world, "render_pipeline", None) + if not render_pipeline: + return + apply_effect = getattr(render_pipeline, "_internal_set_effect", None) or getattr(render_pipeline, "set_effect", None) + if not callable(apply_effect): + return + try: + apply_effect( + node, + "effects/lui_world_ui.yaml", + { + "render_gbuffer": True, + "render_shadow": False, + "render_voxelize": False, + "render_envmap": False, + "render_forward": False, + "alpha_testing": False, + "normal_mapping": False, + "parallax_mapping": False, + }, + 120, + ) + except Exception as exc: + print(f"LUI 3DUI scene effect failed: {exc}") + + def _rebuild_canvas_visual(self, canvas_index: int, canvas: Dict[str, Any]) -> None: + node = canvas.get("node") + if node is None or node.isEmpty(): + return + + self._remove_canvas_visual(canvas_index) + if not canvas.get("visible", True): + return + + visual_root = node.attachNewNode(f"world_lui_visual_{canvas_index}") + self._clear_camera_facing_effects(visual_root) + self._visual_roots[canvas_index] = visual_root + + width, height = self._canvas_size(canvas) + ppu = max(1.0, _num(canvas.get("world_pixels_per_unit"), 100.0)) + depth_test = bool(canvas.get("world_depth_test", True)) + two_sided = bool(canvas.get("world_two_sided", False)) + + canvas_texture = self._build_canvas_texture(canvas_index, canvas, width, height) + if canvas_texture is not None: + card_node = self._add_canvas_texture( + visual_root, + canvas_index, + canvas_texture, + width, + height, + ppu, + depth_test, + two_sided, + ) + self._clear_camera_facing_effects(card_node) + return + + bg_color = self._canvas_background_color(canvas) + self._add_rect( + visual_root, + "canvas_bg", + 0, + 0, + width, + height, + width, + height, + ppu, + bg_color, + layer=0.0, + depth_test=depth_test, + two_sided=two_sided, + ) + + comps = [ + (index, comp) + for index, comp in enumerate(getattr(self.manager, "components", []) or []) + if comp.get("canvas_index") == canvas_index + ] + comps.sort(key=lambda item: (_num(item[1].get("sort"), 1.0) + _num(item[1].get("z_offset"), 0.0), item[0])) + for order, (comp_index, comp) in enumerate(comps, start=1): + self._add_component_visual(visual_root, comp_index, comp, width, height, ppu, order, depth_test, two_sided) + + def _px_to_local(self, x: float, y: float, canvas_w: float, canvas_h: float, ppu: float) -> Tuple[float, float]: + return (x - canvas_w * 0.5) / ppu, (canvas_h * 0.5 - y) / ppu + + def _add_rect( + self, + parent: p3d.NodePath, + name: str, + x: float, + y: float, + width: float, + height: float, + canvas_w: float, + canvas_h: float, + ppu: float, + color, + layer: float, + depth_test: bool = True, + two_sided: bool = False, + texture_path: str = "", + ) -> Optional[p3d.NodePath]: + width = max(1.0, _num(width, 1.0)) + height = max(1.0, _num(height, 1.0)) + x0, z_top = self._px_to_local(x, y, canvas_w, canvas_h, ppu) + x1, z_bottom = self._px_to_local(x + width, y + height, canvas_w, canvas_h, ppu) + + card = p3d.CardMaker(name) + card.setFrame(x0, x1, z_bottom, z_top) + node = parent.attachNewNode(card.generate()) + node.setPos(0, -0.002 * layer, 0) + node.setColor(*_color(color)) + node.setTransparency(p3d.TransparencyAttrib.MAlpha) + node.setTwoSided(bool(two_sided)) + node.setLightOff(1) + node.setDepthTest(depth_test) + node.setDepthWrite(False) + node.setBin("transparent", int(layer)) + if texture_path: + tex = self._load_texture(texture_path) + if tex is not None: + node.setTexture(tex, 1) + return node + + def _add_text( + self, + parent: p3d.NodePath, + name: str, + text: str, + x: float, + y: float, + canvas_w: float, + canvas_h: float, + ppu: float, + font_size: float, + color, + layer: float, + max_width: float = 0.0, + two_sided: bool = False, + ) -> Optional[p3d.NodePath]: + if text is None: + text = "" + text_node = p3d.TextNode(name) + text_node.setText(str(text)) + text_node.setAlign(p3d.TextNode.ALeft) + text_node.setTextColor(*_color(color)) + if max_width > 0: + try: + text_node.setWordwrap(max(1.0, max_width / max(1.0, font_size))) + except Exception: + pass + node = parent.attachNewNode(text_node) + local_x, local_z = self._px_to_local(x, y + font_size, canvas_w, canvas_h, ppu) + scale = max(1.0, font_size) / ppu + node.setPos(local_x, -0.002 * layer, local_z) + node.setScale(scale) + node.setTwoSided(bool(two_sided)) + node.setLightOff(1) + node.setDepthWrite(False) + node.setBin("transparent", int(layer)) + return node + + def _add_component_visual( + self, + parent: p3d.NodePath, + index: int, + comp: Dict[str, Any], + canvas_w: float, + canvas_h: float, + ppu: float, + order: int, + depth_test: bool, + two_sided: bool, + ) -> None: + x, y = self._component_abs_pos(index) + width = max(1.0, _num(comp.get("width"), 100.0)) + height = max(1.0, _num(comp.get("height"), 30.0)) + comp_type = str(comp.get("type") or "").lower() + layer = 10 + order + hovered = bool(comp.get("world_hovered")) + pressed = bool(comp.get("world_pressed")) + + if comp_type in ("text", "label"): + self._add_text( + parent, + f"world_lui_text_{index}", + comp.get("text", ""), + x, + y, + canvas_w, + canvas_h, + ppu, + _num(comp.get("font_size"), 14.0), + comp.get("color", (1, 1, 1, 1)), + layer, + width, + ) + return + + if comp_type == "button": + color = (0.18, 0.25, 0.34, 0.92) + if hovered: + color = (0.24, 0.34, 0.45, 0.95) + if pressed: + color = (0.12, 0.19, 0.28, 0.98) + self._add_rect(parent, f"world_lui_btn_{index}", x, y, width, height, canvas_w, canvas_h, ppu, color, layer, depth_test) + self._add_text(parent, f"world_lui_btn_text_{index}", comp.get("text", "Button"), x + 10, y + max(3.0, (height - 14) / 2), canvas_w, canvas_h, ppu, 14, (1, 1, 1, 1), layer + 0.5, width - 20) + return + + if comp_type == "checkbox": + box = min(18.0, height) + self._add_rect(parent, f"world_lui_chk_box_{index}", x, y + (height - box) * 0.5, box, box, canvas_w, canvas_h, ppu, (0.1, 0.13, 0.18, 0.95), layer, depth_test) + if comp.get("checked"): + self._add_text(parent, f"world_lui_chk_mark_{index}", "X", x + 3, y + (height - box) * 0.5 + 1, canvas_w, canvas_h, ppu, 14, (0.5, 1.0, 0.8, 1), layer + 0.5) + self._add_text(parent, f"world_lui_chk_text_{index}", comp.get("text", "Checkbox"), x + box + 8, y + max(0.0, (height - 14) / 2), canvas_w, canvas_h, ppu, 14, (1, 1, 1, 1), layer + 0.5, width - box - 8) + return + + if comp_type == "slider": + self._add_rect(parent, f"world_lui_slider_bg_{index}", x, y + height * 0.42, width, max(3, height * 0.16), canvas_w, canvas_h, ppu, (0.12, 0.16, 0.2, 1), layer, depth_test) + min_v = _num(comp.get("min_value", comp.get("min")), 0.0) + max_v = _num(comp.get("max_value", comp.get("max")), 100.0) + val = _num(comp.get("value"), (min_v + max_v) * 0.5) + ratio = 0.0 if max_v == min_v else max(0.0, min(1.0, (val - min_v) / (max_v - min_v))) + self._add_rect(parent, f"world_lui_slider_fill_{index}", x, y + height * 0.42, width * ratio, max(3, height * 0.16), canvas_w, canvas_h, ppu, (0.45, 0.82, 0.7, 1), layer + 0.3, depth_test) + knob_w = max(8.0, min(18.0, height + 2.0)) + self._add_rect(parent, f"world_lui_slider_knob_{index}", x + width * ratio - knob_w * 0.5, y + (height - knob_w) * 0.5, knob_w, knob_w, canvas_w, canvas_h, ppu, (0.9, 0.94, 1.0, 1), layer + 0.6, depth_test) + return + + if comp_type == "inputfield": + self._add_rect(parent, f"world_lui_input_{index}", x, y, width, height, canvas_w, canvas_h, ppu, (0.04, 0.06, 0.08, 0.95), layer, depth_test) + text = comp.get("value") or comp.get("text") or comp.get("placeholder") or "" + text_color = (1, 1, 1, 1) if (comp.get("value") or comp.get("text")) else (0.8, 0.8, 0.8, 0.55) + self._add_text(parent, f"world_lui_input_text_{index}", text, x + 8, y + max(3.0, (height - 14) / 2), canvas_w, canvas_h, ppu, 14, text_color, layer + 0.5, width - 16) + if index == self._focused_input_index: + self._add_rect(parent, f"world_lui_input_cursor_{index}", x + width - 10, y + 5, 2, max(8, height - 10), canvas_w, canvas_h, ppu, (0.7, 0.9, 1.0, 1), layer + 0.8, depth_test) + return + + if comp_type == "progressbar": + self._add_rect(parent, f"world_lui_progress_bg_{index}", x, y, width, height, canvas_w, canvas_h, ppu, (0.08, 0.1, 0.12, 0.95), layer, depth_test) + ratio = max(0.0, min(1.0, _num(comp.get("value"), 0.0) / 100.0)) + self._add_rect(parent, f"world_lui_progress_fg_{index}", x, y, width * ratio, height, canvas_w, canvas_h, ppu, (0.32, 0.76, 0.62, 0.95), layer + 0.4, depth_test) + return + + if comp_type == "image": + self._add_rect(parent, f"world_lui_image_{index}", x, y, width, height, canvas_w, canvas_h, ppu, comp.get("color", (1, 1, 1, 1)), layer, depth_test, str(comp.get("texture_path") or "")) + return + + if comp_type == "httptext": + self._add_rect(parent, f"world_lui_http_{index}", x, y, width, height, canvas_w, canvas_h, ppu, comp.get("color", (0.08, 0.14, 0.2, 0.92)), layer, depth_test) + self._add_text(parent, f"world_lui_http_text_{index}", comp.get("text") or comp.get("http_status") or "", x + 8, y + 8, canvas_w, canvas_h, ppu, 13, comp.get("text_color", (0.92, 0.95, 1, 1)), layer + 0.5, width - 16) + return + + color = comp.get("color", (0.12, 0.15, 0.19, 0.82)) + self._add_rect(parent, f"world_lui_{comp_type or 'component'}_{index}", x, y, width, height, canvas_w, canvas_h, ppu, color, layer, depth_test) + if comp_type == "selectbox": + label = self._selected_option_label(comp) + self._add_text(parent, f"world_lui_select_text_{index}", label, x + 8, y + max(3.0, (height - 14) / 2), canvas_w, canvas_h, ppu, 14, (1, 1, 1, 1), layer + 0.5, width - 16) + + def _selected_option_label(self, comp: Dict[str, Any]) -> str: + selected = comp.get("selected_option_id") + options = comp.get("options") or [] + for item in options: + if isinstance(item, (list, tuple)) and len(item) >= 2: + if str(item[0]) == str(selected): + return str(item[1]) + elif isinstance(item, dict) and str(item.get("id", item.get("value"))) == str(selected): + return str(item.get("label", item.get("text", item.get("value", "")))) + if options: + item = options[0] + if isinstance(item, (list, tuple)) and len(item) >= 2: + return str(item[1]) + if isinstance(item, dict): + return str(item.get("label", item.get("text", item.get("value", "")))) + return "Select" + + def _load_texture(self, source: str): + source = str(source or "").strip() + if not source or source.lower() == "blank": + return None + candidates = [source] + try: + if not os.path.isabs(source): + candidates.extend([ + os.path.join(os.getcwd(), source), + os.path.join(os.getcwd(), "Resources", source), + ]) + except Exception: + pass + for path in candidates: + try: + if os.path.exists(path): + return self.world.loader.loadTexture(path) + except Exception: + pass + try: + return self.world.loader.loadTexture(source) + except Exception: + return None + + def _component_abs_pos(self, index: int) -> Tuple[float, float]: + if index < 0 or index >= len(self.manager.components): + return 0.0, 0.0 + comp = self.manager.components[index] + left = _num(comp.get("left"), 0.0) + top = _num(comp.get("top"), 0.0) + parent_index = comp.get("parent_index") + if parent_index is not None and parent_index >= 0: + px, py = self._component_abs_pos(int(parent_index)) + return px + left, py + top + return left, top + + def _hit_test_pointer(self) -> Optional[Dict[str, Any]]: + if not hasattr(self.world, "mouseWatcherNode") or not self.world.mouseWatcherNode.hasMouse(): + return None + + mpos = self.world.mouseWatcherNode.getMouse() + near = p3d.Point3() + far = p3d.Point3() + try: + self.world.cam.node().getLens().extrude(p3d.Point2(mpos.x, mpos.y), near, far) + except Exception: + return None + + render = getattr(self.world, "render", None) + cam = getattr(self.world, "cam", None) + if render is None or cam is None: + return None + + near_world = render.getRelativePoint(cam, near) + far_world = render.getRelativePoint(cam, far) + best = None + best_dist = float("inf") + + for canvas_index, canvas in enumerate(getattr(self.manager, "canvases", []) or []): + if _canvas_mode(canvas) != "world" or not canvas.get("visible", True) or not canvas.get("world_interactive", True): + continue + node = canvas.get("node") + if node is None or node.isEmpty(): + continue + width, height = self._canvas_size(canvas) + ppu = max(1.0, _num(canvas.get("world_pixels_per_unit"), 100.0)) + local_near = node.getRelativePoint(render, near_world) + local_far = node.getRelativePoint(render, far_world) + delta = local_far - local_near + if abs(delta.y) < 1e-6: + continue + t = -local_near.y / delta.y + if t < 0.0 or t > 1.0: + continue + local_hit = local_near + delta * t + px = local_hit.x * ppu + width * 0.5 + py = height * 0.5 - local_hit.z * ppu + if px < 0 or py < 0 or px > width or py > height: + continue + world_hit = render.getRelativePoint(node, local_hit) + dist = (world_hit - near_world).lengthSquared() + if dist < best_dist: + best_dist = dist + best = { + "canvas_index": canvas_index, + "canvas": canvas, + "x": px, + "y": py, + "component_index": self._hit_test_component(canvas_index, px, py), + "world_point": world_hit, + } + return best + + def _hit_test_component(self, canvas_index: int, px: float, py: float) -> int: + matches: List[Tuple[float, int]] = [] + for index, comp in enumerate(getattr(self.manager, "components", []) or []): + if comp.get("canvas_index") != canvas_index: + continue + x, y = self._component_abs_pos(index) + w = max(1.0, _num(comp.get("width"), 100.0)) + h = max(1.0, _num(comp.get("height"), 30.0)) + if x <= px <= x + w and y <= py <= y + h: + z = _num(comp.get("sort"), 1.0) + _num(comp.get("z_offset"), 0.0) + matches.append((z, index)) + if not matches: + return -1 + matches.sort(key=lambda item: (item[0], item[1]), reverse=True) + return matches[0][1] + + def _process_pointer(self) -> None: + hit = self._hit_test_pointer() + self._update_hover(hit) + mouse_down = self._is_mouse_down() + + if mouse_down and not self._last_mouse_down: + if hit: + self._handle_press(hit) + elif mouse_down and self._active_slider_index >= 0: + current = hit or self._active_hit + if current: + self._set_slider_from_hit(self._active_slider_index, current) + elif not mouse_down and self._last_mouse_down: + self._handle_release(hit) + + self._last_mouse_down = mouse_down + + def _is_mouse_down(self) -> bool: + try: + return self.world.mouseWatcherNode.is_button_down(p3d.MouseButton.one()) + except Exception: + return False + + def _update_hover(self, hit: Optional[Dict[str, Any]]) -> None: + old_index = self._hover_hit.get("component_index") if self._hover_hit else -1 + new_index = hit.get("component_index") if hit else -1 + if old_index == new_index: + self._hover_hit = hit + return + self._set_component_flag(old_index, "world_hovered", False) + self._set_component_flag(new_index, "world_hovered", True) + self._hover_hit = hit + + def _handle_press(self, hit: Dict[str, Any]) -> None: + comp_index = hit.get("component_index", -1) + self._active_hit = hit + self._active_slider_index = -1 + + try: + if comp_index >= 0 and not getattr(self.manager, "play_mode", False): + self.manager.selected_index = comp_index + self.manager._last_selected_index = comp_index + hide_handles = getattr(self.manager, "_hide_resize_handles", None) + if callable(hide_handles): + hide_handles() + except Exception: + pass + + if comp_index < 0: + self._focused_input_index = -1 + return + + comp = self.manager.components[comp_index] + comp_type = str(comp.get("type") or "").lower() + self._set_component_flag(comp_index, "world_pressed", True) + self._invoke_component_method(comp, "on_mousedown") + + if comp_type == "slider": + self._active_slider_index = comp_index + self._set_slider_from_hit(comp_index, hit) + elif comp_type == "inputfield": + self._focused_input_index = comp_index + elif comp_type != "inputfield": + self._focused_input_index = -1 + + def _handle_release(self, hit: Optional[Dict[str, Any]]) -> None: + active = self._active_hit + self._active_hit = None + slider_index = self._active_slider_index + self._active_slider_index = -1 + + if not active: + return + comp_index = active.get("component_index", -1) + if comp_index < 0 or comp_index >= len(self.manager.components): + return + + self._set_component_flag(comp_index, "world_pressed", False) + comp = self.manager.components[comp_index] + self._invoke_component_method(comp, "on_mouseup") + + release_index = hit.get("component_index", -1) if hit else -1 + if slider_index >= 0: + self._emit_component_event(comp_index, "changed") + return + if release_index != comp_index: + return + + comp_type = str(comp.get("type") or "").lower() + if comp_type == "checkbox": + new_val = not bool(comp.get("checked", False)) + comp["checked"] = new_val + obj = comp.get("object") + try: + if obj is not None and hasattr(obj, "checked"): + obj.checked = new_val + except Exception: + pass + self._emit_component_event(comp_index, "changed") + elif comp_type == "selectbox": + self._cycle_select_option(comp_index) + self._emit_component_event(comp_index, "changed") + else: + self._emit_component_event(comp_index, "click") + + def _set_component_flag(self, index: int, key: str, value: bool) -> None: + if index is None or index < 0 or index >= len(getattr(self.manager, "components", [])): + return + comp = self.manager.components[index] + if bool(comp.get(key, False)) == bool(value): + return + comp[key] = bool(value) + canvas_index = comp.get("canvas_index") + if canvas_index is not None: + self._signatures.pop(int(canvas_index), None) + + def _invoke_component_method(self, comp: Dict[str, Any], method_name: str) -> None: + obj = comp.get("object") + if obj is None: + return + + class _Event: + coordinates = p3d.Point2(0, 0) + sender = None + + def get_modifier_state(self, _name): + return False + + try: + method = getattr(obj, method_name, None) + if callable(method): + method(_Event()) + except Exception: + pass + + def _emit_component_event(self, index: int, event_name: str) -> None: + if index < 0 or index >= len(self.manager.components): + return + comp = self.manager.components[index] + comp["last_world_event"] = event_name + comp["last_world_event_time"] = time.time() + obj = comp.get("object") + try: + if obj is not None and hasattr(obj, "trigger_event"): + obj.trigger_event(event_name) + except Exception: + pass + try: + messenger = getattr(self.world, "messenger", None) + if messenger: + messenger.send("lui-world-event", [event_name, index, comp]) + else: + from direct.showbase.MessengerGlobal import messenger as global_messenger + global_messenger.send("lui-world-event", [event_name, index, comp]) + except Exception: + pass + + def _set_slider_from_hit(self, index: int, hit: Dict[str, Any]) -> None: + if index < 0 or index >= len(self.manager.components): + return + comp = self.manager.components[index] + x, _ = self._component_abs_pos(index) + width = max(1.0, _num(comp.get("width"), 100.0)) + ratio = max(0.0, min(1.0, (_num(hit.get("x"), x) - x) / width)) + min_v = _num(comp.get("min_value", comp.get("min")), 0.0) + max_v = _num(comp.get("max_value", comp.get("max")), 100.0) + value = min_v + ratio * (max_v - min_v) + comp["value"] = value + obj = comp.get("object") + try: + if obj is not None and hasattr(obj, "set_value"): + obj.set_value(value) + elif obj is not None and hasattr(obj, "value"): + obj.value = value + except Exception: + pass + canvas_index = comp.get("canvas_index") + if canvas_index is not None: + self._signatures.pop(int(canvas_index), None) + + def _cycle_select_option(self, index: int) -> None: + comp = self.manager.components[index] + options = list(comp.get("options") or []) + if not options: + return + ids = [] + for item in options: + if isinstance(item, (list, tuple)) and item: + ids.append(item[0]) + elif isinstance(item, dict): + ids.append(item.get("id", item.get("value", item.get("label")))) + if not ids: + return + current = comp.get("selected_option_id", ids[0]) + try: + next_index = (ids.index(current) + 1) % len(ids) + except ValueError: + next_index = 0 + comp["selected_option_id"] = ids[next_index] + obj = comp.get("object") + try: + if obj is not None and hasattr(obj, "_select_option"): + obj._select_option(ids[next_index]) + except Exception: + pass + + def _process_keyboard(self) -> None: + index = self._focused_input_index + if index < 0 or index >= len(getattr(self.manager, "components", [])): + return + comp = self.manager.components[index] + if str(comp.get("type") or "").lower() != "inputfield": + return + watcher = getattr(self.world, "mouseWatcherNode", None) + if watcher is None: + return + + for key_name, char in self._keyboard_map().items(): + try: + button = p3d.KeyboardButton.ascii_key(key_name) if len(key_name) == 1 else getattr(p3d.KeyboardButton, key_name)() + except Exception: + continue + down = bool(watcher.is_button_down(button)) + prev = self._last_keyboard_buttons.get(key_name, False) + self._last_keyboard_buttons[key_name] = down + if down and not prev: + self._apply_input_key(comp, char) + + def _keyboard_map(self) -> Dict[str, str]: + mapping = {ch: ch for ch in "abcdefghijklmnopqrstuvwxyz0123456789"} + mapping.update({"space": " ", "backspace": "\b"}) + return mapping + + def _apply_input_key(self, comp: Dict[str, Any], char: str) -> None: + current = str(comp.get("value", comp.get("text", "")) or "") + if char == "\b": + current = current[:-1] + else: + current += char + comp["value"] = current + comp["text"] = current + obj = comp.get("object") + try: + if obj is not None and hasattr(obj, "value"): + obj.value = current + except Exception: + pass + self._emit_component_event(self._focused_input_index, "changed") + canvas_index = comp.get("canvas_index") + if canvas_index is not None: + self._signatures.pop(int(canvas_index), None) diff --git a/ui/lui_manager.py b/ui/lui_manager.py index 034f52b7..65e0330a 100644 --- a/ui/lui_manager.py +++ b/ui/lui_manager.py @@ -5,6 +5,13 @@ from imgui_bundle import imgui, imgui_ctx from ui.LUI.lui_manager_editor import LUIManagerEditorMixin from ui.LUI.lui_manager_interaction import LUIManagerInteractionMixin +try: + from ui.LUI.lui_world_space import WorldSpaceLUIRuntime +except Exception as exc: + WorldSpaceLUIRuntime = None + LUI_WORLD_SPACE_IMPORT_ERROR = exc +else: + LUI_WORLD_SPACE_IMPORT_ERROR = None from ui.LUI.lui_shared import ( CardMaker, LUIButton, @@ -105,6 +112,10 @@ class LUIManager(LUIManagerInteractionMixin, LUIManagerEditorMixin): self.drag_states = {} self._last_interaction_frame = -1 # 记录最后一次交互的帧数,用于防止事件穿透 self._input_enabled = True + self.debug_world_lui = str(os.environ.get("EG_LUI_WORLD_DEBUG", "1")).lower() not in ("0", "false", "off", "no") + self._debug_lui_visibility_state = {} + self.world_space_runtime = None + self._init_world_space_runtime() # 创建resize handles和selection box(延迟创建,确保在Canvas之后) if hasattr(self.world, 'taskMgr'): @@ -121,6 +132,226 @@ class LUIManager(LUIManagerInteractionMixin, LUIManagerEditorMixin): print("✓ LUIManager initialized with complete functionality") + def _init_world_space_runtime(self): + """Attach the optional Panda3D scene-space LUI mirror.""" + if WorldSpaceLUIRuntime is None: + print(f"⚠ LUI 3D 场景运行时不可用: {LUI_WORLD_SPACE_IMPORT_ERROR}") + return + try: + self.world_space_runtime = WorldSpaceLUIRuntime(self) + print("✓ LUI 3D 场景运行时已启用") + except Exception as exc: + self.world_space_runtime = None + print(f"⚠ LUI 3D 场景运行时初始化失败: {exc}") + + def _canvas_render_mode(self, canvas_data): + mode = str((canvas_data or {}).get("render_mode") or "screen").lower().strip() + return "world" if mode == "world" else "screen" + + def _debug_world_lui_enabled(self): + return bool(getattr(self, "debug_world_lui", False)) + + def _debug_lui_parent_label(self, obj): + if obj is None: + return "None" + try: + get_name = getattr(obj, "getName", None) or getattr(obj, "get_name", None) + if callable(get_name): + name = get_name() + if name: + return f"{obj.__class__.__name__}:{name}" + except Exception: + pass + try: + name = getattr(obj, "name", None) + if name: + return f"{obj.__class__.__name__}:{name}" + except Exception: + pass + return f"{obj.__class__.__name__}@{id(obj):x}" + + def _debug_lui_visible_value(self, obj): + if obj is None: + return None + try: + return getattr(obj, "visible", None) + except Exception: + return "?" + + def _debug_lui_screen_visibility(self, canvas_data, requested_visible): + if not self._debug_world_lui_enabled(): + return + panel = (canvas_data or {}).get("panel") + panel_parent = getattr(panel, "parent", None) + parent_state = "None" if panel_parent is None else "parented" + state = ( + bool(requested_visible), + parent_state, + self._debug_lui_visible_value(panel), + self._debug_lui_visible_value((canvas_data or {}).get("background")), + self._debug_lui_visible_value((canvas_data or {}).get("title_label")), + self._canvas_render_mode(canvas_data), + ) + key = id(canvas_data) + if self._debug_lui_visibility_state.get(key) == state: + return + self._debug_lui_visibility_state[key] = state + print( + "[LUI3D Debug] screen overlay visibility " + f"canvas='{(canvas_data or {}).get('name')}' " + f"mode={state[5]} requested={state[0]} " + f"panel_parent={self._debug_lui_parent_label(panel_parent)} " + f"panel_parent_state={state[1]} panel_visible={state[2]} " + f"bg_visible={state[3]} title_visible={state[4]}" + ) + + def _set_lui_object_visible_deep(self, obj, visible, visited=None): + if obj is None: + return + if visited is None: + visited = set() + obj_id = id(obj) + if obj_id in visited: + return + visited.add(obj_id) + + try: + if visible and hasattr(obj, "show"): + obj.show() + elif (not visible) and hasattr(obj, "hide"): + obj.hide() + elif hasattr(obj, "visible"): + obj.visible = bool(visible) + except Exception: + pass + + try: + if hasattr(obj, "visible"): + obj.visible = bool(visible) + except Exception: + pass + + child_attrs = ( + "_layout", "_label", "_text", "_shadow_text", "_knob", + "_slider_bg", "_slider_fill", "_sprite_left", "_sprite_mid", + "_sprite_right", "content_node", "root_layout", "header_bar", + "main_frame", "label", "text_label", "sprite", "widget", + "layout_obj", "object", + ) + for attr_name in child_attrs: + try: + child = getattr(obj, attr_name, None) + except Exception: + child = None + if child is not None: + self._set_lui_object_visible_deep(child, visible, visited) + + try: + obj_dict = getattr(obj, "__dict__", {}) or {} + except Exception: + obj_dict = {} + for value in obj_dict.values(): + if value is obj: + continue + if isinstance(value, dict): + for nested in value.values(): + self._set_lui_object_visible_deep(nested, visible, visited) + elif isinstance(value, (list, tuple, set)): + for nested in value: + self._set_lui_object_visible_deep(nested, visible, visited) + elif value is not None and value.__class__.__name__.lower().startswith("lui"): + self._set_lui_object_visible_deep(value, visible, visited) + + def _set_lui_canvas_screen_visible(self, canvas_data, visible): + panel = (canvas_data or {}).get("panel") + if panel is None: + return + visible = bool(visible) + try: + if visible: + parent = canvas_data.get("_screen_parent") + if parent is None: + parent = getattr(self, "overlay_root", None) + if parent is not None and getattr(panel, "parent", None) is None: + panel.parent = parent + else: + current_parent = getattr(panel, "parent", None) + if current_parent is not None: + canvas_data["_screen_parent"] = current_parent + panel.parent = None + self._set_lui_object_visible_deep(panel, visible) + self._set_lui_object_visible_deep(canvas_data.get("background"), visible) + self._set_lui_object_visible_deep(canvas_data.get("title_label"), visible) + except Exception: + pass + self._debug_lui_screen_visibility(canvas_data, visible) + + def _set_lui_component_screen_visible(self, comp_data, visible): + comp_obj = (comp_data or {}).get("object") + if comp_obj is None: + return + visible = bool(visible) + try: + if visible: + parent = comp_data.get("_screen_parent") + if parent is None: + canvas_index = comp_data.get("canvas_index") + if canvas_index is not None and 0 <= int(canvas_index) < len(getattr(self, "canvases", [])): + parent = self.canvases[int(canvas_index)].get("panel") + if parent is not None and getattr(comp_obj, "parent", None) is None: + comp_obj.parent = parent + else: + current_parent = getattr(comp_obj, "parent", None) + if current_parent is not None: + comp_data["_screen_parent"] = current_parent + comp_obj.parent = None + except Exception: + pass + + self._set_lui_object_visible_deep(comp_obj, visible) + for key in ( + "widget", "sprite", "text_label", "layout_obj", + "keep_alive", "keep_alive_node", + ): + self._set_lui_object_visible_deep(comp_data.get(key), visible) + + def is_world_space_pointer_over_ui(self): + runtime = getattr(self, "world_space_runtime", None) + if runtime is None: + return False + try: + return bool(runtime.is_pointer_over_ui()) + except Exception: + return False + + def has_world_space_pointer_capture(self): + runtime = getattr(self, "world_space_runtime", None) + if runtime is None: + return False + return bool(getattr(runtime, "_active_hit", None)) + + def handle_world_space_pointer_press(self): + runtime = getattr(self, "world_space_runtime", None) + if runtime is None: + return False + try: + return bool(runtime.handle_pointer_press_from_event()) + except Exception as exc: + print(f"LUI 3D UI pointer handling failed: {exc}") + return False + + def invalidate_world_space_canvas(self, canvas_index=None): + runtime = getattr(self, "world_space_runtime", None) + if runtime is None: + return + try: + if canvas_index is None: + runtime._signatures.clear() + else: + runtime._signatures.pop(int(canvas_index), None) + except Exception: + pass + def set_input_enabled(self, enabled): """Enable/disable LUI input handling (used to avoid ImGui click-through).""" if not self.lui_enabled: