"""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._normalize_legacy_default_hpr(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 _normalize_legacy_default_hpr(self, canvas: Dict[str, Any]) -> None: if canvas.get("_world_hpr_user_set") or canvas.get("_world_legacy_hpr_normalized"): return hpr = self._triplet(canvas.get("world_hpr"), (0.0, 0.0, 0.0)) if not all(abs(value - target) < 1e-6 for value, target in zip(hpr, (25.0, 0.0, 0.0))): canvas["_world_legacy_hpr_normalized"] = True return canvas["world_hpr"] = (0.0, 0.0, 0.0) canvas["_world_legacy_hpr_normalized"] = True node = canvas.get("node") try: if node is not None and not node.isEmpty(): node.setHpr(0.0, 0.0, 0.0) except Exception: pass 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", (0.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, (0.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', True))}" ) 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", True)), "renderer": "texture_rgba8_gbuffer_scene_fixed_v5_flipped", }, "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) try: transpose = getattr(Image, "Transpose", Image) image = image.transpose(getattr(transpose, "FLIP_TOP_BOTTOM")) except Exception: pass 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", True)) 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)