1632 lines
66 KiB
Python
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)
|