EG/ui/LUI/lui_world_space.py
2026-05-25 14:53:16 +08:00

1632 lines
66 KiB
Python

"""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)