This commit is contained in:
Hector 2026-02-25 11:49:31 +08:00
parent 3b1b547ed8
commit 62fe0628d8
293 changed files with 18798 additions and 7200 deletions

2
.idea/misc.xml generated
View File

@ -3,5 +3,5 @@
<component name="Black">
<option name="sdkName" value="Python 3.12 (PythonProject)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (EG)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10" project-jdk-type="Python SDK" />
</project>

View File

@ -9,14 +9,14 @@ enabled:
- color_correction
- env_probes
- forward_shading
- motion_blur
# - motion_blur # disabled for editor performance
- pssm
- scattering
- skin_shading
- sky_ao
- smaa
- ssr
- clouds
# - ssr # disabled for editor performance
#- clouds
#- dof
#- fxaa
#- volumetrics
@ -120,7 +120,7 @@ overrides:
logarithmic_factor: 3.0
sun_distance: 100.0
split_count: 4
resolution: 1024
resolution: 512
border_bias: 0.058
use_pcf: True
filter_sequence: halton_2D_32
@ -133,7 +133,7 @@ overrides:
pcss_penumbra_size: 2.38
pcss_min_penumbra_size: 7.0
use_distant_shadows: True
dist_shadow_resolution: 4096
dist_shadow_resolution: 1024
dist_shadow_clipsize: 400.0
dist_shadow_sundist: 300.0
scene_shadow_resolution: 512
@ -157,7 +157,7 @@ overrides:
sky_ao:
sample_radius: 17.17
max_radius: 500.0
resolution: 1024
resolution: 512
sample_sequence: poisson_2D_32
ao_multiplier: 0.83
ao_bias: 0.0

View File

@ -1,4 +1,5 @@
from direct.task.TaskManagerGlobal import taskMgr
from panda3d.core import KeyboardButton
class CustomMouseController:
@ -23,18 +24,30 @@ class CustomMouseController:
self.showbase.accept("mouse3-up", self.setKey, ["mouse3", False]) # 右键释放
self.showbase.accept("w", self.setKey, ["cam-forward", True])
self.showbase.accept("W", self.setKey, ["cam-forward", True])
self.showbase.accept("a", self.setKey, ["cam-left", True])
self.showbase.accept("A", self.setKey, ["cam-left", True])
self.showbase.accept("s", self.setKey, ["cam-backward", True])
self.showbase.accept("S", self.setKey, ["cam-backward", True])
self.showbase.accept("d", self.setKey, ["cam-right", True])
self.showbase.accept("D", self.setKey, ["cam-right", True])
self.showbase.accept("e", self.setKey, ["cam-up", True])
self.showbase.accept("E", self.setKey, ["cam-up", True])
self.showbase.accept("q", self.setKey, ["cam-down", True])
self.showbase.accept("Q", self.setKey, ["cam-down", True])
self.showbase.accept("w-up", self.setKey, ["cam-forward", False])
self.showbase.accept("W-up", self.setKey, ["cam-forward", False])
self.showbase.accept("a-up", self.setKey, ["cam-left", False])
self.showbase.accept("A-up", self.setKey, ["cam-left", False])
self.showbase.accept("s-up", self.setKey, ["cam-backward", False])
self.showbase.accept("S-up", self.setKey, ["cam-backward", False])
self.showbase.accept("d-up", self.setKey, ["cam-right", False])
self.showbase.accept("D-up", self.setKey, ["cam-right", False])
self.showbase.accept("e-up", self.setKey, ["cam-up", False])
self.showbase.accept("E-up", self.setKey, ["cam-up", False])
self.showbase.accept("q-up", self.setKey, ["cam-down", False])
self.showbase.accept("Q-up", self.setKey, ["cam-down", False])
self.last_mouse_x = 0
self.last_mouse_y = 0
@ -61,17 +74,25 @@ class CustomMouseController:
# 检查ImGui是否捕获了键盘输入
if self._should_handle_keyboard():
if self.keyMap["cam-left"]:
watcher = self.showbase.mouseWatcherNode
left_down = self.keyMap["cam-left"] or watcher.is_button_down(KeyboardButton.ascii_key("a"))
right_down = self.keyMap["cam-right"] or watcher.is_button_down(KeyboardButton.ascii_key("d"))
backward_down = self.keyMap["cam-backward"] or watcher.is_button_down(KeyboardButton.ascii_key("s"))
forward_down = self.keyMap["cam-forward"] or watcher.is_button_down(KeyboardButton.ascii_key("w"))
up_down = self.keyMap["cam-up"] or watcher.is_button_down(KeyboardButton.ascii_key("e"))
down_down = self.keyMap["cam-down"] or watcher.is_button_down(KeyboardButton.ascii_key("q"))
if left_down:
self.showbase.camera.setX(self.showbase.camera, -self.move_speed * dt)
if self.keyMap["cam-right"]:
if right_down:
self.showbase.camera.setX(self.showbase.camera, +self.move_speed * dt)
if self.keyMap["cam-backward"]:
if backward_down:
self.showbase.camera.setY(self.showbase.camera, -self.move_speed * dt)
if self.keyMap["cam-forward"]:
if forward_down:
self.showbase.camera.setY(self.showbase.camera, +self.move_speed * dt)
if self.keyMap["cam-up"]:
if up_down:
self.showbase.camera.setZ(self.showbase.camera, +self.move_speed * dt)
if self.keyMap["cam-down"]:
if down_down:
self.showbase.camera.setZ(self.showbase.camera, -self.move_speed * dt)
if self.keyMap["mouse3"]: # 只使用右键控制视角旋转
try:

View File

@ -254,7 +254,7 @@ class EventHandler:
return
# 根据当前工具处理点击事件
if self.world.currentTool == "选择":
if self.world.currentTool in ("选择", "移动", "旋转", "缩放"):
print("✓ 使用选择工具处理点击")
try:
self._handleSelectionClick(hitNode)
@ -589,4 +589,4 @@ class EventHandler:
self.world.selection.updateGizmoHighlight(x, y)
# 调用CoreWorld的父类方法处理基础的相机旋转
super(type(self.world), self.world).mouseMoveEvent(evt)
super(type(self.world), self.world).mouseMoveEvent(evt)

View File

@ -402,7 +402,10 @@ class ImGuiStyleManager:
if icon_path.exists():
# 使用base.imgui.loadTexture方法
if hasattr(base, 'imgui') and hasattr(base.imgui, 'loadTexture'):
return base.imgui.loadTexture(str(icon_path))
# 转换路径为Panda3D兼容格式 (Windows下: D:\... -> /d/...)
# 注意: p3dimgui.loadTexture 仅支持 str 或 Texture不支持 Filename 对象
fn = Filename.fromOsSpecific(str(icon_path))
return base.imgui.loadTexture(fn.getFullpath())
else:
print(f"⚠ ImGui后端未初始化")
return None

View File

@ -118,6 +118,30 @@ class SelectionSystem:
def _resetCursor(self):
self._setCursor("default")
def _has_new_transform_gizmo(self):
tg = getattr(self.world, "newTransform", None)
return tg is not None
def sync_transform_gizmo_mode(self):
"""Sync TransformGizmo mode with current tool."""
if not self._has_new_transform_gizmo():
return
try:
from TransformGizmo.events import TransformGizmoMode
tool_mgr = getattr(self.world, "tool_manager", None)
if not tool_mgr:
return
if tool_mgr.isRotateTool():
self.world.newTransform.set_mode(TransformGizmoMode.ROTATE)
elif tool_mgr.isScaleTool():
self.world.newTransform.set_mode(TransformGizmoMode.SCALE)
elif tool_mgr.isMoveTool() or tool_mgr.isSelectionTool():
self.world.newTransform.set_mode(TransformGizmoMode.MOVE)
else:
self.world.newTransform.set_mode(TransformGizmoMode.NONE)
except Exception as e:
print(f"sync transform gizmo mode failed: {e}")
# ==================== 选择框系统 ====================
def createSelectionBox(self, nodePath):
@ -135,8 +159,8 @@ class SelectionSystem:
self.selectionBox = self.world.render.attachNewNode("selectionBox")
self.selectionBoxTarget = nodePath
taskMgr.add(self.updateSelectionBoxTask, "updateSelectionBox")
self.updateSelectionBoxGeometry()
#taskMgr.add(self.updateSelectionBoxTask, "updateSelectionBox")
#self.updateSelectionBoxGeometry()
except Exception as e:
print(f" ✗ 创建选择框失败: {str(e)}")
@ -340,6 +364,17 @@ class SelectionSystem:
def createGizmo(self, nodePath):
"""为选中的节点创建坐标轴工具 - 保留箭头版本"""
if self._has_new_transform_gizmo():
self.gizmo = None
self.gizmoTarget = nodePath
if not nodePath:
return
self.sync_transform_gizmo_mode()
try:
self.world.newTransform.attach(nodePath)
except Exception as e:
print(f"attach TransformGizmo failed: {e}")
return
try:
#print(f" 开始创建坐标轴,目标节点: {nodePath.getName()}")
@ -818,15 +853,31 @@ class SelectionSystem:
pass
def clearGizmo(self):
"""清除坐标轴"""
"""Clear transform gizmo."""
if self._has_new_transform_gizmo():
try:
self.world.newTransform.detach()
except Exception as e:
print(f"detach TransformGizmo failed: {e}")
self.gizmo = None
self.gizmoTarget = None
self.gizmoXAxis = None
self.gizmoYAxis = None
self.gizmoZAxis = None
self.isDraggingGizmo = False
self.dragGizmoAxis = None
self.dragStartMousePos = None
self.gizmoTargetStartPos = None
self.gizmoStartPos = None
self._resetCursor()
return
if self.gizmo:
self.gizmo.removeNode()
self.gizmo = None
# 停止坐标轴更新任务
taskMgr.remove("updateGizmo")
# 清除坐标轴相关引用
self.gizmoTarget = None
self.gizmoXAxis = None
self.gizmoYAxis = None
@ -838,39 +889,8 @@ class SelectionSystem:
self.gizmoStartPos = None
self._resetCursor()
# def setGizmoAxisColor(self, axis, color):
# """设置坐标轴颜色 - RenderPipeline 兼容版本"""
# try:
# from panda3d.core import AntialiasAttrib,TransparencyAttrib
#
# axis_nodes = {
# "x": self.gizmoXAxis,
# "y": self.gizmoYAxis,
# "z": self.gizmoZAxis
# }
#
# if axis in axis_nodes and axis_nodes[axis]:
# axis_node = axis_nodes[axis]
#
# axis_node.setColor(color[0]*20.0,color[1]*20.0,color[2]*20.0,color[3])
# axis_node.setColorScale(color[0]*10.0,color[1]*10.0,color[2]*10.0,color[3])
# axis_node.setShaderOff(10000)
# axis_node.setLightOff()
# axis_node.setMaterialOff()
# axis_node.setTextureOff()
# axis_node.setFogOff()
#
# except Exception as e:
# print(f"设置坐标轴颜色失败: {str(e)}")
# # 回退到简单的颜色设置
# try:
# if axis in axis_nodes and axis_nodes[axis]:
# axis_nodes[axis].setColor(*color)
# except:
# pass
def setGizmoRotAxisColor(self, axis, color):
"""使用材质设置坐标轴颜色 - RenderPipeline兼容版本"""
try:

View File

@ -37,6 +37,12 @@ class ToolManager:
# 坐标轴现在始终跟随选中状态,不再依赖工具类型
try:
if hasattr(self.world, "selection") and self.world.selection:
self.world.selection.sync_transform_gizmo_mode()
except Exception as e:
print(f"sync gizmo mode on tool change failed: {e}")
def getCurrentTool(self):
"""获取当前工具"""
return self.currentTool

View File

@ -20,6 +20,7 @@ render_pipeline_path = os.path.join(project_root, "RenderPipelineFile")
sys.path.insert(0, render_pipeline_path)
from RenderPipelineFile.rpcore import RenderPipeline
from ssbo_component.ssbo_editor import SSBOEditor
# 从渲染管线工具模块导入全局函数
from core.render_pipeline_utils import get_render_pipeline, set_render_pipeline
@ -47,12 +48,19 @@ class CoreWorld(ShowBase):
loadPrcFileData("", f"win-size {width} {height}")
loadPrcFileData("", "win-fixed-size #f") # 允许窗口调整大小
# Performance preset to match the standalone smooth runner
loadPrcFileData("", "threading-model /Draw")
loadPrcFileData("", "pstats-gpu-timing #f")
loadPrcFileData("", "gl-debug #f")
loadPrcFileData("", "gl-debug-object-labels #f")
# VR性能优化配置
loadPrcFileData("", "prefer-single-buffer true")
loadPrcFileData("", "gl-force-flush false")
loadPrcFileData("", "sync-video false")
loadPrcFileData("", "support-stencil false")
loadPrcFileData("", "clock-mode non-real-time")
loadPrcFileData("", "support-threads false")
if is_fullscreen:
loadPrcFileData("", "fullscreen #t")
@ -60,6 +68,9 @@ class CoreWorld(ShowBase):
# 创建渲染管线
self.render_pipeline = RenderPipeline()
self.render_pipeline.pre_showbase_init()
# 强制开启多线程支持 (Video播放必需)
loadPrcFileData("force_threads", "support-threads #f")
# 初始化 ShowBase
ShowBase.__init__(self)

117
imgui.ini
View File

@ -1,5 +1,5 @@
[Window][Debug##Default]
Pos=28,731
Pos=28,465
Size=400,400
Collapsed=0
@ -24,34 +24,34 @@ Size=832,45
Collapsed=0
[Window][工具栏]
Pos=287,20
Size=1234,32
Pos=325,20
Size=1228,32
Collapsed=0
DockId=0x00000007,0
DockId=0x0000000D,0
[Window][场景树]
Pos=0,20
Size=285,776
Size=323,634
Collapsed=0
DockId=0x00000001,0
DockId=0x00000007,0
[Window][属性面板]
Pos=1523,20
Size=327,498
Pos=1555,20
Size=365,989
Collapsed=0
DockId=0x00000005,0
DockId=0x00000003,0
[Window][控制台]
Pos=880,798
Size=641,218
Pos=0,656
Size=323,353
Collapsed=0
DockId=0x0000000C,0
DockId=0x00000008,0
[Window][脚本管理]
Pos=1523,520
Size=327,496
Pos=1540,20
Size=380,390
Collapsed=0
DockId=0x00000006,0
DockId=0x00000003,1
[Window][中文显示测试]
Pos=60,60
@ -60,7 +60,7 @@ Collapsed=0
[Window][WindowOverViewport_11111111]
Pos=0,20
Size=1850,996
Size=1920,989
Collapsed=0
[Window][测试窗口1]
@ -79,30 +79,30 @@ Size=93,65
Collapsed=0
[Window][新建项目]
Pos=725,358
Pos=760,354
Size=400,300
Collapsed=0
[Window][选择路径]
Pos=625,258
Pos=660,254
Size=600,500
Collapsed=0
[Window][打开项目]
Pos=675,308
Pos=710,304
Size=500,400
Collapsed=0
[Window][导入模型]
Pos=625,258
Pos=660,254
Size=600,500
Collapsed=0
[Window][资源管理器]
Pos=0,798
Size=878,218
Pos=325,827
Size=1228,182
Collapsed=0
DockId=0x0000000B,0
DockId=0x00000006,0
[Window][创建3D文本]
Pos=60,60
@ -135,27 +135,68 @@ Size=89,250
Collapsed=0
[Window][颜色选择器]
Pos=775,308
Pos=810,304
Size=300,400
Collapsed=0
[Window][选择diffuse纹理文件##texture_dialog]
Pos=625,308
Pos=660,304
Size=600,400
Collapsed=0
[Window][创建GUI图片]
Pos=60,60
Size=101,226
Collapsed=0
[Window][LUI编辑器]
Pos=1540,412
Size=380,597
Collapsed=0
DockId=0x00000004,0
[Window][LUI测试控制面板]
Pos=6,10
Size=300,200
Collapsed=1
[Window][选择高度图文件]
Pos=60,60
Size=596,498
Collapsed=0
[Window][创建平面地形]
Pos=61,60
Size=238,176
Collapsed=0
[Window][选择normal纹理文件##texture_dialog]
Pos=660,304
Size=600,400
Collapsed=0
[Window][选择metallic纹理文件##texture_dialog]
Pos=660,304
Size=600,400
Collapsed=0
[Window][选择roughness纹理文件##texture_dialog]
Pos=660,304
Size=600,400
Collapsed=0
[Docking][Data]
DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1850,996 Split=X
DockNode ID=0x00000003 Parent=0x08BD597D SizeRef=1521,996 Split=Y
DockNode ID=0x00000009 Parent=0x00000003 SizeRef=1380,776 Split=X
DockNode ID=0x00000001 Parent=0x00000009 SizeRef=285,730 HiddenTabBar=1 Selected=0xE0015051
DockNode ID=0x00000002 Parent=0x00000009 SizeRef=1234,730 Split=Y
DockNode ID=0x00000007 Parent=0x00000002 SizeRef=1380,32 HiddenTabBar=1 Selected=0x43A39006
DockNode ID=0x00000008 Parent=0x00000002 SizeRef=1380,742 CentralNode=1 Selected=0x5E5F7166
DockNode ID=0x0000000A Parent=0x00000003 SizeRef=1380,218 Split=X Selected=0x5428E753
DockNode ID=0x0000000B Parent=0x0000000A SizeRef=878,111 HiddenTabBar=1 Selected=0x3A2E05C3
DockNode ID=0x0000000C Parent=0x0000000A SizeRef=641,111 HiddenTabBar=1 Selected=0x5428E753
DockNode ID=0x00000004 Parent=0x08BD597D SizeRef=327,996 Split=Y Selected=0x5DB6FF37
DockNode ID=0x00000005 Parent=0x00000004 SizeRef=304,498 HiddenTabBar=1 Selected=0x5DB6FF37
DockNode ID=0x00000006 Parent=0x00000004 SizeRef=304,496 HiddenTabBar=1 Selected=0x3188AB8D
DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1920,989 Split=X
DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1553,989 Split=X
DockNode ID=0x00000009 Parent=0x00000001 SizeRef=323,989 Split=Y Selected=0xE0015051
DockNode ID=0x00000007 Parent=0x00000009 SizeRef=271,634 Selected=0xE0015051
DockNode ID=0x00000008 Parent=0x00000009 SizeRef=271,353 Selected=0x5428E753
DockNode ID=0x0000000A Parent=0x00000001 SizeRef=1228,989 Split=Y
DockNode ID=0x0000000D Parent=0x0000000A SizeRef=1318,32 HiddenTabBar=1 Selected=0x43A39006
DockNode ID=0x0000000E Parent=0x0000000A SizeRef=1318,955 Split=Y
DockNode ID=0x00000005 Parent=0x0000000E SizeRef=1341,771 CentralNode=1
DockNode ID=0x00000006 Parent=0x0000000E SizeRef=1341,182 Selected=0x3A2E05C3
DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=365,989 Split=Y Selected=0x3188AB8D
DockNode ID=0x00000003 Parent=0x00000002 SizeRef=351,390 Selected=0x5DB6FF37
DockNode ID=0x00000004 Parent=0x00000002 SizeRef=351,597 Selected=0x1EB923B7

7763
main.py

File diff suppressed because it is too large Load Diff

View File

@ -99,3 +99,4 @@ xdg==5
xkit==0.0.0
zipp==1.0.0
openvr==2.2.0
imgui-bundle

67
ssbo_component/README.md Normal file
View File

@ -0,0 +1,67 @@
# SSBO Editor Component Usage Guide
This directory contains a modular SSBO Scene Editor component for Panda3D RenderPipeline.
## 📁 File Structure
- `ssbo_editor.py`: Main component class `SSBOEditor`. Handles ImGui, RP integration, and input.
- `ssbo_controller.py`: Core logic for object ID baking and SSBO matrix packing.
- `effects/ssbo_instancing.yaml`: The custom shader effect for RenderPipeline (includes Normal Matrix fix).
- `demo_component.py`: Example usage script.
## 🚀 How to Integrate
### 1. Prerequisites
Ensure your project has:
- `RenderPipeline` setup
- `imgui_bundle` and `p3dimgui` installed
- `panda3d` (version 1.10.15+)
### 2. Basic Setup (See demo_component.py)
```python
from direct.showbase.ShowBase import ShowBase
from rpcore import RenderPipeline
from ssbo_component.ssbo_editor import SSBOEditor
class MyApp(ShowBase):
def __init__(self):
# 1. Initialize RP
self.rp = RenderPipeline()
self.rp.pre_showbase_init()
super().__init__()
self.rp.create(self)
# 2. Add SSBO Component
# Pass your ShowBase instance (self), the RP instance (self.rp), and your model path
self.editor = SSBOEditor(
base_app=self,
render_pipeline=self.rp,
model_path="path/to/your/model.glb",
font_path="path/to/chinese/font.ttc" # Optional
)
app = MyApp()
app.run()
```
### 3. Key Features
- **SSBO Instancing**: Efficiently renders thousands of objects using hardware instancing.
- **Normal Correction**: Default shaders are patched to correctly handle non-uniform scaling (Inverse Transpose Normal Matrix).
- **ImGui Editor**: Built-in scene tree browsing, search, and selection.
- **Object Manipulation**: Select and move objects (Arrows + Z/X keys).
- **Shadow Integration**: Correctly injects SSBO data into RP shadow passes.
- **Motion Blur Support**: Computes per-object velocity for correct motion blur.
### 4. Notes
- **Model Preparation**: The input model should have a clear hierarchy. The component will flatten it but preserve logical objects for selection.
- **Effects Path**: The `ssbo_instancing.yaml` is loaded relative to the `ssbo_editor.py` file, so the directory structure inside `ssbo_component` should be preserved.
- **GPU Picking**: Basic GPU picking is implemented using shaders in `ssbo_component/shaders/`. These are loaded automatically relative to the component path.
## ⚠️ Important for RenderPipeline
If you encounter `ImportError: No module named 'rplibs.six.moves'`, ensure you apply the compatibility fix at the start of your main script (as seen in `demo_component.py`).

View File

@ -0,0 +1,77 @@
"""
SSBO Editor Component Demo
==========================
Demonstrates how to usage the SSBOEditor component in a new project.
"""
import sys
import os
# ============================================================
# [CRITICAL] RenderPipeline Python Compatibility Fix
# ============================================================
if "d:/panda3d/RenderPipeline" not in sys.path:
sys.path.insert(0, "d:/panda3d/RenderPipeline")
try:
import rplibs.six
import six
if not hasattr(rplibs.six, 'moves'):
rplibs.six.moves = six.moves
sys.modules['rplibs.six.moves'] = six.moves
except ImportError:
import six
import types
if "rplibs" not in sys.modules:
rplibs = types.ModuleType("rplibs")
sys.modules["rplibs"] = rplibs
if "rplibs.six" not in sys.modules:
rplibs.six = six
sys.modules["rplibs.six"] = six
sys.modules["rplibs.six.moves"] = six.moves
# ============================================================
# Main Application
# ============================================================
from direct.showbase.ShowBase import ShowBase
from panda3d.core import loadPrcFileData, Filename
from rpcore import RenderPipeline
# Import our new component
# Assuming d:/panda3d is in python path, or running from d:/panda3d
from ssbo_component.ssbo_editor import SSBOEditor
loadPrcFileData("", "show-frame-rate-meter true")
loadPrcFileData("", "sync-video false")
class MyGame(ShowBase):
def __init__(self):
# 1. RP Init
self.rp = RenderPipeline()
self.rp.pre_showbase_init()
super().__init__()
# Position camera before RP create to avoid huge motion blur jump
self.cam.setPos(0, -500, 200)
self.cam.lookAt(0, 0, 0)
self.rp.create(self)
self.rp.daytime_mgr.time = 0.415
# 2. Use the Component
# Point to your model
model_path = "d:/panda3d/panda3d/jyc.glb"
font_path = "d:/panda3d/font/msyh.ttc"
print("Initializing SSBO Editor Component...")
self.editor = SSBOEditor(
base_app=self,
render_pipeline=self.rp,
model_path=model_path,
font_path=font_path
)
print("Demo ready.")
if __name__ == "__main__":
app = MyGame()
app.run()

View File

@ -0,0 +1,36 @@
vertex:
inout: |
// SSBO Layout - Default column_major matches Panda3D's internal layout
layout(std430) buffer transforms {
mat4 transform_data[];
};
// Input from Vertex Color (Baked ID)
in vec4 p3d_Color;
transform: |
// Decode Local ID (p3d_Color stores Local ID within chunk)
int low = int(p3d_Color.r * 255.0 + 0.5);
int high = int(p3d_Color.g * 255.0 + 0.5);
int id = high * 256 + low;
id = clamp(id, 0, 65535);
// Fetch Matrix from per-chunk SSBO
mat4 model = transform_data[id];
// Transform Position
vOutput.position = (model * p3d_Vertex).xyz;
// Transform Normal
vOutput.normal = normalize(mat3(model) * p3d_Normal);
post_transform: |
// Motion blur disabled for performance
fragment:
inout: |
material: |
vec4 tex_color = texture(p3d_Texture0, vOutput.texcoord);
if (tex_color.a < 0.5) discard;

View File

@ -0,0 +1,21 @@
#version 430
// Input from vertex shader
flat in int v_instance_id;
// Output: Object ID encoded as RGB color
out vec4 fragColor;
void main() {
// Encode instance ID into RGB channels
// R = low byte, G = high byte, B = 0, A = 1
int low_byte = v_instance_id & 0xFF;
int high_byte = (v_instance_id >> 8) & 0xFF;
fragColor = vec4(
float(low_byte) / 255.0,
float(high_byte) / 255.0,
0.0,
1.0
);
}

View File

@ -0,0 +1,21 @@
#version 430
// No SSBO — vertices are already in world space.
// Just read vertex color for object ID and output it.
in vec4 p3d_Vertex;
in vec4 p3d_Color;
uniform mat4 p3d_ModelViewProjectionMatrix;
flat out int v_instance_id;
void main() {
// Decode object ID from vertex color R/G channels
int low_byte = int(p3d_Color.r * 255.0 + 0.5);
int high_byte = int(p3d_Color.g * 255.0 + 0.5);
v_instance_id = low_byte + (high_byte << 8);
// Standard transform — vertices already in world space
gl_Position = p3d_ModelViewProjectionMatrix * p3d_Vertex;
}

View File

@ -0,0 +1,550 @@

from panda3d.core import (
GeomVertexFormat, GeomVertexWriter, GeomVertexReader, GeomVertexRewriter,
InternalName, Vec3, Vec4, LMatrix4f, ShaderBuffer, GeomEnums,
BoundingSphere, NodePath, GeomNode, Texture, SamplerState,
Point3, BoundingBox, Quat
)
import struct
import time
class ObjectController:
"""
物体控制器 (No Custom Shader Mode)
====================================
Uses RP's default rendering (no rp.set_effect) for maximum FPS.
Vertex colors baked for picking. Movement modifies vertex data directly.
Stores original vertex positions per object for rotation/translation.
"""
def __init__(self):
self.name_to_ids = {}
self.id_to_name = {}
self.key_to_node = {}
self.node_list = []
self.display_names = {}
self.global_transforms = [] # Original transforms (for center/position)
self.id_to_chunk = {} # global_id -> (chunk_key, local_idx)
self.chunks = {} # chunk_key -> dict with 'node' key
# Vertex index: local_id -> list of (geom_node_np, geom_idx, [row_indices])
self.vertex_index = {}
# Original vertex positions: local_id -> list of (Vec3,) matching row order
self.original_positions = {}
# Current position offsets: local_id -> Vec3 delta
self.position_offsets = {}
self.local_to_global_id = {}
self.local_transform_state = {}
self.local_transform_base_positions = {}
self.virtual_tree = None
self.virtual_tree_meta = None
self.model = None
self.chunk_node = None # Single chunk node
def bake_ids_and_collect(self, model):
"""
Bake IDs into vertex colors, flatten, then build vertex index.
NO transform reset vertices keep world-space positions.
NO SSBO uses RP default rendering.
"""
t0 = time.time()
geom_nodes = list(model.find_all_matches("**/+GeomNode"))
print(f"[控制器] 找到 {len(geom_nodes)} 个 GeomNode")
self.name_to_ids = {}
self.id_to_name = {}
self.key_to_node = {}
self.node_list = []
self.display_names = {}
self.global_transforms = []
self.id_to_chunk = {}
self.chunks = {}
self.vertex_index = {}
self.original_positions = {}
self.position_offsets = {}
self.local_to_global_id = {}
self.local_transform_state = {}
self.local_transform_base_positions = {}
self.virtual_tree = None
self.virtual_tree_meta = None
global_id_counter = 0
chunk_key = model.get_name() or "default"
# No chunk wrapper — flatten directly on model (same as load_jyc_flatten.py)
self.chunk_node = model
self.chunks[chunk_key] = {'node': model, 'base_id': 0}
# Flatten hierarchy
for np in geom_nodes:
np.wrt_reparent_to(model)
local_idx = 0
for np in geom_nodes:
gnode = np.node()
if gnode.get_num_parents() > 1:
parent = np.get_parent()
if not parent.is_empty():
new_np = np.copy_to(parent)
np.detach_node()
np = new_np
gnode = np.node()
unique_key = str(np)
display_name = np.get_name() or f"Object_{global_id_counter}"
if unique_key not in self.name_to_ids:
self.name_to_ids[unique_key] = []
self.key_to_node[unique_key] = np
self.node_list.append(unique_key)
self.display_names[unique_key] = display_name
# Save original transform
mat_double = np.get_mat()
original_transform = LMatrix4f(mat_double)
for i in range(gnode.get_num_geoms()):
geom = gnode.modify_geom(i)
vdata = geom.modify_vertex_data()
if not vdata.has_column("color"):
new_format = vdata.get_format().get_union_format(GeomVertexFormat.get_v3c4())
vdata.set_format(new_format)
# Encode Local ID in R/G
low = local_idx % 256
high = local_idx // 256
r = low / 255.0
g = high / 255.0
writer = GeomVertexWriter(vdata, InternalName.make("color"))
for row in range(vdata.get_num_rows()):
writer.set_row(row)
writer.set_data4f(r, g, 0.0, 1.0)
self.global_transforms.append(original_transform)
self.id_to_chunk[global_id_counter] = (chunk_key, local_idx)
self.name_to_ids[unique_key].append(global_id_counter)
self.id_to_name[global_id_counter] = unique_key
self.local_to_global_id[local_idx] = global_id_counter
self.position_offsets[local_idx] = Vec3(0, 0, 0)
global_id_counter += 1
local_idx += 1
# DO NOT reset transform — keep world-space positions
# Flatten directly on model — NO set_final, allows per-geom frustum culling
model.flatten_strong()
t1 = time.time()
print(f"[控制器] Flatten took {(t1-t0)*1000:.0f}ms")
# Build vertex index AFTER flatten
self._build_vertex_index(model)
self._init_local_transform_state()
self.build_virtual_hierarchy()
t2 = time.time()
print(f"[控制器] Vertex index built in {(t2-t1)*1000:.0f}ms, "
f"{len(self.vertex_index)} unique IDs indexed")
self.model = model
self.node_list.sort()
return global_id_counter
def build_virtual_hierarchy(self):
"""Build a readonly virtual tree from node_list path keys."""
root = {
"name": "",
"path": "",
"children": {},
"leaf_key": None,
"display_name": "",
}
max_depth = 0
leaf_count = 0
for key in self.node_list:
if not key:
continue
parts = [p for p in str(key).split("/") if p]
if not parts:
continue
max_depth = max(max_depth, len(parts))
cursor = root
path_acc = ""
for i, part in enumerate(parts):
path_acc = f"{path_acc}/{part}" if path_acc else part
child = cursor["children"].get(part)
if child is None:
child = {
"name": part,
"path": path_acc,
"children": {},
"leaf_key": None,
"display_name": part,
}
cursor["children"][part] = child
cursor = child
if i == len(parts) - 1:
cursor["leaf_key"] = key
cursor["display_name"] = self.display_names.get(key, part)
leaf_count += 1
self.virtual_tree = root
self.virtual_tree_meta = {"max_depth": max_depth, "leaf_count": leaf_count}
return root
def get_virtual_hierarchy(self):
"""Return cached virtual tree; build on demand."""
if self.virtual_tree is None:
return self.build_virtual_hierarchy()
return self.virtual_tree
def _build_vertex_index(self, chunk_root):
"""
After flatten, batch-read all vertex data with numpy to build:
local_id -> [(geom_node_np, geom_idx, row_indices_array)]
Also stores original vertex positions per object (as numpy arrays).
"""
import numpy as np
for gn_np in chunk_root.find_all_matches("**/+GeomNode"):
gnode = gn_np.node()
for gi in range(gnode.get_num_geoms()):
geom = gnode.get_geom(gi)
vdata = geom.get_vertex_data()
num_rows = vdata.get_num_rows()
if num_rows == 0:
continue
# Find vertex and color column info
fmt = vdata.get_format()
# Get position column
pos_col = fmt.get_column(InternalName.get_vertex())
if pos_col is None:
continue
pos_array_idx = fmt.get_array_with(InternalName.get_vertex())
pos_start = pos_col.get_start()
# Get color column
color_col = fmt.get_column(InternalName.make("color"))
if color_col is None:
continue
color_array_idx = fmt.get_array_with(InternalName.make("color"))
color_start = color_col.get_start()
# Read raw position array
pos_array_format = fmt.get_array(pos_array_idx)
pos_stride = pos_array_format.get_stride()
pos_handle = vdata.get_array(pos_array_idx).get_handle()
pos_raw = bytes(pos_handle.get_data())
pos_buf = np.frombuffer(pos_raw, dtype=np.uint8).reshape(num_rows, pos_stride)
# Extract xyz positions (3 floats starting at pos_start)
positions = np.ndarray((num_rows, 3), dtype=np.float32,
buffer=pos_buf[:, pos_start:pos_start+12].tobytes())
# Read raw color array
color_array_format = fmt.get_array(color_array_idx)
color_stride = color_array_format.get_stride()
if color_array_idx == pos_array_idx:
color_buf = pos_buf
else:
color_handle = vdata.get_array(color_array_idx).get_handle()
color_raw = bytes(color_handle.get_data())
color_buf = np.frombuffer(color_raw, dtype=np.uint8).reshape(num_rows, color_stride)
# Decode color format to get ID
# Color can be stored as float32 RGBA or unorm8 RGBA
num_components = color_col.get_num_components()
component_bytes = color_col.get_component_bytes()
if component_bytes == 4: # float32 per component
color_data = np.ndarray((num_rows, num_components), dtype=np.float32,
buffer=color_buf[:, color_start:color_start+num_components*4].tobytes())
r_vals = (color_data[:, 0] * 255.0 + 0.5).astype(np.int32)
g_vals = (color_data[:, 1] * 255.0 + 0.5).astype(np.int32)
elif component_bytes == 1: # uint8 per component
color_bytes = color_buf[:, color_start:color_start+num_components].copy()
r_vals = color_bytes[:, 0].astype(np.int32)
g_vals = color_bytes[:, 1].astype(np.int32)
else:
# Fallback: skip this geom
continue
local_ids = r_vals + (g_vals << 8)
# Group rows by local_id using argsort (O(N log N) instead of O(N×K))
sort_idx = np.argsort(local_ids)
sorted_ids = local_ids[sort_idx]
sorted_positions = positions[sort_idx]
# Find group boundaries
boundaries = np.where(np.diff(sorted_ids) != 0)[0] + 1
# Split into groups
id_groups = np.split(sort_idx, boundaries)
pos_groups = np.split(sorted_positions, boundaries)
group_ids = sorted_ids[np.concatenate([[0], boundaries])]
for k in range(len(group_ids)):
uid = int(group_ids[k])
rows = id_groups[k]
pos = pos_groups[k]
if uid not in self.vertex_index:
self.vertex_index[uid] = []
self.original_positions[uid] = []
self.vertex_index[uid].append((gn_np, gi, rows))
self.original_positions[uid].append(pos.copy())
def _init_local_transform_state(self):
"""Initialize transform state for each local_idx after vertex index is ready."""
self.local_transform_state = {}
self.local_transform_base_positions = {}
for local_idx in self.vertex_index.keys():
self.local_transform_base_positions[local_idx] = self.original_positions.get(local_idx, [])
self.local_transform_state[local_idx] = {
"offset": Vec3(0, 0, 0),
"quat": Quat.identQuat(),
"scale": Vec3(1, 1, 1),
"pivot": self.get_local_pivot(local_idx),
}
def get_local_indices_from_global_ids(self, global_ids):
"""Map global ids to unique local indices."""
local_indices = []
if not global_ids:
return local_indices
seen = set()
for global_id in global_ids:
mapping = self.id_to_chunk.get(global_id)
if not mapping:
continue
_, local_idx = mapping
if local_idx in seen:
continue
if local_idx not in self.vertex_index:
continue
seen.add(local_idx)
local_indices.append(local_idx)
return local_indices
def get_local_pivot(self, local_idx):
"""Get pivot for one local object (world-space center)."""
global_id = self.local_to_global_id.get(local_idx)
if global_id is None:
return Vec3(0, 0, 0)
return self.get_object_center(global_id)
def get_selection_center(self, local_indices):
"""Get center point for a multi-object selection."""
if not local_indices:
return Vec3(0, 0, 0)
acc = Vec3(0, 0, 0)
valid = 0
for local_idx in local_indices:
state = self.local_transform_state.get(local_idx)
if not state:
continue
acc += state.get("pivot", Vec3(0, 0, 0)) + state.get("offset", Vec3(0, 0, 0))
valid += 1
if valid == 0:
return Vec3(0, 0, 0)
return acc / float(valid)
def begin_transform_session(self, local_indices):
"""Create immutable baseline snapshot for one gizmo drag session."""
if not local_indices:
return {"locals": {}}
locals_snapshot = {}
for local_idx in local_indices:
base_state = self.local_transform_state.get(local_idx)
if not base_state:
continue
entries = self.vertex_index.get(local_idx, [])
base_positions = self.local_transform_base_positions.get(local_idx, [])
locals_snapshot[local_idx] = {
"offset": Vec3(base_state["offset"]),
"quat": Quat(base_state["quat"]),
"scale": Vec3(base_state["scale"]),
"pivot": Vec3(base_state["pivot"]),
"entries": entries,
"base_positions": base_positions,
}
return {"locals": locals_snapshot}
def apply_transform_session(self, snapshot, delta_pos, delta_quat, delta_scale):
"""Apply transform delta to all local indices in snapshot and rewrite vertices."""
import numpy as np
if not snapshot or "locals" not in snapshot:
return
if delta_pos is None:
delta_pos = Vec3(0, 0, 0)
if delta_quat is None:
delta_quat = Quat.identQuat()
if delta_scale is None:
delta_scale = Vec3(1, 1, 1)
dscale = np.array([delta_scale.x, delta_scale.y, delta_scale.z], dtype=np.float32)
dpos = np.array([delta_pos.x, delta_pos.y, delta_pos.z], dtype=np.float32)
for local_idx, local_data in snapshot["locals"].items():
base_offset = local_data["offset"]
base_quat = local_data["quat"]
base_scale = local_data["scale"]
pivot = local_data["pivot"]
final_offset = Vec3(base_offset) + delta_pos
final_quat = Quat(delta_quat * base_quat)
final_scale = Vec3(
base_scale.x * delta_scale.x,
base_scale.y * delta_scale.y,
base_scale.z * delta_scale.z,
)
rot_mat = self._quat_to_np_mat3(final_quat)
self.local_transform_state[local_idx]["offset"] = final_offset
self.local_transform_state[local_idx]["quat"] = final_quat
self.local_transform_state[local_idx]["scale"] = final_scale
self.position_offsets[local_idx] = final_offset
pivot_np = np.array([pivot.x, pivot.y, pivot.z], dtype=np.float32)
base_s = np.array([base_scale.x, base_scale.y, base_scale.z], dtype=np.float32)
total_scale = base_s * dscale
total_offset = np.array([base_offset.x, base_offset.y, base_offset.z], dtype=np.float32) + dpos
entries = local_data["entries"]
base_positions = local_data["base_positions"]
for i, (gn_np, gi, rows) in enumerate(entries):
if i >= len(base_positions):
continue
orig_pos = base_positions[i]
if orig_pos is None or len(orig_pos) == 0:
continue
centered = orig_pos - pivot_np
scaled = centered * total_scale
rotated = scaled @ rot_mat.T
new_pos = rotated + pivot_np + total_offset
gnode = gn_np.node()
geom = gnode.modify_geom(gi)
vdata = geom.modify_vertex_data()
writer = GeomVertexWriter(vdata, "vertex")
for j in range(len(rows)):
writer.set_row(int(rows[j]))
writer.set_data3f(float(new_pos[j, 0]), float(new_pos[j, 1]), float(new_pos[j, 2]))
def _quat_to_np_mat3(self, quat):
"""Convert Panda3D Quat to 3x3 numpy rotation matrix."""
import numpy as np
q = Quat(quat)
q.normalize()
w = float(q.getR())
x = float(q.getI())
y = float(q.getJ())
z = float(q.getK())
xx = x * x
yy = y * y
zz = z * z
xy = x * y
xz = x * z
yz = y * z
wx = w * x
wy = w * y
wz = w * z
return np.array([
[1.0 - 2.0 * (yy + zz), 2.0 * (xy - wz), 2.0 * (xz + wy)],
[2.0 * (xy + wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz - wx)],
[2.0 * (xz - wy), 2.0 * (yz + wx), 1.0 - 2.0 * (xx + yy)],
], dtype=np.float32)
def create_ssbo(self):
"""No SSBO needed — using RP default rendering."""
return None
def move_object(self, global_id, delta):
"""
Move an object by modifying vertex positions directly.
delta: Vec3 translation to apply.
Uses numpy for batch vertex updates.
"""
import numpy as np
if global_id not in self.id_to_chunk:
return
_, local_idx = self.id_to_chunk[global_id]
if local_idx not in self.vertex_index:
return
# Accumulate offset
self.position_offsets[local_idx] = self.position_offsets.get(local_idx, Vec3(0)) + delta
offset = self.position_offsets[local_idx]
offset_arr = np.array([offset.x, offset.y, offset.z], dtype=np.float32)
# Update each (geom_node, geom_idx, rows) group
entries = self.vertex_index[local_idx]
originals = self.original_positions[local_idx]
for i, (gn_np, gi, rows) in enumerate(entries):
orig_pos = originals[i] # numpy array (N, 3)
new_pos = orig_pos + offset_arr # vectorized add
gnode = gn_np.node()
geom = gnode.modify_geom(gi)
vdata = geom.modify_vertex_data()
writer = GeomVertexWriter(vdata, "vertex")
for j in range(len(rows)):
writer.set_row(int(rows[j]))
writer.set_data3f(float(new_pos[j, 0]), float(new_pos[j, 1]), float(new_pos[j, 2]))
def get_world_pos(self, global_id):
"""Get current world position of an object."""
if global_id not in self.id_to_chunk:
return Vec3(0, 0, 0)
_, local_idx = self.id_to_chunk[global_id]
original_mat = self.global_transforms[global_id]
original_pos = original_mat.get_row3(3)
offset = self.position_offsets.get(local_idx, Vec3(0))
return Vec3(original_pos) + offset
def get_object_center(self, global_id):
"""Get the original center position of an object (for rotation pivot)."""
if global_id >= len(self.global_transforms):
return Vec3(0, 0, 0)
mat = self.global_transforms[global_id]
return Vec3(mat.get_row3(3))
def get_transform(self, global_id):
"""Get original transform."""
if global_id >= len(self.global_transforms):
return LMatrix4f.ident_mat()
return self.global_transforms[global_id]
@property
def transforms(self):
return self.global_transforms

View File

@ -0,0 +1,617 @@
import sys
import os
import struct
import time
from panda3d.core import (
Filename, loadPrcFileData, GeomVertexFormat,
GeomVertexWriter, InternalName, Shader, Texture, SamplerState,
Vec3, Vec4, Point2, Point3, LMatrix4f, ShaderBuffer, GeomEnums, OmniBoundingVolume, Quat,
TransparencyAttrib, BoundingSphere, NodePath,
GraphicsEngine, WindowProperties, FrameBufferProperties,
GraphicsPipe, GraphicsOutput, Camera, DisplayRegion, OrthographicLens,
BoundingBox
)
import p3dimgui.backend as p3dimgui_backend
import p3dimgui.shaders as p3dimgui_shaders
from imgui_bundle import imgui
from rpcore.effect import Effect
# Work around p3dimgui import-order issue where backend may import an unrelated
# top-level "shaders" module and miss these globals.
if not hasattr(p3dimgui_backend, "VERT_SHADER"):
p3dimgui_backend.VERT_SHADER = p3dimgui_shaders.VERT_SHADER
if not hasattr(p3dimgui_backend, "FRAG_SHADER"):
p3dimgui_backend.FRAG_SHADER = p3dimgui_shaders.FRAG_SHADER
ImGuiBackend = p3dimgui_backend.ImGuiBackend
from .ssbo_controller import ObjectController
class SSBOEditor:
"""
SSBO Editor Component
====================
Encapsulates the SSBO rendering, ImGui editor, and interaction logic.
Can be integrated into any ShowBase application using RenderPipeline.
"""
def __init__(self, base_app, render_pipeline, model_path=None, font_path=None):
self.base = base_app
self.rp = render_pipeline
self.controller = None
self.model = None
self.ssbo = None
self.font_path = font_path
# Picking resources may be created later when a model is loaded.
self.pick_buffer = None
self.pick_texture = None
self.pick_cam = None
self.pick_cam_np = None
self.pick_lens = None
# Internal State
self.selected_name = None
self.selected_ids = []
self.search_text = ""
self.last_search_text = None
self.filtered_nodes = []
self.debug_mode = False
self.keys = {}
self._ssbo_transform_active = False
self._ssbo_selected_local_indices = []
self._ssbo_transform_snapshot = None
self._ssbo_gizmo_proxy = None
self._ssbo_proxy_start = {"pos": None, "quat": None, "scale": None}
self._bound_transform_gizmo = None
# Initialize ImGui Backend if not already present
if not hasattr(self.base, 'imgui_backend'):
print("[SSBOEditor] Initializing ImGui Backend...")
self.base.imgui_backend = ImGuiBackend()
self.load_font()
# Register Events
self.base.accept("imgui-new-frame", self.draw_imgui)
self.base.accept("f", self.focus_on_selected)
self.base.accept("d", self.toggle_debug)
self.base.accept("mouse1", self.on_mouse_click)
# Register Input Tasks
for key in ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'z', 'x']:
self.base.accept(key, self.keys.__setitem__, [key, True])
self.base.accept(f"{key}-up", self.keys.__setitem__, [key, False])
# Add Tasks
self.base.taskMgr.add(self.update_task, "update_task")
# Load Model if provided
if model_path:
self.load_model(model_path)
def load_font(self):
"""Load custom font for ImGui"""
io = imgui.get_io()
# Load Chinese Glyph Ranges
glyph_ranges = None
try:
if hasattr(io.fonts, 'get_glyph_ranges_chinese_full'):
glyph_ranges = io.fonts.get_glyph_ranges_chinese_full()
elif hasattr(io.fonts, 'get_glyph_ranges_chinese_simplified_common'):
glyph_ranges = io.fonts.get_glyph_ranges_chinese_simplified_common()
except Exception as e:
print(f"[SSBOEditor] Warning: Could not get Chinese glyph ranges: {e}")
try:
if self.font_path and os.path.exists(self.font_path):
io.fonts.clear()
# If glyph_ranges is None, it uses default (Basic Latin)
if glyph_ranges:
io.fonts.add_font_from_file_ttf(self.font_path, 18.0, glyph_ranges=glyph_ranges)
else:
io.fonts.add_font_from_file_ttf(self.font_path, 18.0)
else:
# Fallback to default or common font
default_font = os.path.join(os.path.dirname(os.path.dirname(__file__)), "font", "msyh.ttc")
if os.path.exists(default_font):
io.fonts.clear()
io.fonts.add_font_from_file_ttf(default_font, 18.0, glyph_ranges=glyph_ranges)
else:
io.fonts.clear()
io.fonts.add_font_default()
except Exception as e:
print(f"[SSBOEditor] Font load error: {e}")
io.fonts.clear()
io.fonts.add_font_default()
def load_model(self, model_path):
"""Load and process a model — NO custom shader, uses RP default rendering."""
print(f"[SSBOEditor] Loading model: {model_path}")
fn = Filename.fromOsSpecific(model_path)
self.model = self.base.loader.loadModel(fn)
self.controller = ObjectController()
count = self.controller.bake_ids_and_collect(self.model)
self._ssbo_transform_active = False
self._ssbo_selected_local_indices = []
self._ssbo_transform_snapshot = None
self._cleanup_ssbo_proxy()
self.model.reparent_to(self.base.render)
# NO rp.set_effect() — use RP default rendering for max FPS
# NO SSBO creation — vertex positions are baked
# Setup GPU Picking (uses simple vertex-color shader)
self.setup_gpu_picking()
print(f"[SSBOEditor] Model loaded. Total objects: {count}")
# No custom effect needed — RP default rendering for maximum FPS
def _inject_ssbo_into_shadow_state(self, effect_path):
"""Inject SSBO inputs into RP shadow tag state"""
try:
if not hasattr(self.rp.tag_mgr, 'containers'): return
shadow_container = self.rp.tag_mgr.containers.get("shadow")
if not shadow_container: return
tag_value = self.model.get_tag(shadow_container.tag_name)
if not tag_value: return
effect = Effect.load(effect_path, {})
if effect is None: return
shadow_shader = effect.get_shader_obj("shadow")
if shadow_shader is None: return
# Since inputs are now on Nodes (Chunks), we just need to ensure the shader is applied.
# extra_inputs is no longer needed if the inputs are on the nodes themselves?
# Wait, RP might override state.
# But specific shader inputs on NodePath have priority over State inputs usually?
# Let's try applying without extra inputs first.
self.rp.tag_mgr.apply_state(
"shadow", self.model, shadow_shader,
tag_value, 65)
print(f"[SSBO Shadow] Re-applied shadow state (tag='{tag_value}')")
except Exception as e:
print(f"[SSBO Shadow] Error injecting shadow state: {e}")
def setup_gpu_picking(self):
"""Setup GPU Picking (Basic implementation)"""
# ... (Buffer setup code remains same) ...
win_props = WindowProperties()
win_props.set_size(1, 1)
fb_props = FrameBufferProperties()
fb_props.set_rgba_bits(8, 8, 8, 8)
fb_props.set_depth_bits(16)
self.pick_buffer = self.base.graphicsEngine.make_output(
self.base.pipe, "pick_buffer", -100,
fb_props, win_props,
GraphicsPipe.BF_refuse_window,
self.base.win.get_gsg(), self.base.win
)
if not self.pick_buffer:
print("[GPU Picking] Failed to create buffer!")
return
self.pick_texture = Texture()
self.pick_texture.set_minfilter(Texture.FT_nearest)
self.pick_texture.set_magfilter(Texture.FT_nearest)
self.pick_buffer.add_render_texture(self.pick_texture, GraphicsOutput.RTM_copy_ram)
self.pick_cam = Camera("pick_camera")
self.pick_cam_np = self.base.cam.attach_new_node(self.pick_cam)
self.pick_lens = self.base.camLens.make_copy()
self.pick_cam.set_lens(self.pick_lens)
dr = self.pick_buffer.make_display_region()
dr.set_camera(self.pick_cam_np)
# Load pick shader
current_dir = os.path.dirname(os.path.abspath(__file__))
pick_vert = os.path.join(current_dir, "shaders", "pick_id.vert")
pick_frag = os.path.join(current_dir, "shaders", "pick_id.frag")
pick_vert = Filename.fromOsSpecific(pick_vert).getFullpath()
pick_frag = Filename.fromOsSpecific(pick_frag).getFullpath()
try:
pick_shader = Shader.load(
Shader.SL_GLSL,
pick_vert,
pick_frag
)
self.pick_cam.set_scene(self.model)
initial_state = NodePath("initial")
initial_state.set_shader(pick_shader, 100)
# Remove global SSBO input, Chunks have their own inputs
# initial_state.set_shader_input("transforms", ssbo)
self.pick_cam.set_initial_state(initial_state.get_state())
except Exception as e:
print(f"[GPU Picking] Warning: pick shaders failed to load: {e}")
print("Picking disabled.")
return
self.pick_buffer.set_active(False)
self.pick_buffer.set_clear_color(Vec4(0, 0, 0, 0))
self.pick_buffer.set_clear_color_active(True)
def pick_object(self, mx, my):
if (not self.pick_buffer or not self.pick_texture or not self.pick_lens or
not self.controller or not self.model):
return False
self.pick_lens.set_fov(0.1)
self.pick_lens.set_film_offset(0, 0)
self.pick_cam.set_lens(self.pick_lens)
near_point = Point3()
far_point = Point3()
self.base.camLens.extrude(Point2(mx, my), near_point, far_point)
self.pick_cam_np.set_pos(0, 0, 0)
self.pick_cam_np.look_at(far_point)
self.pick_buffer.set_active(True)
self.base.graphicsEngine.render_frame()
self.pick_buffer.set_active(False)
self.base.graphicsEngine.extract_texture_data(
self.pick_texture, self.base.win.get_gsg()
)
ram_image = self.pick_texture.get_ram_image_as("RGBA")
if ram_image:
data = memoryview(ram_image)
if len(data) >= 4:
r, g, b, a = data[0], data[1], data[2], data[3]
if a > 0:
hit_id = r + (g << 8)
node_key = self.controller.id_to_name.get(hit_id)
if node_key:
print(f"[Pick] Hit: ID={hit_id} -> {node_key}")
self.select_node(node_key)
return True
self.selected_name = None
self.selected_ids = []
return False
def on_mouse_click(self):
io = imgui.get_io()
if io.want_capture_mouse:
return
if self.base.mouseWatcherNode.has_mouse():
mpos = self.base.mouseWatcherNode.get_mouse()
# If clicking gizmo, skip SSBO pick.
if self._try_start_gizmo_drag(mpos.x, mpos.y):
return
prev_selected = self.selected_name
hit = self.pick_object(mpos.x, mpos.y)
# SSBO miss must clear current selection.
if not hit:
self._sync_selection_none()
# Always fallback to legacy ray pick when SSBO misses.
# This keeps scene selection usable if SSBO ID mapping is incomplete.
self._fallback_legacy_pick(mpos.x, mpos.y)
elif prev_selected != self.selected_name:
# Ensure selection visuals refresh when SSBO selection changes.
self._sync_selection_from_key(self.selected_name)
def toggle_debug(self):
self.debug_mode = not self.debug_mode
def clear_selection(self):
pass # No selection mask texture needed without custom shader
def update_selection_mask(self):
pass # No selection mask texture needed without custom shader
def select_node(self, key):
if not self.controller or key not in self.controller.name_to_ids:
return
self.selected_name = key
self.selected_ids = self.controller.name_to_ids.get(key, [])
self._sync_selection_from_key(key)
def _sync_selection_from_key(self, key):
"""Sync SSBO picked key to legacy SelectionSystem."""
try:
if hasattr(self.base, "selection") and self.base.selection:
kind, target = self._resolve_ssbo_selection_target(key)
if kind == "proxy":
target_np = target
else:
target_np = target if target is not None else self.model
if target_np is None or target_np.isEmpty():
target_np = self.model
self.base.selection.updateSelection(target_np)
except Exception as e:
print(f"[SSBOEditor] selection sync failed: {e}")
def _sync_selection_none(self):
"""Clear legacy SelectionSystem selection."""
try:
self._ssbo_transform_active = False
self._ssbo_selected_local_indices = []
self._ssbo_transform_snapshot = None
self._cleanup_ssbo_proxy()
if hasattr(self.base, "selection") and self.base.selection:
self.base.selection.updateSelection(None)
except Exception as e:
print(f"[SSBOEditor] clear selection sync failed: {e}")
def bind_transform_gizmo(self, transform_gizmo):
"""Bind TransformGizmo drag hooks so SSBO sub-object transforms can follow gizmo."""
self._bound_transform_gizmo = transform_gizmo
if not transform_gizmo:
return
hooks = {
"move": {
"drag_start": [self._on_ssbo_gizmo_drag_start],
"drag_move": [self._on_ssbo_gizmo_drag_move],
"drag_end": [self._on_ssbo_gizmo_drag_end],
},
"rotate": {
"drag_start": [self._on_ssbo_gizmo_drag_start],
"drag_move": [self._on_ssbo_gizmo_drag_move],
"drag_end": [self._on_ssbo_gizmo_drag_end],
},
"scale": {
"drag_start": [self._on_ssbo_gizmo_drag_start],
"drag_move": [self._on_ssbo_gizmo_drag_move],
"drag_end": [self._on_ssbo_gizmo_drag_end],
},
}
try:
if hasattr(transform_gizmo, "set_event_hooks"):
transform_gizmo.set_event_hooks(hooks, replace=False)
print("[SSBOEditor] TransformGizmo hooks bound")
except Exception as e:
print(f"[SSBOEditor] bind transform gizmo failed: {e}")
def _resolve_ssbo_selection_target(self, key):
"""Resolve selected SSBO key to proxy node (preferred) or regular node."""
self._ssbo_transform_active = False
self._ssbo_transform_snapshot = None
self._ssbo_selected_local_indices = []
if not self.controller or not key:
return "node", self.model
global_ids = self.controller.name_to_ids.get(key, [])
local_indices = self.controller.get_local_indices_from_global_ids(global_ids)
self._ssbo_selected_local_indices = local_indices
if local_indices:
print(f"[SSBOEditor] selection locals={len(local_indices)} key={key}")
center = self.controller.get_selection_center(local_indices)
proxy = self._ensure_ssbo_proxy(center)
return "proxy", proxy
target_np = self.controller.key_to_node.get(key)
if target_np is None or target_np.isEmpty():
target_np = self.model
return "node", target_np
def _ensure_ssbo_proxy(self, center):
if self._ssbo_gizmo_proxy is None or self._ssbo_gizmo_proxy.isEmpty():
self._ssbo_gizmo_proxy = self.base.render.attach_new_node("ssbo_transform_proxy")
self._ssbo_gizmo_proxy.setTag("is_ssbo_proxy", "1")
self._ssbo_gizmo_proxy.set_pos(center)
self._ssbo_gizmo_proxy.set_hpr(0, 0, 0)
self._ssbo_gizmo_proxy.set_scale(1, 1, 1)
return self._ssbo_gizmo_proxy
def _cleanup_ssbo_proxy(self):
if self._ssbo_gizmo_proxy and not self._ssbo_gizmo_proxy.isEmpty():
self._ssbo_gizmo_proxy.removeNode()
self._ssbo_gizmo_proxy = None
def _on_ssbo_gizmo_drag_start(self, payload):
try:
target = payload.get("target") if payload else None
if not target or target != self._ssbo_gizmo_proxy:
self._ssbo_transform_active = False
return
if not self.controller or not self._ssbo_selected_local_indices:
self._ssbo_transform_active = False
return
self._ssbo_transform_snapshot = self.controller.begin_transform_session(
self._ssbo_selected_local_indices
)
self._ssbo_proxy_start = {
"pos": Vec3(target.getPos(self.base.render)),
"quat": Quat(target.getQuat(self.base.render)),
"scale": Vec3(target.getScale()),
}
self._ssbo_transform_active = True
print(f"[SSBOEditor] drag_start locals={len(self._ssbo_selected_local_indices)}")
except Exception as e:
self._ssbo_transform_active = False
print(f"[SSBOEditor] drag_start bridge failed: {e}")
def _on_ssbo_gizmo_drag_move(self, payload):
try:
if not self._ssbo_transform_active:
return
target = payload.get("target") if payload else None
if not target or target != self._ssbo_gizmo_proxy:
return
start_pos = self._ssbo_proxy_start.get("pos")
start_quat = self._ssbo_proxy_start.get("quat")
start_scale = self._ssbo_proxy_start.get("scale")
if start_pos is None or start_quat is None or start_scale is None:
return
curr_pos = Vec3(target.getPos(self.base.render))
curr_quat = Quat(target.getQuat(self.base.render))
curr_scale = Vec3(target.getScale())
delta_pos = curr_pos - start_pos
inv_start_quat = Quat(start_quat)
inv_start_quat.invertInPlace()
delta_quat = curr_quat * inv_start_quat
delta_scale = Vec3(
curr_scale.x / start_scale.x if abs(start_scale.x) > 1e-8 else 1.0,
curr_scale.y / start_scale.y if abs(start_scale.y) > 1e-8 else 1.0,
curr_scale.z / start_scale.z if abs(start_scale.z) > 1e-8 else 1.0,
)
self.controller.apply_transform_session(
self._ssbo_transform_snapshot,
delta_pos,
delta_quat,
delta_scale,
)
except Exception as e:
print(f"[SSBOEditor] drag_move bridge failed: {e}")
def _on_ssbo_gizmo_drag_end(self, payload):
try:
if self._ssbo_transform_active:
print(f"[SSBOEditor] drag_end locals={len(self._ssbo_selected_local_indices)}")
self._ssbo_transform_active = False
self._ssbo_transform_snapshot = None
except Exception as e:
print(f"[SSBOEditor] drag_end bridge failed: {e}")
def _fallback_legacy_pick(self, mx, my):
"""Fallback to legacy ray picking when SSBO misses."""
try:
if not hasattr(self.base, "event_handler") or not self.base.event_handler:
return
win_w, win_h = self.base.win.getSize()
x = (mx + 1.0) * 0.5 * win_w
y = (1.0 - my) * 0.5 * win_h
self.base.event_handler.mousePressEventLeft({"x": x, "y": y})
except Exception as e:
print(f"[SSBOEditor] legacy fallback pick failed: {e}")
def _try_start_gizmo_drag(self, mouse_x=None, mouse_y=None):
"""Try to start gizmo drag using the existing SelectionSystem pipeline."""
try:
new_transform = getattr(self.base, "newTransform", None)
if (
new_transform is not None and
mouse_x is not None and
mouse_y is not None and
self._is_mouse_on_new_gizmo(new_transform, mouse_x, mouse_y)
):
return True
selection = getattr(self.base, "selection", None)
if not selection or not selection.gizmo:
return False
win_w, win_h = self.base.win.getSize()
mpos = self.base.mouseWatcherNode.get_mouse()
x = (mpos.x + 1.0) * 0.5 * win_w
y = (1.0 - mpos.y) * 0.5 * win_h
axis = selection.gizmoHighlightAxis or selection.checkGizmoClick(x, y)
if axis:
selection.startGizmoDrag(axis, x, y)
return True
except Exception as e:
print(f"[SSBOEditor] gizmo drag start failed: {e}")
return False
def _is_mouse_on_new_gizmo(self, new_transform, mouse_x, mouse_y):
"""Refresh and query hover state for TransformGizmo on current click position."""
try:
mouse_pos = Point3(mouse_x, mouse_y, 0.0)
for gizmo_name in ("move_gizmo", "rotate_gizmo", "scale_gizmo"):
gizmo = getattr(new_transform, gizmo_name, None)
if not gizmo or not getattr(gizmo, "attached", False):
continue
hover_updater = getattr(gizmo, "_update_hover_highlight", None)
if callable(hover_updater):
hover_updater(mouse_pos)
return bool(getattr(new_transform, "is_hovering", False))
except Exception as e:
print(f"[SSBOEditor] new gizmo hover check failed: {e}")
return False
def focus_on_selected(self):
if self.selected_name and self.selected_ids:
first_id = self.selected_ids[0]
pos = self.controller.get_world_pos(first_id)
dist = 100
self.base.camera.set_pos(pos.x, pos.y - dist, pos.z + dist * 0.5)
self.base.camera.look_at(pos)
def draw_imgui(self):
if not self.controller: return
imgui.set_next_window_pos((10, 10), imgui.Cond_.first_use_ever)
imgui.set_next_window_size((350, 600), imgui.Cond_.first_use_ever)
expanded, opened = imgui.begin("Scene Tree (Component)")
if expanded:
imgui.text(f"FPS: {globalClock.getAverageFrameRate():.1f}")
imgui.separator()
changed, self.search_text = imgui.input_text("Search", self.search_text, 256)
if imgui.begin_child("ObjectList", (0, 380), child_flags=imgui.ChildFlags_.borders):
if self.search_text != self.last_search_text:
self.last_search_text = self.search_text
search_lower = self.search_text.lower()
self.filtered_nodes = []
for key in self.controller.node_list:
display = self.controller.display_names.get(key, key.split('/')[-1])
if not search_lower or (search_lower in display.lower() or search_lower in key.lower()):
geom_count = len(self.controller.name_to_ids.get(key, []))
self.filtered_nodes.append((key, display, geom_count))
# If list is empty initially (no search), show all
if not self.search_text and not self.filtered_nodes:
if len(self.filtered_nodes) != len(self.controller.node_list):
self.filtered_nodes = [(k, self.controller.display_names.get(k, k), len(self.controller.name_to_ids.get(k,[]))) for k in self.controller.node_list]
count = len(self.filtered_nodes)
clipper = imgui.ListClipper()
clipper.begin(count)
while clipper.step():
for i in range(clipper.display_start, clipper.display_end):
key, display, geom_count = self.filtered_nodes[i]
label = f"{display} ({geom_count})"
is_selected = (key == self.selected_name)
if imgui.selectable(label, is_selected)[0]:
self.select_node(key)
imgui.end_child()
imgui.separator()
if self.selected_name:
imgui.text_colored((1, 0.8, 0.2, 1), f"Selected: {self.selected_name}")
if imgui.button("Focus (F)"): self.focus_on_selected()
imgui.end()
# swap_transforms_task removed - motion blur disabled for performance
def update_task(self, task):
dt = globalClock.getDt()
io = imgui.get_io()
if io.want_capture_keyboard: return task.cont
if self.selected_ids and self.controller:
speed = 50 * dt
acc = Vec3(0, 0, 0)
if self.keys.get('arrow_up'): acc.z += speed
if self.keys.get('arrow_down'): acc.z -= speed
if self.keys.get('arrow_left'): acc.x -= speed
if self.keys.get('arrow_right'): acc.x += speed
if self.keys.get('z'): acc.y += speed
if self.keys.get('x'): acc.y -= speed
if acc.length_squared() > 0:
for idx in self.selected_ids:
self.controller.move_object(idx, acc)
return task.cont

339
ui/Builtin/Elements.py Normal file
View File

@ -0,0 +1,339 @@
"""
OUTDATED
Do not use anymore.
"""
import colorsys
from LUIObject import LUIObject
from LUISlider import LUISlider
from LUISprite import LUISprite
from LUIVerticalLayout import LUIVerticalLayout
from LUICallback import LUICallback
from LUILabel import LUILabel
from LUIFrame import LUIFrame
from LUIButton import LUIButton
class LUISliderWithLabel(LUIObject, LUICallback):
def __init__(self, parent=None, width=100.0, filled=False, min_value=0, max_value=1.0, precision=2, value=None):
LUIObject.__init__(self, x=0, y=0, w=width, h=0)
LUICallback.__init__(self)
max_numbers_before = max(len(str(int(max_value))), len(str(int(min_value))))
number_space_required = max_numbers_before
if precision > 0:
number_space_required += 1 + precision
pixels_per_number = 7
self.precision = precision
self.slider = LUISlider(self, width=width - pixels_per_number * number_space_required - 5, filled=filled, min_value=min_value, max_value=max_value, value=value)
self.label = LUILabel(parent=self, shadow=True, text=u"1.23")
self.label.right = 0
self.label.top = self.label.height - self.slider.height
self.label.color = (1,1,1,0.5)
self.slider.add_change_callback(self._on_slider_changed)
self.slider.add_change_callback(self._trigger_callback)
self._on_slider_changed(self.slider, self.slider.get_value())
if parent is not None:
self.parent = parent
self.fit_to_children()
def get_value(self):
return self.slider.get_value()
def set_value(self, val):
self.slider.set_value(val)
def _on_slider_changed(self, obj, value):
self.label.text = ("{:." + str(self.precision) + "f}").format(value)
class LUIKeyMarker(LUIObject):
def __init__(self, parent=None, key=u"A"):
LUIObject.__init__(self)
self.bgLeft = LUISprite(self, "Keymarker_Left", "skin")
self.bgMid = LUISprite(self, "Keymarker", "skin")
self.bgRight = LUISprite(self, "Keymarker_Right", "skin")
self.label = LUILabel(parent=self, text=key, shadow=True)
self.label.centered = (True, True)
self.label.margin = (-3, 0, 0, -1)
self.margin = (-1, 0, 0, -1)
self.set_key(key)
if parent is not None:
self.parent = parent
self.fit_to_children()
def set_key(self, key):
self.label.set_text(key)
self.width = self.label.width + self.bgLeft.width + self.bgRight.width + 7
self.bgMid.width = self.width - self.bgLeft.width - self.bgRight.width
self.bgMid.left = self.bgLeft.width
self.bgRight.left = self.bgMid.width + self.bgMid.left
self.fit_to_children()
class LUIKeyInstruction(LUIObject):
def __init__(self, parent=None, key=u"A", instruction=u"Instruction"):
LUIObject.__init__(self)
self.marker = LUIKeyMarker(parent=self, key=key)
self.instructionLabel = LUILabel(parent=self, text=instruction, shadow=True)
self.instructionLabel.centered = (False, True)
self.instructionLabel.margin.top = -4
self.set_key(key)
def set_key(self, key):
self.marker.set_key(key)
self.instructionLabel.left = self.marker.width + 5
self.fit_to_children()
class LUIColorpicker(LUIObject):
def __init__(self, parent=None, color=None):
LUIObject.__init__(self, x=0, y=0, w=27, h=27)
self.previewBg = LUISprite(self, "ColorpickerPreviewBg", "skin")
self.filler = LUISprite(self, "blank", "skin")
self.filler.width = 21
self.filler.height = 21
self.filler.pos = (5, 5)
self.filler.color = (0.2,0.6,1.0,1.0)
self.overlay = LUISprite(self, "ColorpickerPreviewOverlay", "skin")
self.overlay.pos = (2, 2)
self.overlay.bind("click", self._open_dialog)
self.fit_to_children()
self.popup = LUIColorpickerPopup(self)
self.popup.hide()
if color is not None:
self.colorValue = color
else:
# My favourite color
self.colorValue = (0.2, 0.6, 1.0)
self.set_color_value(self.colorValue)
self.popup.add_change_callback(self._on_popup_color_changed)
if parent is not None:
self.parent = parent
def _open_dialog(self, event):
if self.has_focus():
self.blur()
else:
self.request_focus()
def on_focus(self, event):
self.popup._load_rgb(self.colorValue)
self.popup.open_at(self, 14.0)
def set_color_value(self, rgb):
self.colorValue = rgb
self.filler.color = rgb
def get_color_value(self):
return self.colorValue
def on_tick(self, event):
self.popup._update(event)
def on_blur(self, event):
self.popup.close()
def _on_popup_color_changed(self, popup, rgb):
self.set_color_value(rgb)
def _on_popup_closed(self):
self.blur()
class LUIPopup(LUIFrame):
def __init__(self, parent=None, width=200, height=200):
LUIFrame.__init__(self, parent=parent, width=width, height=height, padding=10, innerPadding=0)
self.topmost = True
self.borderSize = 33
self.content.bind("click", self._on_content_click)
def open_at(self, targetElement, distance):
self.show()
targetPos = targetElement.get_abs_pos()+ targetElement.get_size() / 2
showAbove = targetPos.y > self.height - self.borderSize
showLeft = targetPos.x > self.width - self.borderSize
relative = self.get_relative_pos(targetPos)
self.pos += relative
if showLeft:
self.left -= self.width - self.borderSize
self.left += 25
else:
self.left -= self.borderSize
self.left -= 25
if showAbove:
self.top -= distance
self.top -= self.height - self.borderSize
else:
self.top += distance
self.top -= self.borderSize
def _on_content_click(self, event):
pass
def close(self):
self.hide()
class LUIColorpickerPopup(LUIPopup, LUICallback):
def __init__(self, parent=None):
LUIPopup.__init__(self, parent=parent, width=240, height=146)
LUICallback.__init__(self)
self.field = LUIObject(self.content, x=0, y=0, w=128, h=128)
self.fieldBG = LUISprite(self.field, "blank", "skin")
self.fieldBG.size = (128, 128)
self.fieldBG.color = (0.2,0.6,1.0)
self.fieldFG = LUISprite(self.field, "ColorpickerFieldOverlay", "skin")
self.fieldFG.pos = (-2, 0)
self.fieldBG.bind("mousedown", self._start_field_dragging)
self.fieldBG.bind("mouseup", self._stop_field_dragging)
self.fieldHandle = LUISprite(self.field, "ColorpickerFieldHandle", "skin")
self.fieldHandle.bind("mousedown", self._start_field_dragging)
self.fieldHandle.bind("mouseup", self._stop_field_dragging)
self.fieldDragging = False
self.hueSlider = LUIObject(self.content, x=140, y=0, w=40, h=128)
self.hueSliderFG = LUISprite(self.hueSlider, "ColorpickerHueSlider", "skin")
self.hueHandle = LUISprite(self.hueSlider, "ColorpickerHueHandle", "skin")
self.hueHandle.left = (self.hueSliderFG.width - self.hueHandle.width) / 2.0
self.hueHandle.top = 50
self.hueDragging = False
self.hueSlider.bind("mousedown", self._start_hue_dragging)
self.hueSlider.bind("mouseup", self._stop_hue_dragging)
self.labels = LUIVerticalLayout(self.content, width=40)
self.labels.pos = (177, 42)
colors = [u"R", u"G", u"B"]
self.colorLabels = []
for color in colors:
label = LUILabel(text=color, shadow=True)
label.color = (1,1,1,0.3)
valueLabel = LUILabel(text=u"255", shadow=True)
valueLabel.right = 0
self.labels.add(label, valueLabel)
self.colorLabels.append(valueLabel)
self.activeColor = LUIObject(self.content, x=177, y=0)
self.activeColorBG = LUISprite(self.activeColor, "blank", "skin")
self.activeColorFG = LUISprite(self.activeColor, "ColorpickerActiveColorOverlay", "skin")
self.activeColorBG.size = (40, 40)
self.activeColorBG.pos = (2, 0)
self.activeColorBG.color = (0.2,0.6,1.0,1.0)
self.closeButton = LUIButton(parent=self.content, text=u"Done", width=45, template="ButtonGreen")
self.closeButton.left = 177
self.closeButton.top = 98
self.closeButton.bind("click", self._close_popup)
self._set_hue(0.5)
self._set_sat_val(0.5, 0.5)
self.widget = parent
def _load_rgb(self, rgb):
hsv = colorsys.rgb_to_hsv(*rgb)
self._set_hue(hsv[0])
self._set_sat_val(hsv[1], hsv[2])
def _close_popup(self, event):
self.widget._on_popup_closed()
self.close()
def _update(self, event):
if self.hueDragging:
offset = event.coordinates.y - self.hueSliderFG.abs_pos.y
offset /= 128.0
offset = 1.0 - max(0.0, min(1.0, offset))
self._set_hue(offset)
if self.fieldDragging:
offset = event.coordinates - self.fieldBG.abs_pos
saturation = max(0.0, min(1.0, offset.x / 128.0))
value = 1.0 - max(0.0, min(1.0, offset.y / 128.0))
self._set_sat_val(saturation, value)
self._update_color()
def _set_sat_val(self, sat, val):
self.saturation = sat
self.valueValue = val
self.fieldHandle.top = (1.0 - self.valueValue) * 128.0 - self.fieldHandle.height / 2.0
self.fieldHandle.left = self.saturation * 128.0 - self.fieldHandle.width / 2.0
def _set_hue(self, hue):
self.hueValue = min(0.999, hue)
self.hueHandle.top = (1.0-hue) * 128.0 - self.hueHandle.height / 2
self.fieldBG.color = colorsys.hsv_to_rgb(self.hueValue, 1, 1)
def _update_color(self):
rgb = colorsys.hsv_to_rgb(self.hueValue, self.saturation, self.valueValue)
self.activeColorBG.color = rgb
self.colorLabels[0].set_text(str(int(rgb[0]*255.0)))
self.colorLabels[1].set_text(str(int(rgb[1]*255.0)))
self.colorLabels[2].set_text(str(int(rgb[2]*255.0)))
self._trigger_callback(rgb)
def _start_field_dragging(self, event):
if not self.fieldDragging:
self.fieldDragging = True
def _stop_field_dragging(self, event):
if self.fieldDragging:
self.fieldDragging = False
def _start_hue_dragging(self, event):
if not self.hueDragging:
self.hueDragging = True
def _stop_hue_dragging(self, event):
if self.hueDragging:
self.hueDragging = False

100
ui/Builtin/LUIBlockText.py Normal file
View File

@ -0,0 +1,100 @@
from panda3d.core import LVecBase2i
from LUIObject import LUIObject
from LUILabel import LUILabel
from LUIInitialState import LUIInitialState
__all__ = ["LUIBlockText"]
class LUIBlockText(LUIObject):
""" Small helper class to format labels into paragraphs.
Uses LUILabels internally """
def __init__(self, **kwargs):
""" Creates a new block of text. """
LUIObject.__init__(self)
LUIInitialState.init(self, kwargs)
self._cursor = LVecBase2i(0)
self._last_size = 14
self.labels = []
def clear(self):
""" Removes all text from this label and resets it to the initial state.
This will also detach the sub-labels from this label. """
self._cursor.set(0, 0)
self.labels = []
self.remove_all_children()
def newline(self, font_size=None):
""" Moves the cursor to the next line. The font size controls how much
the cursor will move. By default, the font size of the last added text
is used, or if no text was added yet, a size of 14."""
self._cursor.x = 0
if font_size is None:
font_size = self._last_size
self._cursor.y += font_size + 2
def add(self, *args, **kwargs):
""" Appends a new text. The arguments are equal to the arguments of
LUILabel. The arguments shouldn't contain information about the
placement like top_left, or center_vertical, since the labels are
placed at explicit positions. """
self._last_size = kwargs.get("font_size", 14)
label = LUILabel(parent=self, left=self._cursor.x, top=self._cursor.y, width=self.get_width(),
*args, **kwargs)
self.labels.append(label)
# This is a bit of a hack, we should use a horizontal layout, but we
# don't for performance reasons.
self._cursor.y += label.text_handle.height
# After every paragraph, we add a new line.
self.newline()
def set_text(self, text):
""" Replaces the text with new text """
self.clear()
self.add(text=text)
def update_height(self):
""" Updates the height of the element, adding a newline to the end of
every paragraph """
top = 0
for child in self.labels:
child.top = top
top += child._text.height
# Newline
top += self._last_size + 2
def set_wrap(self, wrap):
""" Sets text wrapping for the element. Wrapping breaks lines on
spaces, and breaks words if the word is longer than the line
length. """
for child in self.children:
for c in child.children:
c.set_wordwrap(wrap)
self.update_height()
def set_width(self, width):
""" Sets the width of this element, and turns on wrapping. """
for child in self.children:
child.set_width(width)
# Need to force an update to the text when the width changes.
for c in child.children:
c.set_wordwrap(True)
self.update_height()

192
ui/Builtin/LUIButton.py Normal file
View File

@ -0,0 +1,192 @@
from LUIObject import LUIObject
from LUILayouts import LUIHorizontalStretchedLayout
from LUILabel import LUILabel
from LUIInitialState import LUIInitialState
__all__ = ["LUIButton"]
class LUIButton(LUIObject):
""" Simple button, containing three sprites and a label. """
def __init__(self, text="Button", template="ButtonDefault", **kwargs):
""" Constructs a new button. The template controls which sprites to use.
If the template is "ButtonDefault" for example, the sprites
"ButtonDefault_Left", "ButtonDefault" and "ButtonDefault_Right" will
be used. The sprites used when the button is pressed should be named
"ButtonDefaultFocus_Left" and so on then.
If an explicit width is set on the button, the button will stick to
that width, otherwise it will automatically resize to fit the label """
LUIObject.__init__(self, x=0, y=0, solid=True)
self._template = template
self._layout = LUIHorizontalStretchedLayout(
parent=self, prefix=self._template, width="100%")
self._label = LUILabel(parent=self, text=text)
self._label.z_offset = 1
self._label.center_vertical = True
self._label.margin = 0, 20, 0, 20
self.margin.left = -1
self._hovered = False
self._pressed = False
self._use_custom_texture = False
self._custom_texture = None
self._custom_uv = None
self._custom_texture_hover = None
self._custom_uv_hover = None
self._custom_texture_pressed = None
self._custom_uv_pressed = None
LUIInitialState.init(self, kwargs)
def _apply_stretch_sizes(self):
"""Ensure internal sprites stretch to the button size."""
try:
layout = getattr(self, '_layout', None)
if layout is not None:
if hasattr(layout, 'width'):
layout.width = "100%"
if hasattr(layout, 'height'):
layout.height = "100%"
inner = getattr(layout, '_layout', None)
if inner is not None:
if hasattr(inner, 'width'):
inner.width = "100%"
if hasattr(inner, 'height'):
inner.height = "100%"
for attr in ('_sprite_left', '_sprite_mid', '_sprite_right'):
spr = getattr(layout, attr, None)
if spr is not None:
if hasattr(spr, 'height'):
spr.height = "100%"
if attr == '_sprite_mid' and hasattr(spr, 'width'):
spr.width = "100%"
except Exception:
pass
def _apply_custom_texture(self, state="normal"):
"""Apply custom texture based on state: normal/hover/pressed."""
if not self._use_custom_texture:
return
tex = None
uv = None
if state == "pressed" and self._custom_texture_pressed is not None:
tex = self._custom_texture_pressed
uv = self._custom_uv_pressed
elif state == "hover" and self._custom_texture_hover is not None:
tex = self._custom_texture_hover
uv = self._custom_uv_hover
else:
tex = self._custom_texture
uv = self._custom_uv
if tex is None:
return
layout = getattr(self, "_layout", None)
if layout is None:
return
for attr in ("_sprite_left", "_sprite_mid", "_sprite_right"):
spr = getattr(layout, attr, None)
if spr is None:
continue
try:
if hasattr(spr, "set_texture"):
spr.set_texture(tex, resize=False)
if uv and hasattr(spr, "set_uv_range"):
u0, v0, u1, v1 = uv
spr.set_uv_range(u0, v0, u1, v1)
except Exception:
pass
# Hide left/right caps for single-image mode
try:
if hasattr(layout, "_sprite_left") and layout._sprite_left is not None:
layout._sprite_left.width = 0
if hasattr(layout, "_sprite_right") and layout._sprite_right is not None:
layout._sprite_right.width = 0
except Exception:
pass
self._apply_stretch_sizes()
def set_custom_textures(self, normal_tex, normal_uv=None, hover_tex=None, hover_uv=None, pressed_tex=None, pressed_uv=None):
"""Set custom textures for normal/hover/pressed states."""
self._use_custom_texture = True
self._custom_texture = normal_tex
self._custom_uv = normal_uv
self._custom_texture_hover = hover_tex
self._custom_uv_hover = hover_uv
self._custom_texture_pressed = pressed_tex
self._custom_uv_pressed = pressed_uv
if self._hovered:
self._apply_custom_texture("hover")
else:
self._apply_custom_texture("normal")
def set_custom_texture(self, texture, uv=None):
"""Use a single texture for the button background."""
self.set_custom_textures(texture, uv, None, None, None, None)
def clear_custom_texture(self):
"""Restore default template textures."""
self._use_custom_texture = False
self._custom_texture = None
self._custom_uv = None
self._custom_texture_hover = None
self._custom_uv_hover = None
self._custom_texture_pressed = None
self._custom_uv_pressed = None
try:
self._layout.prefix = self._template
self._apply_stretch_sizes()
except Exception:
pass
@property
def text(self):
""" Returns the current label text of the button """
return self._label.text
@text.setter
def text(self, text):
""" Sets the label text of the button """
self._label.text = text
def on_mousedown(self, event):
""" Internal on_mousedown handler. Do not override """
self._pressed = True
if self._use_custom_texture:
self._apply_custom_texture("pressed")
else:
self._layout.prefix = self._template + "Focus"
self._apply_stretch_sizes()
self._label.margin.top = 1
def on_mouseup(self, event):
""" Internal on_mouseup handler. Do not override """
self._pressed = False
if self._use_custom_texture:
if self._hovered:
self._apply_custom_texture("hover")
else:
self._apply_custom_texture("normal")
else:
self._layout.prefix = self._template
self._apply_stretch_sizes()
self._label.margin.top = 0
def on_mouseover(self, event):
""" Internal mouseover handler """
self._hovered = True
if self._use_custom_texture and not self._pressed:
self._apply_custom_texture("hover")
def on_mouseout(self, event):
""" Internal mouseout handler """
self._hovered = False
self._pressed = False
if self._use_custom_texture:
self._apply_custom_texture("normal")

102
ui/Builtin/LUICanvas.py Normal file
View File

@ -0,0 +1,102 @@
"""
LUICanvas - 类似Unity的Canvas容器带有可视化边框
用于组织和管理UI元素所有UI组件都应该是Canvas的子节点
"""
from LUIObject import LUIObject
from LUISprite import LUISprite
from LUIInitialState import LUIInitialState
__all__ = ["LUICanvas"]
class LUICanvas(LUIObject):
"""
Canvas容器类似Unity的Canvas系统
- 所有UI元素的父容器
- 带有可视化的边框线框类似Unity编辑器中的Canvas Gizmo
- 可以设置边框颜色和宽度
"""
def __init__(self, border_color=(0.3, 0.7, 1.0, 0.3), border_width=2, **kwargs):
"""
创建一个新的Canvas
参数:
border_color: 边框颜色 (r, g, b, a)默认为蓝色半透明
border_width: 边框宽度默认为2像素使用平面模式时此参数无效
"""
LUIObject.__init__(self)
self._border_width = border_width
self._border_color = border_color
# 创建一个透明平面覆盖整个Canvas
self._border_plane = LUISprite(self, "blank", "skin")
self._border_plane.pos = (0, 0)
self._border_plane.color = self._border_color
self._border_plane.z_offset = 0
# 先设置一个固定尺寸,确保可见
self._border_plane.width = 1280
self._border_plane.height = 720
# 应用初始状态
LUIInitialState.init(self, kwargs)
# 最后设置Canvas占满整个屏幕这会触发setter更新边框
self.set_size("100%", "100%")
def _update_borders(self):
"""更新边框平面的位置和大小"""
# 直接获取width和height属性值
try:
width = self.width
height = self.height
# 设置平面覆盖整个Canvas区域
self._border_plane.pos = (0, 0)
self._border_plane.width = width
self._border_plane.height = height
except:
# 如果获取失败,使用默认值
pass
def set_border_color(self, color):
"""设置边框颜色 (r, g, b, a)"""
self._border_color = color
self._border_plane.color = color
def set_border_width(self, width):
"""设置边框宽度(平面模式下无效)"""
self._border_width = width
def show_border(self):
"""显示边框"""
self._border_plane.show()
def hide_border(self):
"""隐藏边框"""
self._border_plane.hide()
@property
def width(self):
"""获取宽度"""
return LUIObject.width.fget(self)
@width.setter
def width(self, value):
"""设置宽度并更新边框"""
LUIObject.width.fset(self, value)
self._update_borders()
@property
def height(self):
"""获取高度"""
return LUIObject.height.fget(self)
@height.setter
def height(self, value):
"""设置高度并更新边框"""
LUIObject.height.fset(self, value)
self._update_borders()

83
ui/Builtin/LUICheckbox.py Normal file
View File

@ -0,0 +1,83 @@
from __future__ import division
from LUIObject import LUIObject
from LUISprite import LUISprite
from LUILabel import LUILabel
from LUIInitialState import LUIInitialState
__all__ = ["LUICheckbox"]
class LUICheckbox(LUIObject):
""" This is a simple checkbox, including a Label. The checkbox can either
be checked or unchecked. """
def __init__(self, checked=False, label=u"Checkbox", **kwargs):
""" Constructs a new checkbox with the given label and state. By default,
the checkbox is not checked. """
LUIObject.__init__(self, x=0, y=0, solid=True)
self._checked = checked
self._checkbox_sprite = LUISprite(self, "Checkbox_Default", "skin")
self._label = LUILabel(parent=self, text=label, margin=(0, 0, 0, 25),
center_vertical=True, alpha=0.4)
self._hovered = False
self._update_sprite()
LUIInitialState.init(self, kwargs)
@property
def checked(self):
""" Returns True if the checkbox is currently checked """
return self._checked
@checked.setter
def checked(self, checked):
""" Sets the checkbox state """
self._checked = checked
self._update_sprite()
def toggle(self):
""" Toggles the checkbox state """
self.checked = not self.checked
@property
def label(self):
""" Returns a handle to the label, so it can be modified """
return self._label
@property
def sprite(self):
""" Returns a handle to the internal checkbox sprite """
return self._checkbox_sprite
def on_click(self, event):
""" Internal onclick handler. Do not override """
self._checked = not self._checked
self.trigger_event("changed")
self._update_sprite()
def on_mousedown(self, event):
""" Internal mousedown handler. """
self._checkbox_sprite.color = (0.9, 0.9, 0.9, 1.0)
def on_mouseup(self, event):
""" Internal on_mouseup handler. """
self._checkbox_sprite.color = (1, 1, 1, 1)
def on_mouseover(self, event):
""" Internal mouseover handler """
self._hovered = True
self._update_sprite()
def on_mouseout(self, event):
""" Internal mouseout handler """
self._hovered = False
self._update_sprite()
def _update_sprite(self):
""" Internal method to update the sprites """
img = "Checkbox_Checked" if self._checked else "Checkbox_Default"
if self._hovered:
img += "Hover"
self._checkbox_sprite.set_texture(img, "skin")

View File

@ -0,0 +1,47 @@
from panda3d.core import LVecBase2i
from LUIObject import LUIObject
from LUILabel import LUILabel
from LUIInitialState import LUIInitialState
__all__ = ["LUIFormattedLabel"]
class LUIFormattedLabel(LUIObject):
""" Small helper class to build a text consisting of different formatted
parts of text. Uses LUILabels internally """
def __init__(self, **kwargs):
""" Creates a new formatted label. """
LUIObject.__init__(self)
LUIInitialState.init(self, kwargs)
self._cursor = LVecBase2i(0)
self._last_size = 14
def clear(self):
""" Removes all text from this label and resets it to the initial state.
This will also detach the sub-labels from this label. """
self._cursor.set(0, 0)
self.remove_all_children()
def newline(self, font_size=None):
""" Moves the cursor to the next line. The font size controls how much
the cursor will move. By default, the font size of the last added text
is used, or if no text was added yet, a size of 14."""
self._cursor.x = 0
if font_size is None:
font_size = self._last_size
self._cursor.y += font_size + 2
def add(self, *args, **kwargs):
""" Appends a new text. The arguments are equal to the arguments of
LUILabel. The arguments shouldn't contain information about the
placement like top_left, or center_vertical, since the labels are
placed at explicit positions. """
self._last_size = kwargs.get("font_size", 14)
label = LUILabel(parent=self, left=self._cursor.x, top=self._cursor.y,
*args, **kwargs)
# This is a bit of a hack, we should use a horizontal layout, but we
# don't for performance reasons.
self._cursor.x += label.text_handle.width

66
ui/Builtin/LUIFrame.py Normal file
View File

@ -0,0 +1,66 @@
from __future__ import print_function
from LUIObject import LUIObject
from LUISprite import LUISprite
from LUILayouts import LUICornerLayout
from LUIInitialState import LUIInitialState
from LUIScrollableRegion import LUIScrollableRegion
__all__ = ["LUIFrame"]
class LUIFrame(LUIObject):
""" A container which can store multiple ui-elements. If you don't want a
border/background, you should use an empty LUIObject as container instead.
"""
FS_sunken = 1
FS_raised = 2
def __init__(self, inner_padding=5, scrollable=False, style=FS_raised,
**kwargs):
""" Creates a new frame with the given options and style. If scrollable
is True, the contents of the frame will scroll if they don't fit into
the frame height. inner_padding only has effect if scrollable is True.
You can call fit_to_children() to make the frame fit automatically to
it's contents."""
LUIObject.__init__(self)
# Each *style* has a different border size (size of the shadow). The
# border size shouldn't get calculated to the actual framesize, so we
# are determining it first and then substracting it.
# TODO: We could do this automatically, determined by the sprite size
# probably?
self._border_size = 0
self.padding = 10
self.solid = True
prefix = ""
if style == LUIFrame.FS_raised:
temp = LUISprite(self, "Frame_Left", "skin")
self._border_size = temp.width
self.remove_child(temp)
prefix = "Frame_"
elif style == LUIFrame.FS_sunken:
self._border_size = 0
prefix = "SunkenFrame_"
else:
raise Exception("Unkown LUIFrame style: " + style)
self._scrollable = scrollable
self._layout = LUICornerLayout(parent=self, image_prefix=prefix)
self._layout.margin = -(self.padding.top + self._border_size)
if self._scrollable:
self._content = LUIObject(self)
self._content.size = (self.width, self.height)
self._content.pos = (self._border_size, self._border_size)
self._scroll_content = LUIScrollableRegion(
self._content,
width=self.width - 2 * self.padding.left,
height=self.height - 2 * self.padding.left,
padding=inner_padding)
self.content_node = self._scroll_content.content_node
LUIInitialState.init(self, kwargs)

View File

@ -0,0 +1,20 @@
"""
This is a wrapper file. It contains no actual implementation
"""
from panda3d.lui import LUIHorizontalLayout as _LUIHorizontalLayout
from LUIInitialState import LUIInitialState
__all__ = ["LUIHorizontalLayout"]
class LUIHorizontalLayout(_LUIHorizontalLayout):
""" This is a wrapper class for the C++ LUIHorizontalLayout class, to be
able to use it in a more convenient way. It leverages LUIInitialState
to be able to pass arbitrary keyword arguments. """
def __init__(self, parent=None, spacing=0.0, **kwargs):
_LUIHorizontalLayout.__init__(self, parent, spacing)
LUIInitialState.init(self, kwargs)

View File

@ -0,0 +1,41 @@
__all__ = ["LUIInitialState"]
class LUIInitialState:
""" Small helper class to pass keyword arguments to the LUI-objects. It takes
all keyword arguments of a given call, and calls obj.<kwarg> = <value> for
each keyword. It usually is called at the end of the __init__ method. """
def __init__(self):
raise Exception("LUIInitialState is a static class")
# Some properties have alternative names, under which they can be accessed.
__MAPPINGS = {
"x": "left",
"y": "top",
"w": "width",
"h": "height"
}
@classmethod
def init(cls, obj, kwargs):
""" Applies all keyword arguments as properties. For example, passing
dict({"left": 10, "top": 3, "color": (0.2, 0.6, 1.0)}) results in
behaviour similar to:
element.left = 10
element.top = 3
element.color = 0.2, 0.6, 1.0
Calling this method allows setting arbitrary properties in
constructors, without having to specify each possible keyword argument.
"""
for arg_name, arg_val in kwargs.items():
arg_name = cls.__MAPPINGS.get(arg_name, arg_name)
if hasattr(obj, arg_name):
setattr(obj, arg_name, arg_val)
else:
raise AttributeError("{0} has no attribute {1}".format(
obj.__class__.__name__, arg_name))

219
ui/Builtin/LUIInputField.py Normal file
View File

@ -0,0 +1,219 @@
import re
from LUIObject import LUIObject
from LUISprite import LUISprite
from LUILabel import LUILabel
from LUIInitialState import LUIInitialState
from LUILayouts import LUIHorizontalStretchedLayout
__all__ = ["LUIInputField"]
class LUIInputField(LUIObject):
""" Simple input field, accepting text input. This input field supports
entering text and navigating. Selecting text is (currently) not supported.
The input field also supports various keyboard shortcuts:
[pos1] Move to the beginning of the text
[end] Move to the end of the text
[arrow_left] Move one character to the left
[arrow_right] Move one character to the right
[ctrl] + [arrow_left] Move to the left, skipping over words
[ctrl] + [arrow_right] Move to the right, skipping over words
[escape] Un-focus input element
"""
re_skip = re.compile("\W*\w+\W")
def __init__(self, parent=None, width=200, placeholder=u"Enter some text ..", value=u"", **kwargs):
""" Constructs a new input field. An input field always needs a width specified """
LUIObject.__init__(self, x=0, y=0, solid=True)
self.set_width(width)
self._layout = LUIHorizontalStretchedLayout(parent=self, prefix="InputField", width="100%")
# Container for the text
self._text_content = LUIObject(self)
self._text_content.margin = (5, 7, 5, 7)
self._text_content.clip_bounds = (0,0,0,0)
self._text_content.set_size("100%", "100%")
# Scroller for the text, so we can move right and left
self._text_scroller = LUIObject(parent=self._text_content)
self._text_scroller.center_vertical = True
self._text = LUILabel(parent=self._text_scroller, text="")
# Cursor for the current position
self._cursor = LUISprite(self._text_scroller, "blank", "skin", x=0, y=0, w=2, h=15)
self._cursor.color = (0.5, 0.5, 0.5)
self._cursor.margin.top = 2
self._cursor.z_offset = 20
self._cursor_index = 0
self._cursor.hide()
self._value = value
# Placeholder text, shown when out of focus and no value exists
self._placeholder = LUILabel(parent=self._text_content, text=placeholder, shadow=False,
center_vertical=True, alpha=0.2)
# Various states
self._tickrate = 1.0
self._tickstart = 0.0
self._render_text()
if parent is not None:
self.parent = parent
LUIInitialState.init(self, kwargs)
@property
def value(self):
""" Returns the value of the input field """
return self._value
@value.setter
def value(self, new_value):
""" Sets the value of the input field """
self._value = new_value
self._render_text()
self.trigger_event("changed", self._value)
def clear(self):
""" Clears the input value """
self.value = u""
@property
def cursor_pos(self):
""" Set the cursor position """
return self._cursor_index
@cursor_pos.setter
def cursor_pos(self, pos):
""" Set the cursor position """
if pos >= 0:
self._cursor_index = max(0, min(len(self._value), pos))
else:
self._cursor_index = max(len(self._value) + pos + 1, 0)
self._reset_cursor_tick()
self._render_text()
def on_tick(self, event):
""" Tick handler, gets executed every frame """
frame_time = globalClock.get_frame_time() - self._tickstart
show_cursor = frame_time % self._tickrate < 0.5 * self._tickrate
if show_cursor:
self._cursor.color = (0.5, 0.5, 0.5, 1)
else:
self._cursor.color = (1, 1, 1, 0)
def on_click(self, event):
""" Internal on click handler """
self.request_focus()
def on_mousedown(self, event):
""" Internal mousedown handler """
local_x_offset = self._text.text_handle.get_relative_pos(event.coordinates).x
self.cursor_pos = self._text.text_handle.get_char_index(local_x_offset)
def _reset_cursor_tick(self):
""" Internal method to reset the cursor tick """
self._tickstart = globalClock.get_frame_time()
def on_focus(self, event):
""" Internal focus handler """
self._cursor.show()
self._placeholder.hide()
self._reset_cursor_tick()
self._layout.color = (0.9, 0.9, 0.9, 1)
def on_keydown(self, event):
""" Internal keydown handler. Processes the special keys, and if none are
present, redirects the event """
key_name = event.message
if key_name == "backspace":
self._value = self._value[:max(0, self._cursor_index - 1)] + self._value[self._cursor_index:]
self.cursor_pos -= 1
self.trigger_event("changed", self._value)
elif key_name == "delete":
post_value = self._value[min(len(self._value), self._cursor_index + 1):]
self._value = self._value[:self._cursor_index] + post_value
self.cursor_pos = self._cursor_index
self.trigger_event("changed", self._value)
elif key_name == "arrow_left":
if event.get_modifier_state("alt") or event.get_modifier_state("ctrl"):
self.cursor_skip_left()
else:
self.cursor_pos -= 1
elif key_name == "arrow_right":
if event.get_modifier_state("alt") or event.get_modifier_state("ctrl"):
self.cursor_skip_right()
else:
self.cursor_pos += 1
elif key_name == "escape":
self.blur()
elif key_name == "home":
self.cursor_pos = 0
elif key_name == "end":
self.cursor_pos = len(self.value)
self.trigger_event(key_name, self._value)
def on_keyrepeat(self, event):
""" Internal keyrepeat handler """
self.on_keydown(event)
def on_textinput(self, event):
""" Internal textinput handler """
self._value = self._value[:self._cursor_index] + event.message + \
self._value[self._cursor_index:]
self.cursor_pos = self._cursor_index + len(event.message)
self.trigger_event("changed", self._value)
def on_blur(self, event):
""" Internal blur handler """
self._cursor.hide()
if len(self._value) < 1:
self._placeholder.show()
self._layout.color = (1, 1, 1, 1)
def _render_text(self):
""" Internal method to render the text """
self._text.set_text(self._value)
self._cursor.left = self._text.left + \
self._text.text_handle.get_char_pos(self._cursor_index) + 1
max_left = self.width - 15
if self._value:
self._placeholder.hide()
else:
if not self.focused:
self._placeholder.show()
# Scroll if the cursor is outside of the clip bounds
rel_pos = self.get_relative_pos(self._cursor.get_abs_pos()).x
if rel_pos >= max_left:
self._text_scroller.left = min(0, max_left - self._cursor.left)
if rel_pos <= 0:
self._text_scroller.left = min(0, - self._cursor.left - rel_pos)
def cursor_skip_left(self):
""" Moves the cursor to the left, skipping the previous word """
left_hand_str = ''.join(reversed(self.value[0:self.cursor_pos]))
match = self.re_skip.match(left_hand_str)
if match is not None:
self.cursor_pos -= match.end() - 1
else:
self.cursor_pos = 0
def cursor_skip_right(self):
""" Moves the cursor to the right, skipping the next word """
right_hand_str = self.value[self.cursor_pos:]
match = self.re_skip.match(right_hand_str)
if match is not None:
self.cursor_pos += match.end() - 1
else:
self.cursor_pos = len(self.value)

View File

@ -0,0 +1,8 @@
"""
This is a wrapper file. It contains no actual implementation
"""
from panda3d.lui import LUIInputHandler as __LUIInputHandler
LUIInputHandler = __LUIInputHandler

77
ui/Builtin/LUILabel.py Normal file
View File

@ -0,0 +1,77 @@
from panda3d.lui import LUIText
from LUIObject import LUIObject
from LUIInitialState import LUIInitialState
__all__ = ["LUILabel"]
class LUILabel(LUIObject):
""" A simple label, displaying text. """
# Default variables which can be overridden by skins
DEFAULT_COLOR = (0.9, 0.9, 0.9, 1)
DEFAULT_USE_SHADOW = True
def __init__(self, text="Label", shadow=None, font_size=14, font="label", color=None, wordwrap=False, **kwargs):
""" Creates a new label. If shadow is True, a small text shadow will be
rendered below the actual text. """
LUIObject.__init__(self)
LUIInitialState.init(self, kwargs)
self._text = LUIText(
self,
text,
font,
font_size,
0,
0,
wordwrap
)
self._text.z_offset = 1
if color is None:
self.color = LUILabel.DEFAULT_COLOR
else:
self.color = color
if shadow is None:
shadow = LUILabel.DEFAULT_USE_SHADOW
self._have_shadow = shadow
if self._have_shadow:
self._shadow_text = LUIText(
self,
text,
font,
font_size,
0,
0,
wordwrap
)
self._shadow_text.top = 1
self._shadow_text.color = (0,0,0,0.6)
def get_text_handle(self):
""" Returns a handle to the internal used LUIText object """
return self._text
text_handle = property(get_text_handle)
def get_text(self):
""" Returns the current text of the label """
return self._text.text
def set_text(self, text):
""" Sets the text of the label """
self._text.text = text
if self._have_shadow:
self._shadow_text.text = text
text = property(get_text, set_text)
def get_color(self):
""" Returns the current color of the label's text """
return self._text.color
def set_color(self, color):
""" Sets the color of the label's text """
self._text.color = color
color = property(get_color, set_color)

105
ui/Builtin/LUILayouts.py Normal file
View File

@ -0,0 +1,105 @@
from __future__ import print_function, division
from LUIObject import LUIObject
from LUISprite import LUISprite
from LUIHorizontalLayout import LUIHorizontalLayout
from LUIInitialState import LUIInitialState
__all__ = ["LUICornerLayout", "LUIHorizontalStretchedLayout"]
class LUICornerLayout(LUIObject):
""" This is a layout which is used to combine 9 sprites to a single sprite,
e.g. used for box shadow or frames."""
# List of all sprite identifiers required for the layout
_MODES = ["TR", "Top", "TL", "Right", "Mid", "Left", "BR", "Bottom", "BL"]
def __init__(self, image_prefix="", **kwargs):
""" Creates a new layout, using the image_prefix as prefix. """
LUIObject.__init__(self)
self.set_size("100%", "100%")
self._prefix = image_prefix
self._parts = {}
for i in self._MODES:
self._parts[i] = LUISprite(self, "blank", "skin")
self._update_layout()
LUIInitialState.init(self, kwargs)
def _update_layout(self):
""" Updates the layouts components. """
for i in self._MODES:
self._parts[i].set_texture(self._prefix + i, "skin", resize=True)
# Top and Left
self._parts["Top"].width = "100%"
self._parts["Top"].margin = (0, self._parts["TR"].width, 0, self._parts["TL"].width)
self._parts["Left"].height = "100%"
self._parts["Left"].margin = (self._parts["TL"].height, 0, self._parts["BL"].height, 0)
# Mid
self._parts["Mid"].set_size("100%", "100%")
self._parts["Mid"].margin = (self._parts["Top"].height, self._parts["Right"].width,
self._parts["Bottom"].height, self._parts["Left"].width)
# Bottom and Right
self._parts["Bottom"].width = "100%"
self._parts["Bottom"].margin = (0, self._parts["BR"].width, 0, self._parts["BL"].width)
self._parts["Bottom"].bottom = 0
self._parts["Right"].height = "100%"
self._parts["Right"].margin = (self._parts["TR"].height, 0, self._parts["BR"].width, 0)
self._parts["Right"].right = 0
# Corners
self._parts["TL"].top_left = 0, 0
self._parts["TR"].top_right = 0, 0
self._parts["BL"].bottom_left = 0, 0
self._parts["BR"].bottom_right = 0, 0
def set_prefix(self, prefix):
""" Changes the texture of the layout """
self._prefix = prefix
self._update_layout()
def get_prefix(self):
""" Returns the layouts texture prefix """
return self._prefix
prefix = property(get_prefix, set_prefix)
class LUIHorizontalStretchedLayout(LUIObject):
""" A layout which takes 3 sprites, a left sprite, a right sprite, and a
middle sprite. While the left and right sprites remain untouched, the middle
one will be stretched to fit the layout """
def __init__(self, parent=None, prefix="ButtonDefault", **kwargs):
LUIObject.__init__(self)
self._layout = LUIHorizontalLayout(self, spacing=0)
self._layout.width = "100%"
self._sprite_left = LUISprite(self._layout.cell(), "blank", "skin")
self._sprite_mid = LUISprite(self._layout.cell('*'), "blank", "skin")
self._sprite_right = LUISprite(self._layout.cell(), "blank", "skin")
if parent is not None:
self.parent = parent
self.prefix = prefix
LUIInitialState.init(self, kwargs)
def set_prefix(self, prefix):
""" Sets the layout prefix, this controls which sprites will be used """
self._sprite_left.set_texture(prefix + "_Left", "skin")
self._sprite_mid.set_texture(prefix, "skin")
self._sprite_right.set_texture(prefix + "_Right", "skin")
self._sprite_mid.width = "100%"
self._prefix = prefix
def get_prefix(self):
""" Returns the layout prefix """
return self._prefix
prefix = property(get_prefix, set_prefix)

18
ui/Builtin/LUIObject.py Normal file
View File

@ -0,0 +1,18 @@
"""
This is a wrapper file. It contains no actual implementation
"""
from panda3d.lui import LUIObject as _LUIObject
from LUIInitialState import LUIInitialState
__all__ = ["LUIObject"]
class LUIObject(_LUIObject):
""" This is a wrapper class for the C++ LUIObject class, to be able to
use it in a more convenient way """
def __init__(self, *args, **kwargs):
_LUIObject.__init__(self, *args)
LUIInitialState.init(self, kwargs)

View File

@ -0,0 +1,72 @@
from LUIObject import LUIObject
from LUISprite import LUISprite
from LUILayouts import LUIHorizontalStretchedLayout
from LUILabel import LUILabel
class LUIProgressbar(LUIObject):
""" A simple progress bar """
def __init__(self, parent=None, width=200, value=50, show_label=True):
""" Constructs a new progress bar. If show_label is True, a label indicating
the current progress is shown """
LUIObject.__init__(self)
self.set_width(width)
self._bg_layout = LUIHorizontalStretchedLayout(
parent=self, prefix="ProgressbarBg", width="100%")
self._fg_left = LUISprite(self, "ProgressbarFg_Left", "skin")
self._fg_mid = LUISprite(self, "ProgressbarFg", "skin")
self._fg_right = LUISprite(self, "ProgressbarFg_Right", "skin")
self._fg_finish = LUISprite(self, "ProgressbarFg_Finish", "skin")
self._show_label = show_label
self._progress_pixel = 0
self._fg_finish.right = 0
if self._show_label:
self._progress_label = LUILabel(parent=self, text=u"33 %")
self._progress_label.centered = (True, True)
self.set_value(value)
self._update_progress()
if parent is not None:
self.parent = parent
def get_value(self):
""" Returns the current value of the progress bar """
return (self._progress_pixel / self.width * 100.0)
def set_value(self, val):
""" Sets the value of the progress bar """
val = max(0, min(100, val))
self._progress_pixel = int(val / 100.0 * self.width)
self._update_progress()
value = property(get_value, set_value)
def _update_progress(self):
""" Internal method to update the progressbar """
self._fg_finish.hide()
if self._progress_pixel <= self._fg_left.width + self._fg_right.width:
self._fg_mid.hide()
self._fg_right.left = self._fg_left.width
else:
self._fg_mid.show()
self._fg_mid.left = self._fg_left.width
self._fg_mid.width = self._progress_pixel - self._fg_right.width - self._fg_left.width
self._fg_right.left = self._fg_mid.left + self._fg_mid.width
if self._progress_pixel >= self.width - self._fg_right.width:
self._fg_finish.show()
self._fg_finish.right = - (self.width - self._progress_pixel)
self._fg_finish.clip_bounds = (0, self.width - self._progress_pixel, 0, 0)
if self._show_label:
percentage = self._progress_pixel / self.width * 100.0
self._progress_label.set_text("{} %".format(int(percentage)))

91
ui/Builtin/LUIRadiobox.py Normal file
View File

@ -0,0 +1,91 @@
from __future__ import division
from LUIObject import LUIObject
from LUISprite import LUISprite
from LUIInitialState import LUIInitialState
from LUILabel import LUILabel
class LUIRadiobox(LUIObject):
""" A radiobox which can be used in combination with a LUIRadioboxGroup """
def __init__(self, parent=None, group=None, value=None, active=False, label=u"Radiobox", **kwargs):
""" Constructs a new radiobox. group should be a handle to a LUIRadioboxGroup.
value will be the value returned by group.value, in case the box was
selected. By default, the radiobox is not active. """
assert group is not None, "LUIRadiobox needs a LUIRadioboxGroup!"
LUIObject.__init__(self, x=0, y=0, solid=True)
self._sprite = LUISprite(self, "Radiobox_Default", "skin")
self._label = LUILabel(parent=self, text=label, margin=(0, 0, 0, 23),
center_vertical=True)
self._value = value
self._active = False
self._hovered = False
self._group = group
self._group.register_box(self)
if active:
self.set_active()
if parent:
self.parent = parent
LUIInitialState.init(self, kwargs)
def on_click(self, event):
""" Internal onclick handler. Do not override. """
self.set_active()
def on_mouseover(self, event):
""" Internal mouseover handler """
self._hovered = True
self._update_sprite()
def on_mouseout(self, event):
""" Internal mouseout handler """
self._hovered = False
self._update_sprite()
def set_active(self):
""" Internal function to set the radiobox active """
if self._group is not None:
self._group.set_active_box(self)
else:
self._update_state(True)
def get_value(self):
""" Returns the value of the radiobox """
return self._value
def set_value(self, value):
""" Sets the value of the radiobox """
self._value = value
value = property(get_value, set_value)
def get_label(self):
""" Returns a handle to the label, so it can be modified (e.g. change
its text) """
return self._label
label = property(get_label)
def _update_state(self, active):
""" Internal method to update the state of the radiobox. Called by the
LUIRadioboxGroup """
self._active = active
self.trigger_event("changed")
self._update_sprite()
def on_mousedown(self, event):
""" Internal onmousedown handler. Do not override. """
self._sprite.color = (0.86,0.86,0.86,1.0)
def on_mouseup(self, event):
""" Internal onmouseup handler. Do not override. """
self._sprite.color = (1,1,1,1)
def _update_sprite(self):
""" Internal function to update the sprite of the radiobox """
img = "Radiobox_Active" if self._active else "Radiobox_Default"
if self._hovered:
img += "Hover"
self._sprite.set_texture(img, "skin")

View File

@ -0,0 +1,41 @@
from LUIObject import LUIObject
class LUIRadioboxGroup(LUIObject):
""" Simple helper to group a bunch of LUIRadiobox and ensure only one is
checked at one timem """
def __init__(self):
""" Constructs a new group without any radioboxes inside """
self._boxes = []
self._selected_box = None
def register_box(self, box):
""" Registers a box to the collection """
if box not in self._boxes:
self._boxes.append(box)
def set_active_box(self, active_box):
""" Internal function to set the active box """
for box in self._boxes:
if box is not active_box:
box._update_state(False)
else:
box._update_state(True)
self._selected_box = active_box
def get_active_box(self):
""" Returns the current selected box """
return self._selected_box
active_box = property(get_active_box, set_active_box)
def get_active_value(self):
""" Returns the value of the current selected box (or None if none is
selected) """
if self._selected_box is None:
return None
return self._selected_box.get_value()
active_value = property(get_active_value)

8
ui/Builtin/LUIRegion.py Normal file
View File

@ -0,0 +1,8 @@
"""
This is a wrapper file. It contains no actual implementation
"""
from panda3d.lui import LUIRegion as __LUIRegion
LUIRegion = __LUIRegion

8
ui/Builtin/LUIRoot.py Normal file
View File

@ -0,0 +1,8 @@
"""
This is a wrapper file. It contains no actual implementation
"""
from panda3d.lui import LUIRoot as __LUIRoot
LUIRoot = __LUIRoot

View File

@ -0,0 +1,155 @@
from LUIObject import LUIObject
from LUISprite import LUISprite
from LUIInitialState import LUIInitialState
from LUILayouts import LUIHorizontalStretchedLayout
class LUIScrollableRegion(LUIObject):
""" Scrollable region, reparent elements to the .content_node to make them
scroll. """
def __init__(self, parent=None, width=100, height=100, padding=10, **kwargs):
LUIObject.__init__(self)
self.set_size(width, height)
self._content_parent = LUIObject(self)
self._content_parent.set_size("100%", "100%")
self._content_parent.clip_bounds = (0,0,0,0)
self._content_clip = LUIObject(self._content_parent, x=padding, y=padding)
self._content_clip.set_size("100%", "100%")
self._content_scroller = LUIObject(self._content_clip)
self._content_scroller.width = "100%"
self._scrollbar = LUIObject(self, x=0, y=0, w=20)
self._scrollbar.height = "100%"
self._scrollbar.right = -10
self._scrollbar_bg = LUISprite(self._scrollbar, "blank", "skin")
self._scrollbar_bg.color = (1,1,1,0.05)
self._scrollbar_bg.set_size(3, "100%")
self._scrollbar_bg.center_horizontal = True
# Handle
self._scrollbar_handle = LUIObject(self._scrollbar, x=5, y=0, w=10)
self._scroll_handle_top = LUISprite(self._scrollbar_handle, "ScrollbarHandle_Top", "skin")
self._scroll_handle_mid = LUISprite(self._scrollbar_handle, "ScrollbarHandle", "skin")
self._scroll_handle_bottom = LUISprite(self._scrollbar_handle, "ScrollbarHandle_Bottom", "skin")
self._scrollbar_handle.solid = True
self._scrollbar.solid = True
self._scrollbar_handle.bind("mousedown", self._start_scrolling)
self._scrollbar_handle.bind("mouseup", self._stop_scrolling)
self._scrollbar.bind("mousedown", self._on_bar_click)
self._scrollbar.bind("mouseup", self._stop_scrolling)
self._handle_dragging = False
self._drag_start_y = 0
self._scroll_top_position = 0
self._content_height = 400
# Scroll shadow
self._scroll_shadow_top = LUIHorizontalStretchedLayout(parent=self, prefix="ScrollShadowTop", width="100%")
self._scroll_shadow_bottom = LUIHorizontalStretchedLayout(parent=self, prefix="ScrollShadowBottom", width="100%")
self._scroll_shadow_bottom.bottom = 0
self._handle_height = 100
if parent is not None:
self.parent = parent
LUIInitialState.init(self, kwargs)
self.content_node = self._content_scroller
taskMgr.doMethodLater(0.05, lambda task: self._update(), "update_scrollbar")
def _on_bar_click(self, event):
""" Internal handler when the user clicks on the scroll bar """
self._scroll_to_bar_pixels(event.coordinates.y - self._scrollbar.abs_pos.y - self._handle_height / 2.0)
self._update()
self._start_scrolling(event)
def _start_scrolling(self, event):
""" Internal method when we start scrolling """
self.request_focus()
if not self._handle_dragging:
self._drag_start_y = event.coordinates.y
self._handle_dragging = True
def _stop_scrolling(self, event):
""" Internal handler when we should stop scrolling """
if self._handle_dragging:
self._handle_dragging = False
self.blur()
def _scroll_to_bar_pixels(self, pixels):
""" Internal method to convert from pixels to a relative position """
offset = pixels * self._content_height / self.height
self._scroll_top_position = offset
self._scroll_top_position = max(0, min(self._content_height - self._content_clip.height, self._scroll_top_position))
def on_tick(self, event):
""" Internal on tick handler """
if self._handle_dragging:
scroll_abs_pos = self._scrollbar.abs_pos
clamped_coord_y = max(scroll_abs_pos.y, min(scroll_abs_pos.y + self.height, event.coordinates.y))
offset = clamped_coord_y - self._drag_start_y
self._drag_start_y = clamped_coord_y
self._scroll_to_bar_pixels(self._scroll_top_position/self._content_height*self.height + offset)
self._update()
def _set_handle_height(self, height):
""" Internal method to set the scrollbar height """
self._scroll_handle_mid.top = float(self._scroll_handle_top.height)
self._scroll_handle_mid.height = max(0.0, height - self._scroll_handle_top.height - self._scroll_handle_bottom.height)
self._scroll_handle_bottom.top = self._scroll_handle_mid.height + self._scroll_handle_mid.top
self._handle_height = height
def _update(self):
""" Internal method to update the scroll bar """
self._content_height = max(1, self._content_scroller.get_height() + 20)
self._content_scroller.top = -self._scroll_top_position
scrollbar_height = max(0.1, min(1.0, self._content_clip.height / self._content_height))
scrollbar_height_px = scrollbar_height * self.height
self._set_handle_height(scrollbar_height_px)
self._scrollbar_handle.top = self._scroll_top_position / self._content_height * self.height
top_alpha = max(0.0, min(1.0, self._scroll_top_position / 50.0))
bottom_alpha = max(0.0, min(1.0, (self._content_height - self._scroll_top_position - self._content_clip.height) / 50.0 ))
self._scroll_shadow_top.color = (1,1,1,top_alpha)
self._scroll_shadow_bottom.color = (1,1,1,bottom_alpha)
if self._content_height <= self.height:
self._scrollbar_handle.hide()
else:
self._scrollbar_handle.show()
def on_element_added(self):
taskMgr.doMethodLater(0.05, lambda task: self._update(), "update_layout")
def get_scroll_percentage(self):
""" Returns the current scroll height in percentage from 0 to 1 """
return self._scroll_top_position / max(1, self._content_height - self._content_clip.height)
def set_scroll_percentage(self, percentage):
""" Sets the scroll position in percentage, 0 means top and 1 means bottom """
percentage = max(0.0, min(1.0, percentage))
pixels = max(0.0, self._content_height - self._content_clip.height) * percentage
self._scroll_top_position = pixels
self._update()
scroll_percentage = property(get_scroll_percentage, set_scroll_percentage)
def scroll_to_bottom(self):
""" Scrolls to the bottom of the frame """
taskMgr.doMethodLater(0.07, lambda task: self.set_scroll_percentage(1.0), "scroll_to_bottom")
def scroll_to_top(self):
""" Scrolls to the top of the frame """
taskMgr.doMethodLater(0.07, lambda task: self.set_scroll_percentage(0.0), "scroll_to_top")

207
ui/Builtin/LUISelectbox.py Normal file
View File

@ -0,0 +1,207 @@
from LUIObject import LUIObject
from LUISprite import LUISprite
from LUILabel import LUILabel
from LUILayouts import LUICornerLayout, LUIHorizontalStretchedLayout
from LUIInitialState import LUIInitialState
from functools import partial
__all__ = ["LUISelectbox"]
class LUISelectbox(LUIObject):
""" Selectbox widget, showing several options whereas the user can select
only one. """
def __init__(self, width=200, options=None, selected_option=None, **kwargs):
""" Constructs a new selectbox with a given width """
LUIObject.__init__(self, x=0, y=0, w=width+4, solid=True)
LUIInitialState.init(self, kwargs)
# The selectbox has a small border, to correct this we move it
self.margin.left = -2
self._bg_layout = LUIHorizontalStretchedLayout(parent=self, prefix="Selectbox", width="100%")
self._label_container = LUIObject(self, x=10, y=0)
self._label_container.set_size("100%", "100%")
self._label_container.clip_bounds = (0,0,0,0)
self._label = LUILabel(parent=self._label_container, text=u"Select an option ..")
self._label.center_vertical = True
self._drop_menu = LUISelectdrop(parent=self, width=width)
self._drop_menu.top = self._bg_layout._sprite_right.height - 7
self._drop_menu.topmost = True
self._drop_open = False
self._drop_menu.hide()
self._options = []
self._current_option_id = None
if options is not None:
self._options = options
self._select_option(selected_option)
def get_selected_option(self):
""" Returns the selected option """
return self._current_option_id
def set_selected_option(self, option_id):
""" Sets the selected option """
raise NotImplementedError()
selected_option = property(get_selected_option, set_selected_option)
def _render_options(self):
""" Internal method to render all available options """
self._drop_menu._render_options(self._options)
def get_options(self):
""" Returns the list of options """
return self._options
def set_options(self, options):
""" Sets the list of options, options should be a list containing entries
whereas each entry is a tuple in the format (option_id, option_label).
The option ID can be an arbitrary object, and will not get modified. """
self._options = options
self._current_option_id = None
self._render_options()
options = property(get_options, set_options)
def _select_option(self, opt_id):
""" Internal method to select an option """
self._label.alpha = 1.0
for elem_opt_id, opt_val in self._options:
if opt_id == elem_opt_id:
self._label.text = opt_val
self._current_option_id = opt_id
return
self._label.alpha = 0.3
# def on_mouseover(self, event):
# """ Internal handle when the select-knob was hovered """
# self._bg_layout.color = (0.9,0.9,0.9,1.0)
# def on_mouseout(self, event):
# """ Internal handle when the select-knob was no longer hovered """
# self._bg_layout.color = (1,1,1,1.0)
def on_click(self, event):
""" On-Click handler """
self.request_focus()
if self._drop_open:
self._close_drop()
else:
self._open_drop()
def on_mousedown(self, event):
""" Mousedown handler """
self._bg_layout.alpha = 0.9
def on_mouseup(self, event):
""" Mouseup handler """
self._bg_layout.alpha = 1
def on_blur(self, event):
""" Internal handler when the selectbox lost focus """
if not self._drop_menu.focused:
self._close_drop()
def _open_drop(self):
""" Internal method to show the dropdown menu """
if not self._drop_open:
self._render_options()
self._drop_menu.show()
self.request_focus()
self._drop_open = True
def _close_drop(self):
""" Internal method to close the dropdown menu """
if self._drop_open:
self._drop_menu.hide()
self._drop_open = False
def _on_option_selected(self, opt_id):
""" Internal method when an option got selected """
self._select_option(opt_id)
self._close_drop()
class LUISelectdrop(LUIObject):
""" Internal class used by the selectbox, representing the dropdown menu """
def __init__(self, parent, width=200):
LUIObject.__init__(self, x=0, y=0, w=width, h=1, solid=True)
self._layout = LUICornerLayout(parent=self, image_prefix="Selectdrop_",
width=width + 10, height=100)
self._layout.margin.left = -3
self._opener = LUISprite(self, "SelectboxOpen_Right", "skin")
self._opener.right = -4
self._opener.top = -25
self._opener.z_offset = 3
self._container = LUIObject(self._layout, 0, 0, 0, 0)
self._container.width = self.width
self._container.clip_bounds = (0,0,0,0)
self._container.left = 5
self._container.solid = True
self._container.bind("mousedown", lambda *args: self.request_focus())
self._selectbox = parent
self._option_focus = False
self.parent = self._selectbox
def _on_opt_over(self, event):
""" Inernal handler when an option got hovered """
event.sender.color = (0,0,0,0.1)
def _on_opt_out(self, event):
""" Inernal handler when an option got no longer hovered """
event.sender.color = (0,0,0,0)
def _on_opt_click(self, opt_id, event):
""" Internal handler when an option got clicked """
self._selectbox._on_option_selected(opt_id)
def _render_options(self, options):
""" Internal method to update the options """
num_visible_options = min(30, len(options))
offset_top = 6
self._layout.height = num_visible_options * 30 + offset_top + 11
self._container.height = num_visible_options * 30 + offset_top + 1
self._container.remove_all_children()
current_y = offset_top
for opt_id, opt_val in options:
opt_container = LUIObject(self._container, x=0, y=current_y, w=self._container.width - 30, h=30)
opt_bg = LUISprite(opt_container, "blank", "skin")
opt_bg.width = self._container.width
opt_bg.height = opt_container.height
opt_bg.color = (0,0,0,0)
opt_bg.bind("mouseover", self._on_opt_over)
opt_bg.bind("mouseout", self._on_opt_out)
opt_bg.bind("mousedown", lambda *args: self.request_focus())
opt_bg.bind("click", partial(self._on_opt_click, opt_id))
opt_bg.solid = True
opt_label = LUILabel(parent=opt_container, text=opt_val)
opt_label.top = 8
opt_label.left = 8
if opt_id == self._selectbox.selected_option:
opt_label.color = (0.6, 0.9, 0.4, 1.0)
divider = LUISprite(opt_container, "SelectdropDivider", "skin")
divider.top = 30 - divider.height / 2
divider.width = self._container.width
current_y += 30

53
ui/Builtin/LUISkin.py Normal file
View File

@ -0,0 +1,53 @@
import os
from os.path import join
from panda3d.core import Filename
from panda3d.lui import LUIFontPool, LUIAtlasPool
class LUISkin:
""" Abstract class, each skin derives from this class """
skin_location = ""
def __init__(self):
pass
def load(self):
""" Skins should override this. Each skin should at least provide the fonts
'default' and 'label', and at least one atlas named 'skin' """
raise NotImplementedError()
def get_resource(self, pth):
""" Turns a relative path into an absolute one, using the skin_location """
return Filename.from_os_specific(join(self.skin_location, pth)).get_fullpath()
class LUIDefaultSkin(LUISkin):
""" The default skin which comes with LUI """
skin_location = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../Skins/Default/")
def __init__(self):
pass
def load(self):
LUIFontPool.get_global_ptr().register_font(
"default", loader.loadFont(self.get_resource("font/SourceSansPro-Semibold.ttf")))
labelFont = loader.loadFont(self.get_resource("font/SourceSansPro-Semibold.ttf"))
labelFont.setPixelsPerUnit(32)
LUIFontPool.get_global_ptr().register_font(
"label", labelFont)
headerFont = loader.loadFont(self.get_resource("font/SourceSansPro-Light.ttf"))
headerFont.setPixelsPerUnit(80)
LUIFontPool.get_global_ptr().register_font("header", headerFont)
LUIAtlasPool.get_global_ptr().load_atlas("skin",
self.get_resource("res/atlas.txt"),
self.get_resource("res/atlas.png"))

214
ui/Builtin/LUISlider.py Normal file
View File

@ -0,0 +1,214 @@
from LUIObject import LUIObject
from LUISprite import LUISprite
from LUIInitialState import LUIInitialState
from LUILayouts import LUIHorizontalStretchedLayout
class LUISlider(LUIObject):
""" Slider which can be used to control values """
KNOB_Y_OFFSET = -5.0
def __init__(self, parent=None, filled=True, min_value=0.0, max_value=1.0, width=100.0, value=None, **kwargs):
""" Constructs a new slider. If filled is True, the part behind the knob
will be solid """
LUIObject.__init__(self, x=0, y=0, solid=True)
self.set_width(width)
self._knob = LUISprite(self, "SliderKnob", "skin")
self._knob.z_offset = 2
self._knob.solid = True
# Construct the background
self._slider_bg = LUIHorizontalStretchedLayout(parent=self, prefix="SliderBg", center_vertical=True, width="100%", margin=(-1, 0, 0, 0))
self._filled = filled
self._min_value = min_value
self._max_value = max_value
self._side_margin = self._knob.width / 4
self._effective_width = self.width - 2 * self._side_margin
if self._filled:
self._slider_fill = LUIObject(self)
self._fill_left = LUISprite(self._slider_fill, "SliderBgFill_Left", "skin")
self._fill_mid = LUISprite(self._slider_fill, "SliderBgFill", "skin")
self._fill_mid.left = self._fill_left.width
self._slider_fill.z_offset = 1
self._slider_fill.center_vertical = True
self._slider_fill.width = "100%"
self._slider_fill.height = "100%"
if parent is not None:
self.parent = parent
# Handle various events
self._knob.bind("mousedown", self._start_drag)
self._knob.bind("mousemove", self._update_drag)
self._knob.bind("mouseup", self._stop_drag)
self._knob.bind("keydown", self._on_keydown)
self._knob.bind("blur", self._stop_drag)
self._knob.bind("keyrepeat", self._on_keydown)
self._drag_start_pos = None
self._dragging = False
self._drag_start_val = 0
self.current_val = 10
# Set initial value
if value is None:
self.set_value( (self._min_value + self._max_value) / 2.0 )
else:
self.set_value(value)
self._update_knob()
LUIInitialState.init(self, kwargs)
self._refresh_layout()
def on_click(self, event):
""" Internal on click handler """
# I don't like this behaviour
# relative_pos = self.get_relative_pos(event.coordinates)
# if not self._dragging:
# self._set_current_val(relative_pos.x)
def _update_knob(self):
""" Internal method to update the slider knob """
self._knob.left = self.current_val - (self._knob.width / 2) + self._side_margin
if self._filled:
knob_center = self._knob.left + (self._knob.width / 2)
fill_len = max(0, knob_center - self._fill_left.width)
self._fill_mid.width = fill_len
if hasattr(self._slider_fill, 'left'):
self._slider_fill.left = 0
def _refresh_layout(self):
"""Recalculate slider layout after size changes."""
# Guard for early init before internal parts are created
if not hasattr(self, "_knob"):
return
try:
# Use half knob width so knob center aligns with bar ends
self._side_margin = self._knob.width / 2
except Exception:
self._side_margin = 0
try:
self._effective_width = max(1, self.width - (self._knob.width if hasattr(self, "_knob") else 0))
except Exception:
self._effective_width = 1
if hasattr(self, "_slider_bg"):
if hasattr(self._slider_bg, 'width'):
self._slider_bg.width = "100%"
if hasattr(self._slider_bg, 'height'):
self._slider_bg.height = "100%"
if hasattr(self._slider_bg, 'center_vertical'):
self._slider_bg.center_vertical = True
if hasattr(self._knob, 'center_vertical'):
self._knob.center_vertical = True
# Explicit vertical centering for knob to avoid mismatch after resize
try:
base_h = self.height
base_top = 0
if hasattr(self, "_slider_bg") and self._slider_bg is not None:
bg_h = getattr(self._slider_bg, "height", None)
if bg_h is not None and bg_h != "100%":
base_h = bg_h
base_top = getattr(self._slider_bg, "top", 0) or 0
if hasattr(self._knob, "height"):
# Visual offset compensates for the skin's knob pivot
self._knob.top = (base_h - self._knob.height) / 2.0 + self.KNOB_Y_OFFSET
except Exception:
pass
if self._filled and hasattr(self, '_slider_fill'):
self._slider_fill.center_vertical = True
try:
base_h = self.height
base_top = 0
if hasattr(self, "_slider_bg") and self._slider_bg is not None:
bg_h = getattr(self._slider_bg, "height", None)
if bg_h is not None and bg_h != "100%":
base_h = bg_h
base_top = getattr(self._slider_bg, "top", 0) or 0
if hasattr(self._slider_fill, 'height'):
self._slider_fill.top = base_top + (base_h - self._slider_fill.height) / 2.0 + self.KNOB_Y_OFFSET
except Exception:
pass
if hasattr(self._slider_fill, 'width'):
self._slider_fill.width = "100%"
if hasattr(self._slider_fill, 'height'):
self._slider_fill.height = "100%"
try:
self.current_val = max(0, min(self.current_val, self._effective_width))
except Exception:
pass
self._update_knob()
def set_width(self, width):
"""Set slider width and refresh layout."""
self.width = width
if hasattr(self, "_slider_bg"):
self._refresh_layout()
def set_height(self, height):
"""Set slider height and refresh layout."""
self.height = height
if hasattr(self, "_slider_bg"):
self._refresh_layout()
def _set_current_val(self, pixels):
""" Internal method to set the current value in pixels """
pixels = max(0, min(self._effective_width, pixels))
self.current_val = pixels
self.trigger_event("changed")
self._update_knob()
def _start_drag(self, event):
""" Internal drag start handler """
self._knob.request_focus()
if not self._dragging:
self._drag_start_pos = event.coordinates
self._dragging = True
self._drag_start_val = self.current_val
self._knob.color = (0.8,0.8,0.8,1.0)
def set_value(self, value):
""" Sets the value of the slider, should be between minimum and maximum. """
scaled = (float(value) - float(self._min_value)) \
/ (float(self._max_value) - float(self._min_value)) \
* self._effective_width
self._set_current_val(scaled)
def get_value(self):
""" Returns the current value of the slider """
return (self.current_val / float(self._effective_width)) \
* (float(self._max_value) - float(self._min_value)) \
+ self._min_value
value = property(get_value, set_value)
def _on_keydown(self, event):
""" Internal keydown handler """
if event.message == "arrow_right":
self._set_current_val(self.current_val + 2)
elif event.message == "arrow_left":
self._set_current_val(self.current_val - 2)
elif event.message == "escape":
self.current_val = self._drag_start_val
self._stop_drag(event)
self._update_knob()
def _update_drag(self, event):
""" Internal drag handler """
if self._dragging:
dragOffset = event.coordinates.x - self._drag_start_pos.x
finalValue = self._drag_start_val + dragOffset
self._set_current_val(finalValue)
def _stop_drag(self, event):
""" Internal drag stop handelr """
self._drag_start_pos = None
self._dragging = False
self._drag_start_val = self.current_val
self._knob.color = (1,1,1,1)

18
ui/Builtin/LUISprite.py Normal file
View File

@ -0,0 +1,18 @@
"""
This is a wrapper file. It contains no actual implementation
"""
from panda3d.lui import LUISprite as _LUISprite
from LUIInitialState import LUIInitialState
__all__ = ["LUISprite"]
class LUISprite(_LUISprite):
""" This is a wrapper class for the C++ LUISprite class, to be able to
use it in a more convenient way """
def __init__(self, *args, **kwargs):
_LUISprite.__init__(self, *args)
LUIInitialState.init(self, kwargs)

View File

@ -0,0 +1,30 @@
from LUIObject import LUIObject
from LUISprite import LUISprite
from LUIInitialState import LUIInitialState
class LUISpriteButton(LUIObject):
""" Simple button that uses only two images: Default and focus. """
def __init__(self, template="ButtonDefault", **kwargs):
LUIObject.__init__(self, x=0, y=0, solid=True)
self._template = template
self._button_sprite = LUISprite(self, template, "skin")
if 'width' in kwargs:
self._button_sprite.width = kwargs['width']
if 'height' in kwargs:
self._button_sprite.height = kwargs['height']
LUIInitialState.init(self, kwargs)
def on_mousedown(self, event):
""" Internal on_mousedown handler. Do not override """
self._button_sprite.set_texture(self.template + "Focus", "skin", resize=False)
def on_mouseup(self, event):
""" Internal on_mouseup handler. Do not override """
self._button_sprite.set_texture(self.template, "skin", resize=False)
def on_click(self, event):
""" Internal onclick handler. Do not override """
self.trigger_event("changed")

View File

@ -0,0 +1,87 @@
from LUIFrame import LUIFrame
from LUILabel import LUILabel
from LUIObject import LUIObject
from LUIVerticalLayout import LUIVerticalLayout
from LUIHorizontalLayout import LUIHorizontalLayout
class LUITabbedFrame(LUIFrame):
def __init__(self, **kwargs):
super(LUITabbedFrame, self).__init__(**kwargs)
# The main window layout
bar_spacing = kwargs.get('bar_spacing', 3)
self.root_layout = LUIVerticalLayout(parent = self,
spacing = bar_spacing)
self.root_layout.height = "100%"
self.root_layout.width = "100%"
self.root_layout.margin = 0
# The header bar
header_spacing = kwargs.get('header_spacing', 3)
self.header_bar = LUIHorizontalLayout(parent = self.root_layout.cell("?"),
spacing = header_spacing)
self.root_layout.add(self.header_bar, "?")
self.header_to_frame = {}
self.current_frame = None
# The main window contents
self.main_frame = LUIObject()
self.main_frame.height = "100%"
self.main_frame.width = "100%"
self.main_frame.margin = 0
# self.main_frame.padding = 0
self.root_layout.add(self.main_frame, "*")
self.bind("expose", self.on_expose)
self.bind("unexpose", self.on_unexpose)
def add(self, header, frame):
# header
if isinstance(header, str):
header = LUILabel(text = header)
self.header_bar.add(header, "?")
self.header_to_frame[header] = frame
header.solid = True
header.bind("click", self._change_to_tab)
# Frame
frame.parent = self.main_frame
frame.width = "100%"
frame.height = "100%"
# Put frame in front
if self.current_frame is None:
self.current_frame = frame
self.current_frame.show()
else:
frame.hide()
return header
def _find_header_index(self, header):
for idx, child in enumerate(self.header_bar.children):
if any([grandchild == header for grandchild in child.children]):
break
else:
raise ValueError("Given object is not a header bar item.")
return idx
def remove(self, header):
idx = self._find_header_index(header)
self.header_bar.remove_cell(idx)
frame = self.header_to_frame[header]
frame.parent = None
del self.header_to_frame[header]
if self.current_frame == frame:
self.current_frame = None
def _change_to_tab(self, lui_event):
header = lui_event.sender
if self.current_frame is not None:
self.current_frame.trigger_event("unexpose")
self.current_frame.hide()
self.current_frame = self.header_to_frame[header]
self.current_frame.show()
self.current_frame.trigger_event("expose")
def on_expose(self, event):
self.current_frame.trigger_event("expose")
def on_unexpose(self, event):
self.current_frame.trigger_event("unexpose")

View File

@ -0,0 +1,20 @@
"""
This is a wrapper file. It contains no actual implementation
"""
from panda3d.lui import LUIVerticalLayout as _LUIVerticalLayout
from LUIInitialState import LUIInitialState
__all__ = ["LUIVerticalLayout"]
class LUIVerticalLayout(_LUIVerticalLayout):
""" This is a wrapper class for the C++ LUIVerticalLayout class, to be
able to use it in a more convenient way. It leverages LUIInitialState
to be able to pass arbitrary keyword arguments. """
def __init__(self, parent=None, spacing=0.0, **kwargs):
_LUIVerticalLayout.__init__(self, parent, spacing)
LUIInitialState.init(self, kwargs)

View File

@ -0,0 +1,85 @@
"""RectTransform - 一个轻量级的 Unity-like RectTransform 实现Python 层 MVP
提供 anchors/pivot/anchored_position/size_delta 的数据结构和
把相对锚点映射到像素矩形的计算方法
此实现不修改底层 C++ LUI而是在 Python 层计算并把结果应用到
`LUIObject` `left/top/width/height` 属性上
"""
from typing import Tuple
class RectTransform(object):
def __init__(self,
anchor_min=(0.5, 0.5),
anchor_max=(0.5, 0.5),
pivot=(0.5, 0.5),
anchored_position=(0.0, 0.0),
size_delta=(100.0, 30.0)):
# 值域: anchor/pivot 为 0..1anchored_position/size_delta 为像素
self.anchor_min = tuple(anchor_min)
self.anchor_max = tuple(anchor_max)
self.pivot = tuple(pivot)
self.anchored_position = tuple(anchored_position)
self.size_delta = tuple(size_delta)
def compute_pixel_rect(self, parent_rect: Tuple[float, float, float, float]):
"""
RectTransform 转换为相对于父节点的像素矩形 (left, top, width, height)
parent_rect: (parent_left, parent_top, parent_width, parent_height)
"""
p_left, p_top, p_w, p_h = parent_rect
a_min_x = self.anchor_min[0]
a_min_y = self.anchor_min[1]
a_max_x = self.anchor_max[0]
a_max_y = self.anchor_max[1]
# 计算锚框的像素坐标left/top 和 right/bottom
anchor_left = p_left + a_min_x * p_w
anchor_right = p_left + a_max_x * p_w
anchor_top = p_top + a_min_y * p_h
anchor_bottom = p_top + a_max_y * p_h
# 宽高
if abs(a_max_x - a_min_x) < 1e-6:
# 固定锚点 -> 使用 size_delta.x
width = float(self.size_delta[0])
# anchored_position.x 表示相对于锚点框的位置(像素)
left = anchor_left + float(self.anchored_position[0]) - self.pivot[0] * width
else:
# 拉伸到锚框再加上 size_delta.x
width = (anchor_right - anchor_left) + float(self.size_delta[0])
# anchored_position.x 表示锚框内的像素偏移
left = anchor_left + float(self.anchored_position[0])
if abs(a_max_y - a_min_y) < 1e-6:
height = float(self.size_delta[1])
# 注意engine 中 top 增大表示向下,所以 anchored_position.y 保持像素系
top = anchor_top + float(self.anchored_position[1]) - self.pivot[1] * height
else:
height = (anchor_bottom - anchor_top) + float(self.size_delta[1])
top = anchor_top + float(self.anchored_position[1])
return (left, top, width, height)
def apply_to_lui(self, lui_obj, parent_rect: Tuple[float, float, float, float]):
"""计算并应用到给定的 LUIObject设置 left/top/width/height"""
left, top, width, height = self.compute_pixel_rect(parent_rect)
try:
lui_obj.left = left
lui_obj.top = top
lui_obj.width = width
lui_obj.height = height
except Exception:
# 忽略不能设置的属性,调用者应保证对象支持这些属性
pass
def to_dict(self):
return {
'anchor_min': tuple(self.anchor_min),
'anchor_max': tuple(self.anchor_max),
'pivot': tuple(self.pivot),
'anchored_position': tuple(self.anchored_position),
'size_delta': tuple(self.size_delta),
}

0
ui/Builtin/__init__.py Normal file
View File

View File

@ -0,0 +1,5 @@
@echo off
cd res
ppython ../../../Misc/LUIAtlasGen.py
pause

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Some files were not shown because too many files have changed in this diff Show More