Compare commits
78 Commits
main
...
hu_migrate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad451729f9 | ||
|
|
24bfb4dbb1 | ||
|
|
9f1ce6812f | ||
|
|
283c891554 | ||
| eb18e84bc9 | |||
| 2bf1f8c345 | |||
|
|
885535031c | ||
|
|
939ea35913 | ||
|
|
976407d803 | ||
|
|
78ffa8efba | ||
|
|
f9f060b1ac | ||
| 26faa11ae0 | |||
|
|
5d1113cd8b | ||
| bab5a9bb27 | |||
|
|
ab6d543dc0 | ||
|
|
6a99c3cc2a | ||
|
|
90239b7051 | ||
|
|
5c0a61d253 | ||
|
|
4723bd9746 | ||
|
|
062bd4e720 | ||
|
|
37f70db122 | ||
|
|
00081072f9 | ||
|
|
d908b7d699 | ||
|
|
a6986002a7 | ||
|
|
f17073160c | ||
|
|
c14c2b8796 | ||
|
|
2822dfee71 | ||
|
|
b9053b6a28 | ||
|
|
9bbcc89daa | ||
|
|
1aa21c2680 | ||
|
|
46fa1756a1 | ||
|
|
4ef6ce97d4 | ||
|
|
776d3c01aa | ||
|
|
d609cb5f39 | ||
|
|
978aeac905 | ||
|
|
07359082cd | ||
|
|
04cb0dc441 | ||
|
|
8a1a0d19ba | ||
|
|
450fcbb123 | ||
|
|
bdd4d0f1b5 | ||
|
|
74b6a3307c | ||
|
|
da629e508d | ||
|
|
4635458300 | ||
|
|
6524f8306b | ||
|
|
c4a507c6ac | ||
|
|
11ad2d5742 | ||
|
|
49d630f967 | ||
|
|
7e6b1d54d9 | ||
|
|
8a80af9825 | ||
|
|
6e76b54ddb | ||
|
|
1cfbeda77b | ||
|
|
b48616d5f2 | ||
|
|
780536203e | ||
|
|
c5dbc6be6f | ||
|
|
718cf802be | ||
|
|
22a9b5b64a | ||
|
|
c006b69613 | ||
|
|
4f70cc113b | ||
|
|
d32a604c2f | ||
|
|
c2e528c55f | ||
|
|
381a478abf | ||
|
|
3a13025009 | ||
|
|
e9784b3400 | ||
|
|
37b3cc30dc | ||
|
|
036b68ef41 | ||
|
|
5752559cdd | ||
|
|
7386d687da | ||
|
|
28d2b124cc | ||
|
|
2ac08b0582 | ||
|
|
86aaa21ddd | ||
|
|
af898947b5 | ||
|
|
f3f8da7b90 | ||
|
|
2183d3fc3e | ||
|
|
756db5b010 | ||
|
|
1fd7e1d7ac | ||
|
|
53e6a829e4 | ||
|
|
e917f9019d | ||
|
|
c93ab3edac |
11
.codex/environments/environment-2.toml
Normal file
@ -0,0 +1,11 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "EG"
|
||||
|
||||
[setup]
|
||||
script = ""
|
||||
|
||||
[[actions]]
|
||||
name = "go"
|
||||
icon = "tool"
|
||||
command = "python ./main.py"
|
||||
11
.codex/environments/environment.toml
Normal file
@ -0,0 +1,11 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "EG"
|
||||
|
||||
[setup]
|
||||
script = ""
|
||||
|
||||
[[actions]]
|
||||
name = "运行"
|
||||
icon = "run"
|
||||
command = "python ./main.py"
|
||||
18
.gitignore
vendored
@ -1,7 +1,17 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.idea/
|
||||
【模板】模型及算法与功能对应清单.docx
|
||||
__pycache__/
|
||||
*.pyc
|
||||
imgui.ini
|
||||
build/
|
||||
Builds/
|
||||
Library/
|
||||
**/Library/
|
||||
**/Builds/
|
||||
**/Scenes/*_gui.json
|
||||
**/Scenes/*_lui.json
|
||||
**/scenes/*_gui.json
|
||||
**/scenes/*_lui.json
|
||||
.idea/
|
||||
【模板】模型及算法与功能对应清单.docx
|
||||
【模板】GY知识-产品模块架构及功能清单和主要应用场景说明.docx
|
||||
1.json
|
||||
2.json
|
||||
|
||||
BIN
.venv311/Lib/site-packages/imgui_bundle/glfw3.dll
Normal file
BIN
.venv311/Lib/site-packages/imgui_bundle/glfw3dll.lib
Normal file
BIN
.venv311/Lib/site-packages/imgui_bundle/opencv_world4100.dll
Normal file
4
.vscode/settings.json
vendored
@ -1,4 +1,4 @@
|
||||
{
|
||||
"python-envs.pythonProjects": [],
|
||||
"kiroAgent.configureMCP": "Disabled"
|
||||
"kiroAgent.configureMCP": "Disabled",
|
||||
"python-envs.defaultEnvManager": "ms-python.python:pyenv"
|
||||
}
|
||||
219
AGENTS.md
@ -1,219 +0,0 @@
|
||||
# EG 项目概览与开发指南
|
||||
|
||||
## 项目简介
|
||||
|
||||
EG 是一个基于 Panda3D 引擎开发的 3D 编辑器和游戏引擎,集成了高级渲染管线、VR 支持、物理模拟、脚本系统等功能。该项目主要用于创建 3D 场景、游戏开发和交互式应用程序。
|
||||
|
||||
## 核心技术栈
|
||||
|
||||
- **渲染引擎**: Panda3D 1.10.15 + RenderPipeline (延迟渲染、PBR材质)
|
||||
- **GUI框架**: PyQt5 + imgui_bundle (用于编辑器界面)
|
||||
- **VR支持**: OpenVR 2.2.0
|
||||
- **脚本系统**: Python 3.10.12
|
||||
- **其他依赖**: PyQt5-WebEngine, assimp, pillow 等
|
||||
|
||||
## 项目架构
|
||||
|
||||
### 核心模块
|
||||
|
||||
- **main.py** - 应用程序入口点,初始化所有系统
|
||||
- **Start_Run.py** - 启动脚本,设置环境变量和路径
|
||||
- **core/** - 核心功能模块
|
||||
- `world.py` - 世界管理器,处理场景渲染和更新
|
||||
- `selection.py` - 选择系统,处理对象选择
|
||||
- `event_handler.py` - 事件处理系统
|
||||
- `tool_manager.py` - 工具管理器
|
||||
- `script_system.py` - 脚本系统
|
||||
- `patrol_system.py` - 巡逻系统
|
||||
- `Command_System.py` - 命令系统
|
||||
- `terrain_manager.py` - 地形管理
|
||||
- `vr_manager.py` - VR 功能管理
|
||||
- `collision_manager.py` - 碰撞检测管理
|
||||
- `resource_manager.py` - 资源管理
|
||||
- `assembly_interaction.py` - 装配交互
|
||||
- `maintenance_gui.py` - 维护界面
|
||||
- `render_pipeline_utils.py` - 渲染管线工具
|
||||
- `InfoPanelManager.py` - 信息面板管理
|
||||
- `CustomMouseController.py` - 自定义鼠标控制器
|
||||
|
||||
### GUI 系统
|
||||
|
||||
- **gui/gui_manager.py** - GUI管理器,处理2D/3D界面元素
|
||||
- **ui/icon_manager.py** - 图标管理器
|
||||
|
||||
### 场景管理
|
||||
|
||||
- **scene/scene_manager.py** - 场景管理器,处理模型导入、场景树构建
|
||||
- **scene/util.py** - 场景工具函数
|
||||
|
||||
### 项目管理
|
||||
|
||||
- **project/project_manager.py** - 项目管理器,处理项目创建、保存、加载
|
||||
|
||||
### 脚本系统
|
||||
|
||||
- **scripts/** - 包含各种预定义脚本
|
||||
- `MoverScript.py` - 移动脚本
|
||||
- `RotatorScript.py` - 旋转脚本
|
||||
- `ScalerScript.py` - 缩放脚本
|
||||
- `ColorChangerScript.py` - 颜色变化脚本
|
||||
- `FollowerScript.py` - 跟随脚本
|
||||
- `BouncerScript.py` - 弹跳脚本
|
||||
- `ComboAnimatorScript.py` - 组合动画脚本
|
||||
|
||||
### VR 系统
|
||||
|
||||
- **vr_actions/** - VR动作配置
|
||||
- `actions.json` - VR动作定义
|
||||
- `bindings_*.json` - 不同VR设备的绑定配置
|
||||
|
||||
### 资源管理
|
||||
|
||||
- **Resources/** - 资源目录
|
||||
- `models/` - 3D模型
|
||||
- `textures/` - 纹理贴图
|
||||
- `materials/` - 材质文件
|
||||
- `animations/` - 动画文件
|
||||
- `icons/` - 图标资源
|
||||
|
||||
### 渲染管线
|
||||
|
||||
- **RenderPipelineFile/** - 高级渲染管线
|
||||
- `rpcore/` - 渲染管线核心
|
||||
- `rpplugins/` - 渲染插件
|
||||
- `effects/` - 后处理效果
|
||||
- `config/` - 渲染配置
|
||||
|
||||
## 启动和运行
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Python 3.10.12
|
||||
- Panda3D 1.10.15
|
||||
- PyQt5 5.15.9
|
||||
- OpenVR 2.2.0 (VR功能)
|
||||
|
||||
### 运行方式
|
||||
|
||||
1. **直接运行主程序**:
|
||||
```bash
|
||||
python Start_Run.py
|
||||
```
|
||||
|
||||
2. **带项目路径运行**:
|
||||
```bash
|
||||
python Start_Run.py /path/to/project
|
||||
```
|
||||
|
||||
3. **从main.py运行**:
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 配置文件
|
||||
|
||||
- **config/vr_settings.json** - VR渲染配置
|
||||
- **imgui.ini** - ImGui界面配置
|
||||
- **.gitignore** - Git忽略文件配置
|
||||
|
||||
## 开发约定
|
||||
|
||||
### 代码风格
|
||||
|
||||
- 使用UTF-8编码
|
||||
- 遵循PEP 8代码规范
|
||||
- 类名使用驼峰命名法
|
||||
- 函数和变量使用下划线命名法
|
||||
- 文件头部包含模块说明和导入信息
|
||||
|
||||
### 脚本开发
|
||||
|
||||
- 所有用户脚本应继承 `ScriptBase` 类
|
||||
- 脚本文件放在 `scripts/` 目录下
|
||||
- 使用 `ScriptManager` 管理脚本生命周期
|
||||
- 脚本API通过 `world` 对象提供
|
||||
|
||||
### 插件开发
|
||||
|
||||
- 插件系统支持动态加载
|
||||
- 插件配置文件使用JSON格式
|
||||
- 插件应实现标准接口
|
||||
|
||||
### VR开发
|
||||
|
||||
- VR动作配置在 `vr_actions/actions.json` 中定义
|
||||
- 支持多种VR设备(Vive、Oculus、Index)
|
||||
- VR渲染配置在 `config/vr_settings.json` 中
|
||||
|
||||
## 构建和部署
|
||||
|
||||
### 依赖安装
|
||||
|
||||
```bash
|
||||
pip install -r requirements/requirements.txt
|
||||
```
|
||||
|
||||
### 测试
|
||||
|
||||
项目包含多个测试脚本:
|
||||
- `test_quick_script.py` - 快速测试脚本
|
||||
- `TestMover.py` - 移动测试
|
||||
- `TestRotator.py` - 旋转测试
|
||||
- `TestScaler.py` - 缩放测试
|
||||
|
||||
### 项目文件
|
||||
|
||||
- 项目配置使用JSON格式
|
||||
- 项目文件包含场景、资源、脚本等信息
|
||||
- 使用 `ProjectManager` 管理项目生命周期
|
||||
|
||||
## 常见问题
|
||||
|
||||
### VR相关问题
|
||||
|
||||
1. 确保VR设备已正确连接
|
||||
2. 检查OpenVR运行时是否安装
|
||||
3. 验证VR动作配置是否正确
|
||||
|
||||
### 渲染问题
|
||||
|
||||
1. 检查显卡驱动是否最新
|
||||
2. 确认RenderPipeline配置正确
|
||||
3. 验证材质和纹理路径
|
||||
|
||||
### 性能优化
|
||||
|
||||
1. 使用合适的LOD设置
|
||||
2. 优化场景复杂度
|
||||
3. 调整渲染质量设置
|
||||
|
||||
## 扩展开发
|
||||
|
||||
### 添加新脚本
|
||||
|
||||
1. 在 `scripts/` 目录创建新脚本文件
|
||||
2. 继承 `ScriptBase` 类
|
||||
3. 实现必要的方法
|
||||
4. 通过 `ScriptManager` 注册脚本
|
||||
|
||||
### 添加新工具
|
||||
|
||||
1. 在 `core/tool_manager.py` 中注册新工具
|
||||
2. 实现工具逻辑
|
||||
3. 添加GUI界面元素
|
||||
|
||||
### 添加新渲染效果
|
||||
|
||||
1. 在 `RenderPipelineFile/rpplugins/` 目录创建插件
|
||||
2. 实现渲染逻辑
|
||||
3. 添加配置选项
|
||||
|
||||
## 联系和支持
|
||||
|
||||
- 项目Git仓库: http://10.0.0.99:4000/Rowland/EG.git
|
||||
- 当前分支: imgui
|
||||
- 最新提交: 移除qt依赖 (33e62bd1e4c2c8d3aac15e045b419edb8992d7ff)
|
||||
|
||||
---
|
||||
|
||||
*该文档由iFlow CLI自动生成,最后更新时间: 2026年1月28日*
|
||||
8
Assets/Models/box1.glb.meta
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"guid": "2c994aea36c94385a8f9a5274c406a67",
|
||||
"asset_type": "model",
|
||||
"source_hash": "fc694905c47a9b0005d77b701cc41852b56ef08c7406829a306e98f3ce158a64",
|
||||
"importer": "model_importer",
|
||||
"import_settings": {},
|
||||
"dependency_guids": []
|
||||
}
|
||||
BIN
Assets/Models/jyc.glb
Normal file
8
Assets/Models/jyc.glb.meta
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"guid": "b547e6c1347a4ec0bb913a9cb1a7ec6b",
|
||||
"asset_type": "model",
|
||||
"source_hash": "46fc525a88f6d16eed0bac714a14692b68818d36a00065690b5877d878788948",
|
||||
"importer": "model_importer",
|
||||
"import_settings": {},
|
||||
"dependency_guids": []
|
||||
}
|
||||
1
EG
@ -1 +0,0 @@
|
||||
Subproject commit 69e2bda47e9713705ad5c45a08b6fc643a2b51f6
|
||||
BIN
Panda3D-1.10/618a39808b4eb20ba86a9f6cabe5b91e.bam
Normal file
BIN
Panda3D-1.10/index-aa08c2.boo
Normal file
1
Panda3D-1.10/index_name.txt
Normal file
@ -0,0 +1 @@
|
||||
index-aa08c2.boo
|
||||
@ -8,7 +8,7 @@ pipeline:
|
||||
# it will also disable the hotkeys, and give a small performance boost.
|
||||
# Most likely you also don't want to show it in your own game, so set
|
||||
# it to false in that case.
|
||||
display_debugger: true
|
||||
display_debugger: false
|
||||
|
||||
# Affects which debugging information is displayed. If this is set to false,
|
||||
# only frame time is displayed, otherwise much more information is visible.
|
||||
|
||||
@ -9,10 +9,11 @@ enabled:
|
||||
- color_correction
|
||||
- forward_shading
|
||||
- motion_blur
|
||||
- pssm
|
||||
#- pssm
|
||||
- scattering
|
||||
- skin_shading
|
||||
- sky_ao
|
||||
- selection_outline
|
||||
- smaa
|
||||
- ssr
|
||||
# - clouds
|
||||
|
||||
@ -63,5 +63,6 @@ global_stage_order:
|
||||
|
||||
# Finishing stages, do not insert anything below
|
||||
- UpscaleStage
|
||||
- SelectionOutlineStage
|
||||
- FinalStage
|
||||
- UpdatePreviousPipesStage
|
||||
|
||||
@ -10,17 +10,21 @@ vertex_shader: |
|
||||
texcoord = p3d_MultiTexCoord0;
|
||||
}
|
||||
|
||||
fragment_shader: |
|
||||
#version 330
|
||||
uniform sampler2D p3d_Texture0;
|
||||
uniform float material_opacity = 1.0;
|
||||
in vec2 texcoord;
|
||||
out vec4 o_color;
|
||||
void main() {
|
||||
vec4 c = texture(p3d_Texture0, texcoord);
|
||||
o_color = vec4(c.rgb, c.a * material_opacity);
|
||||
}
|
||||
|
||||
render_states:
|
||||
TransparencyAttrib: M_alpha
|
||||
DepthWriteAttrib: 0
|
||||
fragment_shader: |
|
||||
#version 330
|
||||
uniform sampler2D p3d_Texture0;
|
||||
uniform vec4 material_base_color = vec4(1.0, 1.0, 1.0, 1.0);
|
||||
uniform float material_opacity = 1.0;
|
||||
in vec2 texcoord;
|
||||
out vec4 o_color;
|
||||
void main() {
|
||||
vec4 c = texture(p3d_Texture0, texcoord);
|
||||
o_color = vec4(
|
||||
material_base_color.rgb * c.rgb,
|
||||
material_base_color.a * c.a * material_opacity
|
||||
);
|
||||
}
|
||||
|
||||
render_states:
|
||||
TransparencyAttrib: M_alpha
|
||||
DepthWriteAttrib: 0
|
||||
|
||||
@ -45,6 +45,7 @@
|
||||
layout(location = 0) in VertexOutput vOutput;
|
||||
|
||||
uniform Panda3DMaterial p3d_Material;
|
||||
uniform vec4 p3d_ColorScale;
|
||||
|
||||
#pragma include "includes/normal_mapping.inc.glsl"
|
||||
#pragma include "includes/forward_shading.inc.glsl"
|
||||
@ -141,9 +142,9 @@ void main() {
|
||||
m.shading_model = mInput.shading_model;
|
||||
|
||||
#if DONT_FETCH_DEFAULT_TEXTURES
|
||||
m.basecolor = mInput.color;
|
||||
m.basecolor = mInput.color * p3d_ColorScale.xyz;
|
||||
#else
|
||||
m.basecolor = mInput.color * sampled_diffuse.xyz;
|
||||
m.basecolor = mInput.color * sampled_diffuse.xyz * p3d_ColorScale.xyz;
|
||||
#endif
|
||||
m.normal = material_nrm;
|
||||
m.metallic = mInput.metallic;
|
||||
@ -160,14 +161,21 @@ void main() {
|
||||
vec3 view_dir = normalize(m_out.position - MainSceneData.camera_pos);
|
||||
vec3 color = vec3(0);
|
||||
|
||||
float alpha = m_out.shading_model_param0;
|
||||
float alpha = m_out.shading_model_param0 * p3d_ColorScale.w;
|
||||
AmbientResult ambient = get_full_forward_ambient(m_out, view_dir);
|
||||
|
||||
color += ambient.diffuse;
|
||||
color += ambient.specular;
|
||||
color += get_sun_shading(m_out, view_dir);
|
||||
color += get_forward_light_shading(m_out);
|
||||
|
||||
// XXX: Apply shading from lights too
|
||||
// Transparent forward materials end up noticeably darker than the same
|
||||
// object in the deferred path in this editor build, which makes them look
|
||||
// "gone" when artists first switch the material type. Apply a small
|
||||
// preview-space lift so the result stays visually comparable.
|
||||
if (m_out.shading_model == SHADING_MODEL_TRANSPARENT) {
|
||||
color *= 1.55;
|
||||
}
|
||||
|
||||
alpha = mix(alpha, 1.0, ambient.fresnel);
|
||||
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
"""Selection outline plugin package."""
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
settings:
|
||||
daytime_settings:
|
||||
|
||||
15
RenderPipelineFile/rpplugins/selection_outline/plugin.py
Normal file
@ -0,0 +1,15 @@
|
||||
from rpcore.pluginbase.base_plugin import BasePlugin
|
||||
|
||||
from .selection_outline_stage import SelectionOutlineStage
|
||||
|
||||
|
||||
class Plugin(BasePlugin):
|
||||
|
||||
name = "Selection Outline"
|
||||
author = "EG Team"
|
||||
description = "Adds Unity-style selected-object outline as a post-process stage."
|
||||
version = "1.0"
|
||||
|
||||
def on_stage_setup(self):
|
||||
self.stage = self.create_stage(SelectionOutlineStage)
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
from panda3d.core import PNMImage, SamplerState, Texture, Vec4
|
||||
|
||||
from rpcore.render_stage import RenderStage
|
||||
|
||||
|
||||
class SelectionOutlineStage(RenderStage):
|
||||
|
||||
required_pipes = ["ShadedScene"]
|
||||
|
||||
def __init__(self, pipeline):
|
||||
RenderStage.__init__(self, pipeline)
|
||||
self._enabled = False
|
||||
self._outline_color = Vec4(1.0, 0.55, 0.0, 1.0)
|
||||
self._outline_width = 2.0
|
||||
self._fill_alpha = 0.0
|
||||
self._mask_tex = self._make_default_mask()
|
||||
|
||||
@property
|
||||
def produced_pipes(self):
|
||||
return {"ShadedScene": self.target.color_tex}
|
||||
|
||||
def create(self):
|
||||
self.target = self.create_target("SelectionOutline")
|
||||
self.target.add_color_attachment(bits=16)
|
||||
self.target.prepare_buffer()
|
||||
self._apply_inputs()
|
||||
|
||||
def reload_shaders(self):
|
||||
self.target.shader = self.load_plugin_shader("selection_outline.frag.glsl")
|
||||
self._apply_inputs()
|
||||
|
||||
def set_mask_texture(self, mask_texture):
|
||||
self._mask_tex = mask_texture if mask_texture else self._make_default_mask()
|
||||
self._apply_inputs()
|
||||
|
||||
def set_enabled_outline(self, enabled):
|
||||
self._enabled = bool(enabled)
|
||||
self._apply_inputs()
|
||||
|
||||
def set_outline_style(self, color=None, width_px=None, fill_alpha=None):
|
||||
if color is not None:
|
||||
self._outline_color = Vec4(color)
|
||||
if width_px is not None:
|
||||
self._outline_width = max(0.0, float(width_px))
|
||||
if fill_alpha is not None:
|
||||
self._fill_alpha = max(0.0, min(1.0, float(fill_alpha)))
|
||||
self._apply_inputs()
|
||||
|
||||
def _apply_inputs(self):
|
||||
enabled_val = 1.0 if self._enabled else 0.0
|
||||
self.target.set_shader_input("SelectionMaskTex", self._mask_tex)
|
||||
self.target.set_shader_input("SelectionOutlineEnabled", enabled_val)
|
||||
self.target.set_shader_input("SelectionOutlineColor", self._outline_color)
|
||||
self.target.set_shader_input("SelectionOutlineWidth", self._outline_width)
|
||||
self.target.set_shader_input("SelectionFillAlpha", self._fill_alpha)
|
||||
|
||||
def _make_default_mask(self):
|
||||
image = PNMImage(1, 1, 4)
|
||||
image.fill(0.0, 0.0, 0.0)
|
||||
image.alpha_fill(0.0)
|
||||
texture = Texture("selection_outline_default_mask")
|
||||
texture.load(image)
|
||||
texture.set_minfilter(SamplerState.FT_nearest)
|
||||
texture.set_magfilter(SamplerState.FT_nearest)
|
||||
texture.set_wrap_u(SamplerState.WM_clamp)
|
||||
texture.set_wrap_v(SamplerState.WM_clamp)
|
||||
return texture
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Selection outline post process stage.
|
||||
*/
|
||||
|
||||
#version 430
|
||||
|
||||
#pragma include "render_pipeline_base.inc.glsl"
|
||||
|
||||
uniform sampler2D ShadedScene;
|
||||
uniform sampler2D SelectionMaskTex;
|
||||
|
||||
uniform float SelectionOutlineEnabled;
|
||||
uniform vec4 SelectionOutlineColor;
|
||||
uniform float SelectionOutlineWidth; // pixels
|
||||
uniform float SelectionFillAlpha; // 0..1
|
||||
|
||||
out vec4 result;
|
||||
|
||||
float sample_mask(vec2 uv) {
|
||||
return textureLod(SelectionMaskTex, uv, 0).r;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = get_texcoord();
|
||||
vec3 scene_col = textureLod(ShadedScene, uv, 0).rgb;
|
||||
|
||||
if (SelectionOutlineEnabled < 0.5) {
|
||||
result = vec4(scene_col, 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
vec2 texel = vec2(max(1.0, SelectionOutlineWidth)) / SCREEN_SIZE;
|
||||
float center = sample_mask(uv);
|
||||
|
||||
float max_nei = 0.0;
|
||||
max_nei = max(max_nei, sample_mask(uv + vec2( texel.x, 0.0)));
|
||||
max_nei = max(max_nei, sample_mask(uv + vec2(-texel.x, 0.0)));
|
||||
max_nei = max(max_nei, sample_mask(uv + vec2(0.0, texel.y)));
|
||||
max_nei = max(max_nei, sample_mask(uv + vec2(0.0, -texel.y)));
|
||||
max_nei = max(max_nei, sample_mask(uv + vec2( texel.x, texel.y)));
|
||||
max_nei = max(max_nei, sample_mask(uv + vec2( texel.x, -texel.y)));
|
||||
max_nei = max(max_nei, sample_mask(uv + vec2(-texel.x, texel.y)));
|
||||
max_nei = max(max_nei, sample_mask(uv + vec2(-texel.x, -texel.y)));
|
||||
|
||||
// Outer contour only.
|
||||
float edge = clamp(max_nei - center, 0.0, 1.0);
|
||||
float fill = center * SelectionFillAlpha;
|
||||
|
||||
vec3 col = scene_col;
|
||||
if (fill > 0.0) {
|
||||
col = mix(col, SelectionOutlineColor.rgb, fill * SelectionOutlineColor.a);
|
||||
}
|
||||
if (edge > 0.0) {
|
||||
col = mix(col, SelectionOutlineColor.rgb, edge * SelectionOutlineColor.a);
|
||||
}
|
||||
|
||||
result = vec4(col, 1.0);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
BIN
Resources/models/Dying.glb
Normal file
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 6.5 MiB |
42
Start_Run.py
@ -1,9 +1,48 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _maybe_relaunch_with_py311():
|
||||
"""Use Python 3.11 to match ui/lui.pyd ABI on Windows."""
|
||||
if sys.version_info >= (3, 11):
|
||||
return
|
||||
if os.environ.get("EG_RELAUNCHED_PY311") == "1":
|
||||
return
|
||||
if os.name != "nt":
|
||||
return
|
||||
|
||||
py_launcher = shutil.which("py")
|
||||
if not py_launcher:
|
||||
print(f"⚠ 当前解释器是 Python {sys.version.split()[0]},未找到 py launcher,无法自动切换到 3.11。")
|
||||
return
|
||||
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
[py_launcher, "-3.11", "-c", "import sys;print(sys.executable)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
print("⚠ 未检测到可用的 Python 3.11,LUI 可能不可用。")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"⚠ 检测 Python 3.11 失败: {e}")
|
||||
return
|
||||
|
||||
os.environ["EG_RELAUNCHED_PY311"] = "1"
|
||||
relaunch_cmd = [py_launcher, "-3.11", os.path.abspath(__file__), *sys.argv[1:]]
|
||||
print(f"✓ 检测到 Python {sys.version.split()[0]},自动切换到 Python 3.11: {probe.stdout.strip()}")
|
||||
os.execv(py_launcher, relaunch_cmd)
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, project_root)
|
||||
third_party_path = os.path.join(project_root, "third_party")
|
||||
if os.path.isdir(third_party_path):
|
||||
sys.path.insert(0, third_party_path)
|
||||
|
||||
# 设置工作目录为项目根目录
|
||||
os.chdir(project_root)
|
||||
@ -22,6 +61,7 @@ sys.path.insert(0, icons_path)
|
||||
|
||||
# 现在可以导入并运行主程序
|
||||
if __name__ == "__main__":
|
||||
_maybe_relaunch_with_py311()
|
||||
args = sys.argv[1:]
|
||||
# args = "/home/tiger/桌面/Test1"
|
||||
# args = "C:/Users/29381/Desktop/1"
|
||||
@ -46,4 +86,4 @@ if __name__ == "__main__":
|
||||
else:
|
||||
# print(f"[DEBUG] 无项目路径,正常启动")
|
||||
app = MyWorld()
|
||||
app.run()
|
||||
app.run()
|
||||
|
||||
@ -496,7 +496,7 @@ class MoveGizmo(DirectObject):
|
||||
def undo_last_move(self):
|
||||
"""
|
||||
Undo the last committed move.
|
||||
Default hotkey (from Qt -> Panda3D translation): Ctrl+Z => 'control-z'.
|
||||
Default hotkey: Ctrl+Z => 'control-z'.
|
||||
"""
|
||||
if not self._move_undo_stack:
|
||||
self._log("undo_last_move: stack empty")
|
||||
@ -530,7 +530,7 @@ class MoveGizmo(DirectObject):
|
||||
# --- Mouse helpers -------------------------------------------------
|
||||
def _get_normalized_mouse(self, extra) -> Optional[Point3]:
|
||||
"""
|
||||
Convert Qt pixel coordinates (from QPanda3DWidget) to Panda's
|
||||
Convert external UI pixel coordinates to Panda's
|
||||
normalized device coordinates (-1..1), or fall back to
|
||||
mouseWatcherNode when available.
|
||||
"""
|
||||
@ -538,14 +538,14 @@ class MoveGizmo(DirectObject):
|
||||
mouse = self.world.mouseWatcherNode.getMouse()
|
||||
return Point3(mouse.x, mouse.y, 0)
|
||||
|
||||
# 1) Extra payload from QPanda3DWidget (pixels)
|
||||
# 1) Extra payload from external UI (pixels)
|
||||
if isinstance(extra, dict) and "x" in extra and "y" in extra:
|
||||
parent = getattr(self.world, "parent", None)
|
||||
if parent is not None:
|
||||
w = max(parent.width(), 1)
|
||||
h = max(parent.height(), 1)
|
||||
nx = (extra["x"] / w) * 2.0 - 1.0
|
||||
# Qt origin is top‑left, Panda origin is center with +Y up
|
||||
# Pixel origin is top-left; Panda origin is center with +Y up.
|
||||
ny = 1.0 - (extra["y"] / h) * 2.0
|
||||
return Point3(nx, ny, 0)
|
||||
|
||||
|
||||
@ -54,10 +54,10 @@ from .events import GizmoEvent
|
||||
- 控件会根据摄像机距离自动缩放,屏幕大小保持近似不变
|
||||
|
||||
集成方式(示例):
|
||||
from QPanda3D.Panda3DWorld import Panda3DWorld
|
||||
from QPanda3DExamples.rotate_gizmo import RotateGizmo
|
||||
from direct.showbase.ShowBase import ShowBase
|
||||
from TransformGizmo.rotate_gizmo import RotateGizmo
|
||||
|
||||
world = Panda3DWorld()
|
||||
world = ShowBase()
|
||||
model_np = world.render.attachNewNode("Box")
|
||||
# ... 在 model_np 下加载模型
|
||||
|
||||
@ -72,7 +72,7 @@ from .events import GizmoEvent
|
||||
|
||||
鼠标事件要求:
|
||||
- 本类默认监听 Panda3D 的 "mouse1" / "mouse1-up" / "mouse-move" 事件
|
||||
- 如果从 Qt / 自定义 UI 传递鼠标像素坐标,可以在发送事件时传入 extra 字典:
|
||||
- 如果从外部 UI 传递鼠标像素坐标,可以在发送事件时传入 extra 字典:
|
||||
messenger.send("mouse1", [{"x": mouse_x, "y": mouse_y}])
|
||||
本类会自动将像素坐标转换到 [-1, 1] 的标准化设备坐标
|
||||
"""
|
||||
@ -955,7 +955,7 @@ class RotateGizmo(DirectObject):
|
||||
"""
|
||||
将鼠标转换到 Panda3D 的标准化设备坐标 [-1, 1]。
|
||||
|
||||
优先使用 Qt / 外部 UI 传入的像素坐标(extra 字典),
|
||||
优先使用外部 UI 传入的像素坐标(extra 字典),
|
||||
如果没有,则回退到 Panda3D 的 mouseWatcherNode。
|
||||
"""
|
||||
if self.world.mouseWatcherNode.hasMouse():
|
||||
@ -969,7 +969,7 @@ class RotateGizmo(DirectObject):
|
||||
w = max(parent.width(), 1)
|
||||
h = max(parent.height(), 1)
|
||||
nx = (extra["x"] / w) * 2.0 - 1.0
|
||||
# Qt 原点在左上,Panda3D 原点在中心,Y 轴向上
|
||||
# 像素坐标原点在左上,Panda3D 原点在中心,Y 轴向上
|
||||
ny = 1.0 - (extra["y"] / h) * 2.0
|
||||
return Point3(nx, ny, 0.0)
|
||||
|
||||
|
||||
@ -48,10 +48,10 @@ from .events import GizmoEvent
|
||||
- 控件会根据摄像机距离自动缩放,屏幕大小保持近似不变
|
||||
|
||||
集成方式(示例):
|
||||
from QPanda3D.Panda3DWorld import Panda3DWorld
|
||||
from QPanda3DExamples.scale_gizmo import ScaleGizmo
|
||||
from direct.showbase.ShowBase import ShowBase
|
||||
from TransformGizmo.scale_gizmo import ScaleGizmo
|
||||
|
||||
world = Panda3DWorld()
|
||||
world = ShowBase()
|
||||
model_np = world.render.attachNewNode("Box")
|
||||
# ... 在 model_np 下加载模型
|
||||
|
||||
@ -66,7 +66,7 @@ from .events import GizmoEvent
|
||||
|
||||
鼠标事件要求:
|
||||
- 本类默认监听 Panda3D 的 "mouse1" / "mouse1-up" / "mouse-move" 事件
|
||||
- 如果从 Qt / 自定义 UI 传递鼠标像素坐标,可以在发送事件时传入 extra 字典:
|
||||
- 如果从外部 UI 传递鼠标像素坐标,可以在发送事件时传入 extra 字典:
|
||||
messenger.send("mouse1", [{"x": mouse_x, "y": mouse_y}])
|
||||
本类会自动将像素坐标转换到 [-1, 1] 的标准化设备坐标
|
||||
"""
|
||||
|
||||
@ -462,10 +462,189 @@ class TransformGizmo(DirectObject):
|
||||
Callback used by MoveGizmo / RotateGizmo / ScaleGizmo to report that a
|
||||
transform action (move/rotate/scale) has been committed.
|
||||
"""
|
||||
if self._record_action_with_command_manager(action):
|
||||
return
|
||||
|
||||
self._history.append(action)
|
||||
# New user action invalidates redo chain.
|
||||
self._redo_history.clear()
|
||||
|
||||
def _coerce_vec3(self, value, fallback) -> p3d.Vec3:
|
||||
if value is None:
|
||||
return p3d.Vec3(fallback)
|
||||
try:
|
||||
return p3d.Vec3(value)
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(value, (tuple, list)) and len(value) >= 3:
|
||||
return p3d.Vec3(value[0], value[1], value[2])
|
||||
return p3d.Vec3(fallback)
|
||||
|
||||
def _make_transform_mat(self, pos, hpr, scale) -> p3d.LMatrix4f:
|
||||
try:
|
||||
state = p3d.TransformState.make_pos_hpr_scale(pos, hpr, scale)
|
||||
return p3d.LMatrix4f(state.get_mat())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
state = p3d.TransformState.makePosHprScale(pos, hpr, scale)
|
||||
return p3d.LMatrix4f(state.getMat())
|
||||
except Exception:
|
||||
temp = NodePath("tg_temp_transform")
|
||||
temp.setPos(pos)
|
||||
temp.setHpr(hpr)
|
||||
temp.setScale(scale)
|
||||
return p3d.LMatrix4f(temp.getMat())
|
||||
|
||||
def _invert_matrix(self, mat) -> Optional[p3d.LMatrix4f]:
|
||||
inv = p3d.LMatrix4f(mat)
|
||||
try:
|
||||
inv.invertInPlace()
|
||||
return inv
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
inv.invert_in_place()
|
||||
return inv
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _build_ssbo_group_snapshot_command(self, action: Dict[str, Any]):
|
||||
node: NodePath = action.get("node")
|
||||
if node is None or node.isEmpty() or (not node.hasTag("is_ssbo_proxy")):
|
||||
return None
|
||||
|
||||
ssbo_editor = getattr(self.world, "ssbo_editor", None)
|
||||
controller = getattr(ssbo_editor, "controller", None) if ssbo_editor else None
|
||||
if not controller:
|
||||
return None
|
||||
|
||||
selection_key = node.getTag("ssbo_selection_key") if node.hasTag("ssbo_selection_key") else None
|
||||
selected_ids = list(controller.name_to_ids.get(selection_key, [])) if selection_key else []
|
||||
if not selected_ids:
|
||||
selected_ids = list(getattr(ssbo_editor, "selected_ids", []) or [])
|
||||
|
||||
targets = []
|
||||
for gid in selected_ids:
|
||||
obj_np = controller.id_to_object_np.get(gid)
|
||||
if obj_np and not obj_np.is_empty():
|
||||
targets.append(obj_np)
|
||||
if not targets:
|
||||
return None
|
||||
|
||||
current_pos = node.getPos(self.world.render)
|
||||
current_hpr = node.getHpr(self.world.render)
|
||||
current_scale = node.getScale(self.world.render)
|
||||
|
||||
old_pos = self._coerce_vec3(action.get("old_pos"), current_pos)
|
||||
new_pos = self._coerce_vec3(action.get("new_pos"), current_pos)
|
||||
old_hpr = self._coerce_vec3(action.get("old_hpr"), current_hpr)
|
||||
new_hpr = self._coerce_vec3(action.get("new_hpr"), current_hpr)
|
||||
old_scale = self._coerce_vec3(action.get("old_scale"), current_scale)
|
||||
new_scale = self._coerce_vec3(action.get("new_scale"), current_scale)
|
||||
|
||||
old_proxy_mat = self._make_transform_mat(old_pos, old_hpr, old_scale)
|
||||
new_proxy_mat = self._make_transform_mat(new_pos, new_hpr, new_scale)
|
||||
new_proxy_inv = self._invert_matrix(new_proxy_mat)
|
||||
if new_proxy_inv is None:
|
||||
return None
|
||||
|
||||
before_state = []
|
||||
after_state = []
|
||||
for target in targets:
|
||||
try:
|
||||
current_world_mat = p3d.LMatrix4f(target.get_mat(self.world.render))
|
||||
except Exception:
|
||||
try:
|
||||
current_world_mat = p3d.LMatrix4f(target.getMat(self.world.render))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
old_world_mat = p3d.LMatrix4f(current_world_mat * new_proxy_inv * old_proxy_mat)
|
||||
before_state.append({"node": target, "mat": old_world_mat})
|
||||
after_state.append({"node": target, "mat": current_world_mat})
|
||||
|
||||
if not before_state:
|
||||
return None
|
||||
|
||||
def apply_state(state):
|
||||
synced_nodes = []
|
||||
for item in state:
|
||||
target = item.get("node")
|
||||
mat = item.get("mat")
|
||||
if target is None or target.isEmpty() or mat is None:
|
||||
continue
|
||||
try:
|
||||
target.set_mat(self.world.render, mat)
|
||||
except Exception:
|
||||
try:
|
||||
target.setMat(self.world.render, mat)
|
||||
except Exception:
|
||||
continue
|
||||
synced_nodes.append(target)
|
||||
|
||||
if ssbo_editor and hasattr(ssbo_editor, "sync_scene_nodes_to_pick"):
|
||||
try:
|
||||
ssbo_editor.sync_scene_nodes_to_pick(synced_nodes)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from core.Command_System import SnapshotStateCommand
|
||||
return SnapshotStateCommand(apply_state, before_state, after_state)
|
||||
|
||||
def _record_action_with_command_manager(self, action: Dict[str, Any]) -> bool:
|
||||
"""Prefer routing transform actions into the global command manager."""
|
||||
command_manager = getattr(self.world, "command_manager", None)
|
||||
if not command_manager:
|
||||
return False
|
||||
|
||||
try:
|
||||
group_command = self._build_ssbo_group_snapshot_command(action)
|
||||
if group_command is not None:
|
||||
command_manager.execute_command(group_command)
|
||||
return True
|
||||
|
||||
from core.Command_System import MoveNodeCommand, RotateNodeCommand, ScaleNodeCommand
|
||||
|
||||
kind = action.get("kind")
|
||||
node: NodePath = action.get("node")
|
||||
if node is None or node.isEmpty():
|
||||
return False
|
||||
|
||||
command = None
|
||||
if kind == "move":
|
||||
command = MoveNodeCommand(
|
||||
node,
|
||||
action.get("old_pos"),
|
||||
action.get("new_pos"),
|
||||
reference_node=self.world.render,
|
||||
world=self.world,
|
||||
)
|
||||
elif kind == "rotate":
|
||||
command = RotateNodeCommand(
|
||||
node,
|
||||
action.get("old_hpr"),
|
||||
action.get("new_hpr"),
|
||||
reference_node=self.world.render,
|
||||
world=self.world,
|
||||
)
|
||||
elif kind == "scale":
|
||||
command = ScaleNodeCommand(
|
||||
node,
|
||||
action.get("old_scale"),
|
||||
action.get("new_scale"),
|
||||
world=self.world,
|
||||
)
|
||||
|
||||
if command is None:
|
||||
return False
|
||||
|
||||
command_manager.execute_command(command)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _sync_light_position_if_needed(self, node: Optional[NodePath]) -> None:
|
||||
"""When target node wraps an RP light, keep RP light position in sync."""
|
||||
try:
|
||||
|
||||
53
config/default_imgui_layout.ini
Normal file
@ -0,0 +1,53 @@
|
||||
[Window][WindowOverViewport_11111111]
|
||||
Pos=0,20
|
||||
Size=2048,1084
|
||||
Collapsed=0
|
||||
|
||||
[Window][工具栏]
|
||||
Pos=453,20
|
||||
Size=1326,32
|
||||
Collapsed=0
|
||||
DockId=0x0000000D,0
|
||||
|
||||
[Window][场景树]
|
||||
Pos=0,20
|
||||
Size=451,748
|
||||
Collapsed=0
|
||||
DockId=0x00000007,0
|
||||
|
||||
[Window][属性面板]
|
||||
Pos=1781,20
|
||||
Size=267,390
|
||||
Collapsed=0
|
||||
DockId=0x00000003,0
|
||||
|
||||
[Window][脚本管理]
|
||||
Pos=1781,412
|
||||
Size=267,356
|
||||
Collapsed=0
|
||||
DockId=0x00000004,0
|
||||
|
||||
[Window][资源管理器]
|
||||
Pos=0,770
|
||||
Size=2048,334
|
||||
Collapsed=0
|
||||
DockId=0x0000000A,0
|
||||
|
||||
[Window][控制台]
|
||||
Pos=0,770
|
||||
Size=2048,334
|
||||
Collapsed=0
|
||||
DockId=0x0000000A,1
|
||||
|
||||
[Docking][Data]
|
||||
DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=2048,1084 Split=Y
|
||||
DockNode ID=0x00000009 Parent=0x08BD597D SizeRef=2560,748 Split=X
|
||||
DockNode ID=0x00000007 Parent=0x00000009 SizeRef=451,1084 Selected=0xE0015051
|
||||
DockNode ID=0x00000008 Parent=0x00000009 SizeRef=1595,1084 Split=X
|
||||
DockNode ID=0x00000001 Parent=0x00000008 SizeRef=1651,989 Split=Y
|
||||
DockNode ID=0x0000000D Parent=0x00000001 SizeRef=1318,32 HiddenTabBar=1 Selected=0x43A39006
|
||||
DockNode ID=0x0000000E Parent=0x00000001 SizeRef=1318,714 CentralNode=1
|
||||
DockNode ID=0x00000002 Parent=0x00000008 SizeRef=267,989 Split=Y Selected=0x3188AB8D
|
||||
DockNode ID=0x00000003 Parent=0x00000002 SizeRef=351,390 Selected=0x5DB6FF37
|
||||
DockNode ID=0x00000004 Parent=0x00000002 SizeRef=351,597 Selected=0x3188AB8D
|
||||
DockNode ID=0x0000000A Parent=0x08BD597D SizeRef=2560,334 Selected=0x3A2E05C3
|
||||
@ -4,6 +4,216 @@ from typing import List
|
||||
from panda3d.core import NodePath, Point3
|
||||
|
||||
|
||||
def _is_valid_node(node) -> bool:
|
||||
return bool(node) and hasattr(node, "isEmpty") and (not node.isEmpty())
|
||||
|
||||
|
||||
def _is_light_node(node: NodePath) -> bool:
|
||||
return bool(node) and hasattr(node, "hasTag") and node.hasTag("light_type")
|
||||
|
||||
|
||||
def _is_terrain_node(node: NodePath) -> bool:
|
||||
return bool(node) and hasattr(node, "hasTag") and node.hasTag("tree_item_type") and node.getTag("tree_item_type") == "TERRAIN_NODE"
|
||||
|
||||
|
||||
def _set_light_registration(world, node: NodePath, registered: bool):
|
||||
if not world or not _is_valid_node(node) or not _is_light_node(node):
|
||||
return
|
||||
|
||||
scene_manager = getattr(world, "scene_manager", None)
|
||||
light_type = node.getTag("light_type")
|
||||
light_lists = []
|
||||
if scene_manager:
|
||||
if light_type == "spot_light" and hasattr(scene_manager, "Spotlight"):
|
||||
light_lists.append(scene_manager.Spotlight)
|
||||
elif light_type == "point_light" and hasattr(scene_manager, "Pointlight"):
|
||||
light_lists.append(scene_manager.Pointlight)
|
||||
|
||||
rp_light = node.getPythonTag("rp_light_object") if hasattr(node, "hasPythonTag") and node.hasPythonTag("rp_light_object") else None
|
||||
current_registered = bool(node.getPythonTag("engine_light_registered")) if hasattr(node, "hasPythonTag") and node.hasPythonTag("engine_light_registered") else False
|
||||
|
||||
if registered:
|
||||
for light_list in light_lists:
|
||||
if node not in light_list:
|
||||
light_list.append(node)
|
||||
if not current_registered:
|
||||
try:
|
||||
if rp_light is not None and getattr(world, "render_pipeline", None):
|
||||
world.render_pipeline.add_light(rp_light)
|
||||
elif hasattr(world, "render") and world.render:
|
||||
world.render.setLight(node)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
node.setPythonTag("engine_light_registered", True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
for light_list in light_lists:
|
||||
try:
|
||||
while node in light_list:
|
||||
light_list.remove(node)
|
||||
except Exception:
|
||||
pass
|
||||
if current_registered:
|
||||
try:
|
||||
if rp_light is not None and getattr(world, "render_pipeline", None):
|
||||
world.render_pipeline.remove_light(rp_light)
|
||||
elif hasattr(world, "render") and world.render:
|
||||
world.render.clearLight(node)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
node.setPythonTag("engine_light_registered", False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _set_terrain_registration(world, node: NodePath, registered: bool):
|
||||
if not world or not _is_valid_node(node) or not _is_terrain_node(node):
|
||||
return
|
||||
|
||||
terrain_manager = getattr(world, "terrain_manager", None)
|
||||
if not terrain_manager or not hasattr(terrain_manager, "terrains"):
|
||||
return
|
||||
|
||||
terrain_info = None
|
||||
if hasattr(node, "hasPythonTag") and node.hasPythonTag("terrain_info"):
|
||||
terrain_info = node.getPythonTag("terrain_info")
|
||||
else:
|
||||
for info in getattr(terrain_manager, "terrains", []):
|
||||
if info.get("node") == node:
|
||||
terrain_info = info
|
||||
break
|
||||
if terrain_info is not None:
|
||||
try:
|
||||
node.setPythonTag("terrain_info", terrain_info)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if registered:
|
||||
if terrain_info is not None:
|
||||
terrain_info["node"] = node
|
||||
if all(info.get("node") != node for info in terrain_manager.terrains):
|
||||
terrain_manager.terrains.append(terrain_info)
|
||||
return
|
||||
|
||||
try:
|
||||
terrain_manager.terrains = [info for info in terrain_manager.terrains if info.get("node") != node]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _register_scene_node(world, node: NodePath):
|
||||
if not world or not _is_valid_node(node):
|
||||
return
|
||||
scene_manager = getattr(world, "scene_manager", None)
|
||||
_set_light_registration(world, node, True)
|
||||
_set_terrain_registration(world, node, True)
|
||||
if scene_manager and hasattr(scene_manager, "models") and node not in scene_manager.models:
|
||||
scene_manager.models.append(node)
|
||||
try:
|
||||
if hasattr(world, "updateSceneTree"):
|
||||
world.updateSceneTree()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _unregister_scene_node(world, node: NodePath):
|
||||
if not world or not node:
|
||||
return
|
||||
scene_manager = getattr(world, "scene_manager", None)
|
||||
_set_light_registration(world, node, False)
|
||||
_set_terrain_registration(world, node, False)
|
||||
if scene_manager and hasattr(scene_manager, "models"):
|
||||
try:
|
||||
while node in scene_manager.models:
|
||||
scene_manager.models.remove(node)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(world, "updateSceneTree"):
|
||||
world.updateSceneTree()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _refresh_scene_tree(world):
|
||||
if not world:
|
||||
return
|
||||
try:
|
||||
if hasattr(world, "updateSceneTree"):
|
||||
world.updateSceneTree()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_world(world=None):
|
||||
if world:
|
||||
return world
|
||||
try:
|
||||
from direct.showbase.ShowBaseGlobal import base
|
||||
return base
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _sync_scene_node_side_effects(world, nodes):
|
||||
world = _resolve_world(world)
|
||||
if not world:
|
||||
return
|
||||
|
||||
ssbo_editor = getattr(world, "ssbo_editor", None)
|
||||
if ssbo_editor and hasattr(ssbo_editor, "sync_scene_nodes_to_pick"):
|
||||
try:
|
||||
ssbo_editor.sync_scene_nodes_to_pick(nodes or [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _sync_transform_side_effects(world, nodes):
|
||||
_sync_scene_node_side_effects(world, nodes)
|
||||
|
||||
|
||||
def _apply_vec3_method(node, method_name: str, value, reference_node=None):
|
||||
if not _is_valid_node(node):
|
||||
return
|
||||
if reference_node is not None and not _is_valid_node(reference_node):
|
||||
reference_node = None
|
||||
|
||||
# Mark node as dirty for the save system if it's a transform operation
|
||||
if method_name.startswith("set") and method_name in (
|
||||
"setPos", "setHpr", "setScale", "setX", "setY", "setZ",
|
||||
"setH", "setP", "setR", "setSx", "setSy", "setSz", "setMat"
|
||||
):
|
||||
try:
|
||||
node.setTag("scene_transform_dirty", "true")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
method = getattr(node, method_name)
|
||||
if reference_node is not None:
|
||||
try:
|
||||
method(reference_node, value)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(value, (tuple, list)) and len(value) >= 3:
|
||||
method(reference_node, value[0], value[1], value[2])
|
||||
return
|
||||
else:
|
||||
try:
|
||||
method(value)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(value, (tuple, list)) and len(value) >= 3:
|
||||
method(value[0], value[1], value[2])
|
||||
return
|
||||
method(value)
|
||||
|
||||
|
||||
class Command(ABC):
|
||||
"""
|
||||
抽象命令类,所有具体命令都需要继承此类
|
||||
@ -50,13 +260,22 @@ class CommandManager:
|
||||
"""
|
||||
try:
|
||||
command.execute()
|
||||
self._undo_stack.append(command)
|
||||
# 清空重做栈,因为执行新命令后就无法重做之前的命令了
|
||||
self._redo_stack.clear()
|
||||
self.record_command(command)
|
||||
except Exception as e:
|
||||
print(f"执行命令时出错: {e}")
|
||||
raise
|
||||
|
||||
def record_command(self, command: Command):
|
||||
"""记录一个已经执行完成的命令。"""
|
||||
self._undo_stack.append(command)
|
||||
self._redo_stack.clear()
|
||||
|
||||
def pop_last_command(self):
|
||||
"""弹出最后一个已执行命令,供复合操作合并历史使用。"""
|
||||
if not self._undo_stack:
|
||||
return None
|
||||
return self._undo_stack.pop()
|
||||
|
||||
def undo(self) -> bool:
|
||||
"""
|
||||
撤销上一个命令
|
||||
@ -130,31 +349,37 @@ class CommandManager:
|
||||
# 示例命令实现
|
||||
class MoveNodeCommand(Command):
|
||||
"""
|
||||
移动节点命令示例
|
||||
Move node command.
|
||||
"""
|
||||
|
||||
def __init__(self, node: NodePath, old_pos, new_pos):
|
||||
def __init__(self, node: NodePath, old_pos, new_pos, reference_node=None, world=None):
|
||||
self.node = node
|
||||
self.old_pos = old_pos
|
||||
self.new_pos = new_pos
|
||||
self.reference_node = reference_node
|
||||
self.world = world
|
||||
|
||||
def _apply(self, value):
|
||||
_apply_vec3_method(self.node, "setPos", value, self.reference_node)
|
||||
_sync_transform_side_effects(self.world, [self.node])
|
||||
|
||||
def execute(self):
|
||||
"""
|
||||
执行移动操作
|
||||
Execute move operation.
|
||||
"""
|
||||
self.node.setPos(self.new_pos)
|
||||
self._apply(self.new_pos)
|
||||
|
||||
def undo(self):
|
||||
"""
|
||||
撤销移动操作
|
||||
Undo move operation.
|
||||
"""
|
||||
self.node.setPos(self.old_pos)
|
||||
self._apply(self.old_pos)
|
||||
|
||||
def redo(self):
|
||||
"""
|
||||
重做移动操作
|
||||
Redo move operation.
|
||||
"""
|
||||
self.node.setPos(self.new_pos)
|
||||
self._apply(self.new_pos)
|
||||
|
||||
|
||||
class DeleteNodeCommand(Command):
|
||||
@ -286,8 +511,10 @@ class DeleteNodeCommand(Command):
|
||||
for i in reversed(tilesets_to_remove):
|
||||
del scene_manager.tilesets[i]
|
||||
|
||||
_unregister_scene_node(self.world, self.node)
|
||||
|
||||
# 从场景图中移除节点,使用 detachNode 而不是 removeNode 以便可以撤销
|
||||
if self.node and not self.node.isEmpty():
|
||||
if _is_valid_node(self.node):
|
||||
self.node.detachNode()
|
||||
|
||||
def undo(self):
|
||||
@ -295,9 +522,12 @@ class DeleteNodeCommand(Command):
|
||||
撤销删除操作(恢复旧节点)
|
||||
"""
|
||||
try:
|
||||
if self.node and not self.node.isEmpty():
|
||||
if _is_valid_node(self.node):
|
||||
# 直接将节点挂载回原父节点
|
||||
self.node.reparentTo(self.parent_node)
|
||||
if _is_valid_node(self.parent_node):
|
||||
self.node.reparentTo(self.parent_node)
|
||||
elif self.world and _is_valid_node(getattr(self.world, "render", None)):
|
||||
self.node.reparentTo(self.world.render)
|
||||
|
||||
# 恢复到相应的管理器列表中
|
||||
if self.world and hasattr(self.world, 'scene_manager'):
|
||||
@ -319,6 +549,8 @@ class DeleteNodeCommand(Command):
|
||||
# 简单恢复到 tilesets
|
||||
if hasattr(scene_manager, 'tilesets'):
|
||||
scene_manager.tilesets.append({'node': self.node, 'url': self.extra_data.get('tileset_url', '')})
|
||||
|
||||
_register_scene_node(self.world, self.node)
|
||||
|
||||
print(f"✅ 成功撤销删除操作,节点 {self.node_name} 已恢复")
|
||||
else:
|
||||
@ -338,60 +570,147 @@ class DeleteNodeCommand(Command):
|
||||
|
||||
class RotateNodeCommand(Command):
|
||||
"""
|
||||
旋转节点命令
|
||||
Rotate node command.
|
||||
"""
|
||||
|
||||
def __init__(self, node: NodePath, old_hpr, new_hpr):
|
||||
def __init__(self, node: NodePath, old_hpr, new_hpr, reference_node=None, world=None):
|
||||
self.node = node
|
||||
self.old_hpr = old_hpr
|
||||
self.new_hpr = new_hpr
|
||||
self.reference_node = reference_node
|
||||
self.world = world
|
||||
|
||||
def _apply(self, value):
|
||||
_apply_vec3_method(self.node, "setHpr", value, self.reference_node)
|
||||
_sync_transform_side_effects(self.world, [self.node])
|
||||
|
||||
def execute(self):
|
||||
"""
|
||||
执行旋转操作
|
||||
Execute move operation.
|
||||
"""
|
||||
self.node.setHpr(self.new_hpr)
|
||||
self._apply(self.new_hpr)
|
||||
|
||||
def undo(self):
|
||||
"""
|
||||
撤销旋转操作
|
||||
Undo move operation.
|
||||
"""
|
||||
self.node.setHpr(self.old_hpr)
|
||||
self._apply(self.old_hpr)
|
||||
|
||||
def redo(self):
|
||||
"""
|
||||
重做旋转操作
|
||||
Redo move operation.
|
||||
"""
|
||||
self.node.setHpr(self.new_hpr)
|
||||
self._apply(self.new_hpr)
|
||||
|
||||
|
||||
class ScaleNodeCommand(Command):
|
||||
"""
|
||||
缩放节点命令
|
||||
Scale node command.
|
||||
"""
|
||||
|
||||
def __init__(self, node: NodePath, old_scale, new_scale):
|
||||
def __init__(self, node: NodePath, old_scale, new_scale, world=None):
|
||||
self.node = node
|
||||
self.old_scale = old_scale
|
||||
self.new_scale = new_scale
|
||||
self.world = world
|
||||
|
||||
def _apply(self, value):
|
||||
_apply_vec3_method(self.node, "setScale", value)
|
||||
_sync_transform_side_effects(self.world, [self.node])
|
||||
|
||||
def execute(self):
|
||||
"""
|
||||
执行缩放操作
|
||||
Execute rotate operation.
|
||||
"""
|
||||
self.node.setScale(self.new_scale)
|
||||
self._apply(self.new_scale)
|
||||
|
||||
def undo(self):
|
||||
"""
|
||||
撤销缩放操作
|
||||
Undo rotate operation.
|
||||
"""
|
||||
self.node.setScale(self.old_scale)
|
||||
self._apply(self.old_scale)
|
||||
|
||||
def redo(self):
|
||||
"""
|
||||
重做缩放操作
|
||||
Redo rotate operation.
|
||||
"""
|
||||
self.node.setScale(self.new_scale)
|
||||
self._apply(self.new_scale)
|
||||
|
||||
|
||||
class RenameNodeCommand(Command):
|
||||
"""Rename a node and refresh scene tree bindings."""
|
||||
|
||||
def __init__(self, node: NodePath, old_name: str, new_name: str, world=None):
|
||||
self.node = node
|
||||
self.old_name = old_name
|
||||
self.new_name = new_name
|
||||
self.world = world
|
||||
|
||||
def execute(self):
|
||||
if _is_valid_node(self.node):
|
||||
self.node.setName(self.new_name)
|
||||
_refresh_scene_tree(self.world)
|
||||
|
||||
def undo(self):
|
||||
if _is_valid_node(self.node):
|
||||
self.node.setName(self.old_name)
|
||||
_refresh_scene_tree(self.world)
|
||||
|
||||
def redo(self):
|
||||
self.execute()
|
||||
|
||||
|
||||
class VisibilityNodeCommand(Command):
|
||||
"""Toggle editor visibility state for a node."""
|
||||
|
||||
def __init__(self, node: NodePath, old_visible: bool, new_visible: bool, world=None):
|
||||
self.node = node
|
||||
self.old_visible = bool(old_visible)
|
||||
self.new_visible = bool(new_visible)
|
||||
self.world = world
|
||||
|
||||
def _apply(self, visible: bool):
|
||||
if not _is_valid_node(self.node):
|
||||
return
|
||||
self.node.setPythonTag("user_visible", bool(visible))
|
||||
if visible:
|
||||
self.node.show()
|
||||
else:
|
||||
self.node.hide()
|
||||
_sync_scene_node_side_effects(self.world, [self.node])
|
||||
|
||||
def execute(self):
|
||||
self._apply(self.new_visible)
|
||||
|
||||
def undo(self):
|
||||
self._apply(self.old_visible)
|
||||
|
||||
def redo(self):
|
||||
self.execute()
|
||||
|
||||
|
||||
class MaterialStateCommand(Command):
|
||||
"""Replay a captured material snapshot for undo/redo."""
|
||||
|
||||
def __init__(self, apply_state_callback, before_state, after_state):
|
||||
self.apply_state_callback = apply_state_callback
|
||||
self.before_state = before_state
|
||||
self.after_state = after_state
|
||||
|
||||
def execute(self):
|
||||
if self.apply_state_callback:
|
||||
self.apply_state_callback(self.after_state)
|
||||
|
||||
def undo(self):
|
||||
if self.apply_state_callback:
|
||||
self.apply_state_callback(self.before_state)
|
||||
|
||||
def redo(self):
|
||||
self.execute()
|
||||
|
||||
|
||||
class SnapshotStateCommand(MaterialStateCommand):
|
||||
"""Generic callback-based snapshot command."""
|
||||
|
||||
|
||||
class CreateNodeCommand(Command):
|
||||
@ -399,49 +718,99 @@ class CreateNodeCommand(Command):
|
||||
创建节点命令
|
||||
"""
|
||||
|
||||
def __init__(self, node_creator_func,parent_node, *args, **kwargs):
|
||||
def __init__(self, node_creator_func, parent_node, *args, world=None, **kwargs):
|
||||
self.node_creator_func = node_creator_func
|
||||
self.parent_node = parent_node
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
self.world = world
|
||||
self.created_node = None
|
||||
|
||||
def execute(self):
|
||||
"""
|
||||
执行创建节点操作
|
||||
"""
|
||||
self.created_node = self.node_creator_func(self.parent_node,*self.args, **self.kwargs)
|
||||
if _is_valid_node(self.created_node):
|
||||
target_parent = self.parent_node
|
||||
if (not _is_valid_node(target_parent)) and self.world:
|
||||
target_parent = getattr(self.world, "render", None)
|
||||
if _is_valid_node(target_parent):
|
||||
self.created_node.wrtReparentTo(target_parent)
|
||||
_register_scene_node(self.world, self.created_node)
|
||||
_sync_scene_node_side_effects(self.world, [self.created_node])
|
||||
return self.created_node
|
||||
|
||||
self.created_node = self.node_creator_func(self.parent_node, *self.args, **self.kwargs)
|
||||
_register_scene_node(self.world, self.created_node)
|
||||
_sync_scene_node_side_effects(self.world, [self.created_node])
|
||||
return self.created_node
|
||||
|
||||
def undo(self):
|
||||
"""
|
||||
撤销创建节点操作
|
||||
"""
|
||||
if self.created_node:
|
||||
if _is_valid_node(self.created_node):
|
||||
_unregister_scene_node(self.world, self.created_node)
|
||||
self.created_node.detachNode()
|
||||
_sync_scene_node_side_effects(self.world, [self.created_node])
|
||||
|
||||
def redo(self):
|
||||
"""
|
||||
重做创建节点操作
|
||||
"""
|
||||
if _is_valid_node(self.created_node):
|
||||
target_parent = self.parent_node
|
||||
if (not _is_valid_node(target_parent)) and self.world:
|
||||
target_parent = getattr(self.world, "render", None)
|
||||
if _is_valid_node(target_parent):
|
||||
self.created_node.wrtReparentTo(target_parent)
|
||||
_register_scene_node(self.world, self.created_node)
|
||||
_sync_scene_node_side_effects(self.world, [self.created_node])
|
||||
return
|
||||
self.execute()
|
||||
|
||||
|
||||
class ReparentNodeCommand(Command):
|
||||
"""
|
||||
重新设置节点父子关系命令 - 增强版(同时处理Panda3D和Qt树)
|
||||
"""
|
||||
class AttachNodeCommand(Command):
|
||||
"""Attach an existing detached node into a parent and make it undoable."""
|
||||
|
||||
def __init__(self, node: NodePath, old_parent: NodePath, new_parent: NodePath,
|
||||
old_parent_item=None, new_parent_item=None, is_2d_gui=False, world=None):
|
||||
def __init__(self, node: NodePath, parent_node: NodePath, world=None):
|
||||
self.node = node
|
||||
self.old_parent = old_parent
|
||||
self.new_parent = new_parent
|
||||
self.old_parent_item = old_parent_item # Qt树中的旧父节点项
|
||||
self.new_parent_item = new_parent_item # Qt树中的新父节点项
|
||||
self.is_2d_gui = is_2d_gui
|
||||
self.parent_node = parent_node
|
||||
self.world = world
|
||||
|
||||
def execute(self):
|
||||
target_parent = self.parent_node
|
||||
if (not target_parent or target_parent.isEmpty()) and self.world:
|
||||
target_parent = getattr(self.world, "render", None)
|
||||
if _is_valid_node(self.node) and _is_valid_node(target_parent):
|
||||
self.node.wrtReparentTo(target_parent)
|
||||
_register_scene_node(self.world, self.node)
|
||||
_sync_scene_node_side_effects(self.world, [self.node])
|
||||
return self.node
|
||||
|
||||
def undo(self):
|
||||
if _is_valid_node(self.node):
|
||||
_unregister_scene_node(self.world, self.node)
|
||||
self.node.detachNode()
|
||||
_sync_scene_node_side_effects(self.world, [self.node])
|
||||
|
||||
def redo(self):
|
||||
self.execute()
|
||||
|
||||
|
||||
class ReparentNodeCommand(Command):
|
||||
"""
|
||||
重新设置节点父子关系命令。
|
||||
"""
|
||||
|
||||
def __init__(self, node: NodePath, old_parent: NodePath, new_parent: NodePath,
|
||||
is_2d_gui=False, world=None):
|
||||
self.node = node
|
||||
self.old_parent = old_parent
|
||||
self.new_parent = new_parent
|
||||
self.is_2d_gui = is_2d_gui
|
||||
self.world = world
|
||||
|
||||
# 保存节点在操作前的世界坐标和局部坐标,以便在撤销/重做时保持位置不变
|
||||
self.world_pos = node.getPos(self.world.render if self.world else node.getParent())
|
||||
self.world_hpr = node.getHpr(self.world.render if self.world else node.getParent())
|
||||
@ -451,34 +820,16 @@ class ReparentNodeCommand(Command):
|
||||
self.local_hpr = node.getHpr()
|
||||
self.local_scale = node.getScale()
|
||||
|
||||
def _updateQtTree(self, node_item, new_parent_item):
|
||||
"""更新Qt树控件中的节点位置"""
|
||||
if not node_item or not new_parent_item:
|
||||
return
|
||||
|
||||
# 从当前父节点中移除
|
||||
current_parent = node_item.parent()
|
||||
if current_parent:
|
||||
current_parent.removeChild(node_item)
|
||||
else:
|
||||
# 如果是顶级项目
|
||||
tree_widget = node_item.treeWidget()
|
||||
if tree_widget:
|
||||
index = tree_widget.indexOfTopLevelItem(node_item)
|
||||
if index >= 0:
|
||||
tree_widget.takeTopLevelItem(index)
|
||||
|
||||
# 添加到新父节点
|
||||
new_parent_item.addChild(node_item)
|
||||
|
||||
def execute(self):
|
||||
"""
|
||||
执行重新父化操作
|
||||
"""
|
||||
if not _is_valid_node(self.node):
|
||||
return
|
||||
# 更新Panda3D节点父子关系
|
||||
if self.is_2d_gui and self.world:
|
||||
# 2D GUI元素需要特殊处理
|
||||
if self.new_parent and not self.new_parent.isEmpty():
|
||||
if _is_valid_node(self.new_parent):
|
||||
if hasattr(self.new_parent, 'getTag') and self.new_parent.getTag("is_gui_element") == "1":
|
||||
# 目标是GUI元素,直接重新父化
|
||||
self.node.wrtReparentTo(self.new_parent)
|
||||
@ -492,7 +843,7 @@ class ReparentNodeCommand(Command):
|
||||
print(f"2D GUI元素重新父化到aspect2d")
|
||||
else:
|
||||
# 普通3D节点的处理
|
||||
if self.new_parent and not self.new_parent.isEmpty():
|
||||
if _is_valid_node(self.new_parent):
|
||||
self.node.wrtReparentTo(self.new_parent)
|
||||
else:
|
||||
# 如果新父节点为空,将其父化到render节点
|
||||
@ -507,13 +858,15 @@ class ReparentNodeCommand(Command):
|
||||
"""
|
||||
撤销重新父化操作
|
||||
"""
|
||||
if not _is_valid_node(self.node):
|
||||
return
|
||||
# 在改变父节点前保存当前的缩放值
|
||||
current_scale = self.node.getScale()
|
||||
|
||||
# 恢复Panda3D节点父子关系
|
||||
if self.is_2d_gui and self.world:
|
||||
# 2D GUI元素需要特殊处理
|
||||
if self.old_parent and not self.old_parent.isEmpty():
|
||||
if _is_valid_node(self.old_parent):
|
||||
if hasattr(self.old_parent, 'getTag') and self.old_parent.getTag("is_gui_element") == "1":
|
||||
# 原父节点是GUI元素,直接重新父化
|
||||
self.node.wrtReparentTo(self.old_parent)
|
||||
@ -527,7 +880,7 @@ class ReparentNodeCommand(Command):
|
||||
print(f"2D GUI元素恢复到aspect2d")
|
||||
else:
|
||||
# 普通3D节点的处理
|
||||
if self.old_parent and not self.old_parent.isEmpty():
|
||||
if _is_valid_node(self.old_parent):
|
||||
self.node.wrtReparentTo(self.old_parent)
|
||||
else:
|
||||
# 如果原父节点为空,将其父化到render节点
|
||||
@ -549,13 +902,15 @@ class ReparentNodeCommand(Command):
|
||||
"""
|
||||
重做重新父化操作
|
||||
"""
|
||||
if not _is_valid_node(self.node):
|
||||
return
|
||||
# 在改变父节点前保存当前的缩放值
|
||||
current_scale = self.node.getScale()
|
||||
|
||||
# 重新执行Panda3D节点父子关系更新
|
||||
if self.is_2d_gui and self.world:
|
||||
# 2D GUI元素需要特殊处理
|
||||
if self.new_parent and not self.new_parent.isEmpty():
|
||||
if _is_valid_node(self.new_parent):
|
||||
if hasattr(self.new_parent, 'getTag') and self.new_parent.getTag("is_gui_element") == "1":
|
||||
# 目标是GUI元素,直接重新父化
|
||||
self.node.wrtReparentTo(self.new_parent)
|
||||
@ -569,7 +924,7 @@ class ReparentNodeCommand(Command):
|
||||
print(f"2D GUI元素重新父化到aspect2d")
|
||||
else:
|
||||
# 普通3D节点的处理
|
||||
if self.new_parent and not self.new_parent.isEmpty():
|
||||
if _is_valid_node(self.new_parent):
|
||||
self.node.wrtReparentTo(self.new_parent)
|
||||
else:
|
||||
# 如果新父节点为空,将其父化到render节点
|
||||
@ -616,40 +971,40 @@ class CompositeCommand(Command):
|
||||
for command in self.commands:
|
||||
command.redo()
|
||||
|
||||
class MoveLightCommand(Command):
|
||||
def __init__(self, node, old_pos, new_pos, light_object=None):
|
||||
self.node = node
|
||||
self.old_pos = Point3(old_pos)
|
||||
self.new_pos = Point3(new_pos)
|
||||
self.light_object = light_object
|
||||
|
||||
def _apply_light_position(self, pos):
|
||||
if not self.light_object:
|
||||
return
|
||||
try:
|
||||
self.light_object.setPos(pos)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.light_object.setPos(pos.x, pos.y, pos.z)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.light_object.pos = Point3(pos)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def execute(self): # 将原来的 do() 改为 execute()
|
||||
self._apply_light_position(self.new_pos)
|
||||
if self.node:
|
||||
self.node.setPos(self.new_pos)
|
||||
|
||||
def undo(self):
|
||||
self._apply_light_position(self.old_pos)
|
||||
if self.node:
|
||||
self.node.setPos(self.old_pos)
|
||||
class MoveLightCommand(Command):
|
||||
def __init__(self, node, old_pos, new_pos, light_object=None):
|
||||
self.node = node
|
||||
self.old_pos = Point3(old_pos)
|
||||
self.new_pos = Point3(new_pos)
|
||||
self.light_object = light_object
|
||||
|
||||
def _apply_light_position(self, pos):
|
||||
if not self.light_object:
|
||||
return
|
||||
try:
|
||||
self.light_object.setPos(pos)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.light_object.setPos(pos.x, pos.y, pos.z)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.light_object.pos = Point3(pos)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def execute(self): # 将原来的 do() 改为 execute()
|
||||
self._apply_light_position(self.new_pos)
|
||||
if _is_valid_node(self.node):
|
||||
self.node.setPos(self.new_pos)
|
||||
|
||||
def undo(self):
|
||||
self._apply_light_position(self.old_pos)
|
||||
if _is_valid_node(self.node):
|
||||
self.node.setPos(self.old_pos)
|
||||
|
||||
def redo(self):
|
||||
self.execute() # 调用 execute() 而不是 do()
|
||||
|
||||
@ -64,10 +64,12 @@ class CustomMouseController:
|
||||
self.keyMap[key] = value
|
||||
# 只在右键按下时记录鼠标位置
|
||||
if key == "mouse3" and value == True:
|
||||
mouse_pos = self.showbase.mouseWatcherNode.getMouse()
|
||||
if mouse_pos:
|
||||
self.last_mouse_x = mouse_pos.get_x()
|
||||
self.last_mouse_y = mouse_pos.get_y()
|
||||
watcher = getattr(self.showbase, "mouseWatcherNode", None)
|
||||
if watcher and watcher.hasMouse():
|
||||
mouse_pos = watcher.getMouse()
|
||||
if mouse_pos:
|
||||
self.last_mouse_x = mouse_pos.get_x()
|
||||
self.last_mouse_y = mouse_pos.get_y()
|
||||
|
||||
def move(self, task):
|
||||
dt = self.showbase.clock.dt
|
||||
@ -98,7 +100,10 @@ class CustomMouseController:
|
||||
try:
|
||||
# 检查是否应该处理鼠标事件(避免与ImGui冲突)
|
||||
if self._should_handle_mouse():
|
||||
mouse_pos = self.showbase.mouseWatcherNode.getMouse()
|
||||
watcher = getattr(self.showbase, "mouseWatcherNode", None)
|
||||
if not watcher or not watcher.hasMouse():
|
||||
return task.cont
|
||||
mouse_pos = watcher.getMouse()
|
||||
if mouse_pos:
|
||||
current_x = mouse_pos.get_x()
|
||||
current_y = mouse_pos.get_y()
|
||||
@ -160,7 +165,10 @@ class CustomMouseController:
|
||||
pass
|
||||
|
||||
# 检查鼠标位置是否在ImGui窗口区域内
|
||||
mouse_pos = self.showbase.mouseWatcherNode.getMouse()
|
||||
watcher = getattr(self.showbase, "mouseWatcherNode", None)
|
||||
if not watcher or not watcher.hasMouse():
|
||||
return True
|
||||
mouse_pos = watcher.getMouse()
|
||||
if not mouse_pos:
|
||||
return True
|
||||
|
||||
@ -185,4 +193,4 @@ class CustomMouseController:
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"鼠标事件检测失败: {e}")
|
||||
return True
|
||||
return True
|
||||
|
||||
@ -1,26 +1,40 @@
|
||||
"""
|
||||
Core package - 核心功能模块
|
||||
|
||||
包含引擎的核心功能:
|
||||
- world.py: 基础世界功能(相机、光照、地板等)
|
||||
- selection.py: 选择和变换系统
|
||||
- event_handler.py: 事件处理系统
|
||||
- tool_manager.py: 工具管理系统
|
||||
- script_system.py: 脚本系统
|
||||
"""
|
||||
|
||||
from .world import CoreWorld
|
||||
from .selection import SelectionSystem
|
||||
from .event_handler import EventHandler
|
||||
from .tool_manager import ToolManager
|
||||
from .script_system import ScriptManager, ScriptBase, ScriptComponent
|
||||
|
||||
__all__ = [
|
||||
'CoreWorld',
|
||||
'SelectionSystem',
|
||||
'EventHandler',
|
||||
'ToolManager',
|
||||
'ScriptManager',
|
||||
'ScriptBase',
|
||||
'ScriptComponent'
|
||||
]
|
||||
"""
|
||||
Core package - 核心功能模块
|
||||
|
||||
Keep package imports lazy so lightweight runtime contexts can import
|
||||
`core.script_system` without eagerly pulling in the editor stack.
|
||||
"""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
__all__ = [
|
||||
"CoreWorld",
|
||||
"SelectionSystem",
|
||||
"EventHandler",
|
||||
"ToolManager",
|
||||
"ScriptManager",
|
||||
"ScriptBase",
|
||||
"ScriptComponent",
|
||||
]
|
||||
|
||||
_LAZY_IMPORTS = {
|
||||
"CoreWorld": ("core.world", "CoreWorld"),
|
||||
"SelectionSystem": ("core.selection", "SelectionSystem"),
|
||||
"EventHandler": ("core.event_handler", "EventHandler"),
|
||||
"ToolManager": ("core.tool_manager", "ToolManager"),
|
||||
"ScriptManager": ("core.script_system", "ScriptManager"),
|
||||
"ScriptBase": ("core.script_system", "ScriptBase"),
|
||||
"ScriptComponent": ("core.script_system", "ScriptComponent"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name not in _LAZY_IMPORTS:
|
||||
raise AttributeError(f"module 'core' has no attribute {name!r}")
|
||||
|
||||
module_name, attr_name = _LAZY_IMPORTS[name]
|
||||
module = import_module(module_name)
|
||||
value = getattr(module, attr_name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
44
core/editor_context.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Editor context adapter for the ImGui editor runtime."""
|
||||
|
||||
|
||||
class EditorContext:
|
||||
"""Provide a stable access layer for editor-facing services."""
|
||||
|
||||
def __init__(self, world):
|
||||
self.world = world
|
||||
|
||||
def get_tree_widget(self):
|
||||
return None
|
||||
|
||||
def get_gui_manager(self):
|
||||
return getattr(self.world, "gui_manager", None)
|
||||
|
||||
def ensure_gui_elements(self):
|
||||
gui_manager = self.get_gui_manager()
|
||||
if gui_manager is not None:
|
||||
gui_elements = getattr(gui_manager, "gui_elements", None)
|
||||
if gui_elements is None:
|
||||
gui_elements = []
|
||||
setattr(gui_manager, "gui_elements", gui_elements)
|
||||
return gui_elements
|
||||
|
||||
gui_elements = getattr(self.world, "gui_elements", None)
|
||||
if gui_elements is None:
|
||||
gui_elements = []
|
||||
setattr(self.world, "gui_elements", gui_elements)
|
||||
return gui_elements
|
||||
|
||||
def append_gui_element(self, element):
|
||||
gui_elements = self.ensure_gui_elements()
|
||||
gui_elements.append(element)
|
||||
return True
|
||||
|
||||
|
||||
def get_editor_context(owner):
|
||||
"""Get or create editor context from a world/owner object."""
|
||||
world = getattr(owner, "world", owner)
|
||||
context = getattr(world, "_editor_context", None)
|
||||
if context is None or getattr(context, "world", None) is not world:
|
||||
context = EditorContext(world)
|
||||
setattr(world, "_editor_context", context)
|
||||
return context
|
||||
@ -1,6 +1,7 @@
|
||||
from panda3d.core import (Point3, Point2, CollisionTraverser, CollisionHandlerQueue,
|
||||
CollisionNode, CollisionRay, GeomNode, LineSegs, RenderState,
|
||||
DepthTestAttrib, ColorAttrib)
|
||||
from core.editor_context import get_editor_context
|
||||
|
||||
|
||||
class EventHandler:
|
||||
@ -9,11 +10,29 @@ class EventHandler:
|
||||
def __init__(self, world):
|
||||
"""初始化事件处理器"""
|
||||
self.world = world
|
||||
self._editor_context = get_editor_context(world)
|
||||
|
||||
# 射线显示相关
|
||||
self.showRay = False # 是否显示射线(默认关闭)
|
||||
self.rayNode = None # 当前显示的射线节点
|
||||
self.rayLifetime = 2.0 # 射线显示时间(秒)
|
||||
|
||||
def _get_editor_context(self):
|
||||
if not getattr(self, "_editor_context", None):
|
||||
self._editor_context = get_editor_context(self.world)
|
||||
return self._editor_context
|
||||
|
||||
def _get_tree_widget(self):
|
||||
"""Qt tree has been removed in the ImGui editor."""
|
||||
return self._get_editor_context().get_tree_widget()
|
||||
|
||||
def _get_gui_manager(self):
|
||||
"""安全获取 GUI 管理器。"""
|
||||
return self._get_editor_context().get_gui_manager()
|
||||
|
||||
def _sync_tree_selection(self, selected_model):
|
||||
"""Scene tree selection is rendered directly from editor state in ImGui."""
|
||||
return False
|
||||
|
||||
def showClickRay(self, nearPoint, farPoint, hitPos=None):
|
||||
"""显示鼠标点击的射线"""
|
||||
@ -191,68 +210,10 @@ class EventHandler:
|
||||
pickerNP.removeNode()
|
||||
return
|
||||
|
||||
# 优先检查是否点击了坐标轴
|
||||
#print(f"检查坐标轴点击: 坐标轴存在={bool(self.world.selection.gizmo)}")
|
||||
if self.world.selection.gizmo:
|
||||
#print("准备检查坐标轴点击...")
|
||||
try:
|
||||
highlighted_axis = self.world.selection.gizmoHighlightAxis
|
||||
if highlighted_axis:
|
||||
print(f"✓ 检测到高亮轴: {highlighted_axis},直接开始拖拽")
|
||||
# 直接使用高亮轴开始拖拽
|
||||
self.world.selection.startGizmoDrag(highlighted_axis, x, y)
|
||||
pickerNP.removeNode()
|
||||
return
|
||||
|
||||
# 如果没有高亮轴,再尝试检测点击
|
||||
gizmoAxis = self.world.selection.checkGizmoClick(x, y)
|
||||
if gizmoAxis:
|
||||
print(f"✓ 检测到坐标轴点击: {gizmoAxis}")
|
||||
# 开始坐标轴拖拽
|
||||
self.world.selection.startGizmoDrag(gizmoAxis, x, y)
|
||||
pickerNP.removeNode()
|
||||
return
|
||||
else:
|
||||
print("× 没有点击到坐标轴")
|
||||
|
||||
# gizmoAxis = self.world.selection.checkGizmoClick(x, y)
|
||||
# if gizmoAxis:
|
||||
# #print(f"✓ 检测到坐标轴点击: {gizmoAxis}")
|
||||
# # 开始坐标轴拖拽
|
||||
# self.world.selection.startGizmoDrag(gizmoAxis, x, y)
|
||||
# pickerNP.removeNode()
|
||||
# return
|
||||
# else:
|
||||
# print("× 没有点击到坐标轴")
|
||||
except Exception as e:
|
||||
print(f"❌ 坐标轴点击检测出现异常: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print("继续处理模型选择...")
|
||||
|
||||
#print("继续处理碰撞结果...")
|
||||
|
||||
if hitPos and hitNode:
|
||||
#print(f"✓ 检测到碰撞,开始处理点击事件")
|
||||
#print(f"GUI编辑模式: {self.world.guiEditMode}")
|
||||
#print(f"当前工具: {self.world.currentTool}")
|
||||
|
||||
# 处理GUI编辑模式
|
||||
if self.world.guiEditMode:
|
||||
#print("处理GUI编辑模式点击")
|
||||
# 检查是否点击了GUI元素
|
||||
clickedGUI = self.world.gui_manager.findClickedGUI(hitNode)
|
||||
if clickedGUI:
|
||||
# 选中GUI元素
|
||||
self.world.selection.updateSelection(clickedGUI)
|
||||
self.world.gui_manager.selectGUIInTree(clickedGUI)
|
||||
print(f"选中GUI元素: {clickedGUI.getTag('gui_text')}")
|
||||
elif hasattr(self.world, 'currentGUITool') and self.world.currentGUITool:
|
||||
# 在点击位置创建新GUI元素
|
||||
self.world.gui_manager.createGUIAtPosition(hitPos, self.world.currentGUITool)
|
||||
pickerNP.removeNode()
|
||||
return
|
||||
|
||||
# 根据当前工具处理点击事件
|
||||
if self.world.currentTool in ("选择", "移动", "旋转", "缩放"):
|
||||
print("✓ 使用选择工具处理点击")
|
||||
@ -267,20 +228,7 @@ class EventHandler:
|
||||
print(f"当前工具不是'选择',无法处理: {self.world.currentTool}")
|
||||
else:
|
||||
print("没有检测到碰撞")
|
||||
|
||||
# 如果不在GUI编辑模式,清除选择
|
||||
if not self.world.guiEditMode:
|
||||
self.world.selection.updateSelection(None)
|
||||
|
||||
# 在GUI编辑模式下,即使没有碰撞也可以在空白区域创建GUI
|
||||
if (self.world.guiEditMode and
|
||||
hasattr(self.world, 'currentGUITool') and
|
||||
self.world.currentGUITool):
|
||||
# 使用默认的地面高度创建GUI
|
||||
default_height = 0.0
|
||||
world_pos = Point3(mx * 10, 0, my * 10) # 简单的屏幕到世界坐标转换
|
||||
world_pos.setZ(default_height)
|
||||
self.world.gui_manager.createGUIAtPosition(world_pos, self.world.currentGUITool)
|
||||
self.world.selection.updateSelection(None)
|
||||
|
||||
# 确保总是清理碰撞检测节点
|
||||
try:
|
||||
@ -441,89 +389,120 @@ class EventHandler:
|
||||
def _handleSelectionClick(self, hitNode):
|
||||
"""处理选择工具的点击事件"""
|
||||
print(f"开始处理选择点击,碰撞节点: {hitNode.getName()}")
|
||||
|
||||
# 查找对应的实际模型节点
|
||||
selectedModel = None
|
||||
|
||||
# 如果点击的是碰撞节点,找到它的父模型
|
||||
if isinstance(hitNode.node(), CollisionNode):
|
||||
print(f"点击的是碰撞节点: {hitNode.getName()}")
|
||||
# 碰撞节点的父节点应该是模型
|
||||
parent = hitNode.getParent()
|
||||
if parent in self.world.models:
|
||||
selectedModel = parent
|
||||
print(f"找到对应的模型: {selectedModel.getName()}")
|
||||
else:
|
||||
print(f"碰撞节点的父节点不是模型: {parent.getName()}")
|
||||
else:
|
||||
# 查找可选择的节点(模型或其子节点)
|
||||
current = hitNode
|
||||
while current != self.world.render:
|
||||
# 检查是否是模型
|
||||
if current in self.world.models:
|
||||
selectedModel = current
|
||||
print(f"找到模型节点: {selectedModel.getName()}")
|
||||
break
|
||||
|
||||
# 检查是否是模型的子节点
|
||||
for model in self.world.models:
|
||||
if current.getParent() == model or current.isAncestorOf(model):
|
||||
selectedModel = model
|
||||
print(f"找到父模型: {selectedModel.getName()}")
|
||||
break
|
||||
|
||||
if selectedModel:
|
||||
def _is_valid_node(node):
|
||||
if not node:
|
||||
return False
|
||||
try:
|
||||
return not node.isEmpty()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _is_helper_node(node):
|
||||
if not _is_valid_node(node):
|
||||
return True
|
||||
try:
|
||||
name = node.getName() or ""
|
||||
except Exception:
|
||||
name = ""
|
||||
lowered = name.lower()
|
||||
return (
|
||||
isinstance(node.node(), CollisionNode)
|
||||
or lowered.startswith("modelcollision_")
|
||||
or lowered.startswith("collision_")
|
||||
or lowered.startswith("gizmo")
|
||||
)
|
||||
|
||||
def _resolve_model_root(node):
|
||||
current = node
|
||||
model_list = self.world.models if hasattr(self.world, "models") else []
|
||||
while _is_valid_node(current) and current != self.world.render:
|
||||
try:
|
||||
if current in model_list or current.hasTag("is_model_root"):
|
||||
return current
|
||||
current = current.getParent()
|
||||
except Exception:
|
||||
break
|
||||
|
||||
current = current.getParent()
|
||||
|
||||
if selectedModel:
|
||||
#print(f"✓ 最终选中模型: {selectedModel.getName()}")
|
||||
self.world.selection.handleMouseClick(selectedModel)
|
||||
|
||||
return None
|
||||
|
||||
# In SSBO mode, animated/legacy models still rely on modelCollision_* for picking,
|
||||
# so we cannot ignore those hits unconditionally.
|
||||
# Only treat a hit on the *currently selected same model root* as blank-space clear.
|
||||
if getattr(self.world, "use_ssbo_mouse_picking", False):
|
||||
try:
|
||||
hit_name = hitNode.getName() or ""
|
||||
except Exception:
|
||||
hit_name = ""
|
||||
if hit_name.lower().startswith("modelcollision_"):
|
||||
current_selected = None
|
||||
try:
|
||||
current_selected = self.world.selection.getSelectedNode()
|
||||
except Exception:
|
||||
current_selected = getattr(getattr(self, "world", None), "selection", None)
|
||||
owner_root = _resolve_model_root(hitNode)
|
||||
try:
|
||||
same_root = bool(
|
||||
current_selected and owner_root and current_selected == owner_root
|
||||
)
|
||||
except Exception:
|
||||
same_root = False
|
||||
if same_root:
|
||||
print("SSBO 模式下命中当前选中模型的辅助碰撞壳,按空白区域清除选择")
|
||||
self.world.selection.updateSelection(None)
|
||||
return
|
||||
|
||||
def _is_selectable_scene_node(node):
|
||||
if not _is_valid_node(node) or node == self.world.render:
|
||||
return False
|
||||
if _is_helper_node(node):
|
||||
return False
|
||||
try:
|
||||
if node.hasTag("is_scene_element") or node.hasTag("is_model_root"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def _resolve_pick_target(node):
|
||||
current = node
|
||||
fallback_model_root = None
|
||||
model_list = self.world.models if hasattr(self.world, "models") else []
|
||||
while _is_valid_node(current) and current != self.world.render:
|
||||
try:
|
||||
if current in model_list or current.hasTag("is_model_root"):
|
||||
fallback_model_root = current
|
||||
if not _is_helper_node(current):
|
||||
return current
|
||||
if _is_selectable_scene_node(current):
|
||||
return current
|
||||
current = current.getParent()
|
||||
except Exception:
|
||||
break
|
||||
return fallback_model_root
|
||||
|
||||
selected_node = _resolve_pick_target(hitNode)
|
||||
|
||||
if selected_node:
|
||||
print(f"最终选中节点: {selected_node.getName()}")
|
||||
self.world.selection.handleMouseClick(selected_node)
|
||||
|
||||
# 更新选择状态并显示选择框和坐标轴
|
||||
self.world.selection.updateSelection(selectedModel)
|
||||
|
||||
self.world.selection.updateSelection(selected_node)
|
||||
|
||||
# 在树形控件中查找并选中对应的项
|
||||
if hasattr(self.world, 'interface_manager') and self.world.interface_manager and hasattr(self.world.interface_manager, 'treeWidget') and self.world.interface_manager.treeWidget:
|
||||
#print("查找树形控件中的对应项...")
|
||||
root = self.world.interface_manager.treeWidget.invisibleRootItem()
|
||||
foundItem = None
|
||||
|
||||
for i in range(root.childCount()):
|
||||
sceneItem = root.child(i)
|
||||
if sceneItem.text(0) == "场景":
|
||||
#print(f"在场景节点下查找...")
|
||||
foundItem = self.world.interface_manager.findTreeItem(selectedModel, sceneItem)
|
||||
if foundItem:
|
||||
print(f"✓ 在树形控件中找到对应项: {foundItem.text(0)}")
|
||||
try:
|
||||
self.world.interface_manager.treeWidget.itemClicked.disconnect()
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
self.world.interface_manager.treeWidget.setCurrentItem(foundItem)
|
||||
|
||||
self.world.interface_manager.treeWidget.itemClicked.connect(
|
||||
self.world.interface_manager.onTreeItemClicked)
|
||||
else:
|
||||
print("× 在树形控件中没有找到对应项")
|
||||
break
|
||||
|
||||
if not foundItem:
|
||||
print("× 没有找到场景节点或对应的树形项")
|
||||
if self._sync_tree_selection(selected_node):
|
||||
tree_widget = self._get_tree_widget()
|
||||
if tree_widget and tree_widget.currentItem():
|
||||
print(f"✓ 在树形控件中找到对应项: {tree_widget.currentItem().text(0)}")
|
||||
else:
|
||||
print("× 树形控件不存在")
|
||||
print("× 场景树未同步(接口不可用或未找到对应项)")
|
||||
else:
|
||||
print("× 没有找到可选择的模型节点")
|
||||
self.world.selection.updateSelection(None)
|
||||
|
||||
def mouseReleaseEventLeft(self, evt):
|
||||
"""处理鼠标左键释放事件"""
|
||||
# 处理坐标轴拖拽结束
|
||||
if self.world.selection.isDraggingGizmo:
|
||||
self.world.selection.stopGizmoDrag()
|
||||
return
|
||||
return
|
||||
|
||||
def wheelForward(self, data=None):
|
||||
"""处理滚轮向前滚动(前进)"""
|
||||
@ -531,11 +510,12 @@ class EventHandler:
|
||||
super(type(self.world), self.world).wheelForward(data)
|
||||
|
||||
# 更新属性面板
|
||||
if (self.world.interface_manager.treeWidget and
|
||||
self.world.interface_manager.treeWidget.currentItem() and
|
||||
self.world.interface_manager.treeWidget.currentItem().text(0) == "相机"):
|
||||
self.world.property_panel.updatePropertyPanel(
|
||||
self.world.interface_manager.treeWidget.currentItem())
|
||||
tree_widget = self._get_tree_widget()
|
||||
current_item = tree_widget.currentItem() if tree_widget else None
|
||||
if (current_item and
|
||||
current_item.text(0) == "相机" and
|
||||
hasattr(self.world, "property_panel")):
|
||||
self.world.property_panel.updatePropertyPanel(current_item)
|
||||
|
||||
def wheelBackward(self, data=None):
|
||||
"""处理滚轮向后滚动(后退)"""
|
||||
@ -543,11 +523,12 @@ class EventHandler:
|
||||
super(type(self.world), self.world).wheelBackward(data)
|
||||
|
||||
# 更新属性面板
|
||||
if (self.world.interface_manager.treeWidget and
|
||||
self.world.interface_manager.treeWidget.currentItem() and
|
||||
self.world.interface_manager.treeWidget.currentItem().text(0) == "相机"):
|
||||
self.world.property_panel.updatePropertyPanel(
|
||||
self.world.interface_manager.treeWidget.currentItem())
|
||||
tree_widget = self._get_tree_widget()
|
||||
current_item = tree_widget.currentItem() if tree_widget else None
|
||||
if (current_item and
|
||||
current_item.text(0) == "相机" and
|
||||
hasattr(self.world, "property_panel")):
|
||||
self.world.property_panel.updatePropertyPanel(current_item)
|
||||
|
||||
def mousePressEventMiddle(self, evt):
|
||||
"""处理鼠标中键按下事件"""
|
||||
@ -563,30 +544,6 @@ class EventHandler:
|
||||
"""处理鼠标移动事件"""
|
||||
if not evt:
|
||||
return
|
||||
|
||||
# 处理坐标轴拖拽
|
||||
if self.world.selection.isDraggingGizmo:
|
||||
x = evt.get('x', 0)
|
||||
y = evt.get('y', 0)
|
||||
|
||||
# 获取准确的窗口尺寸
|
||||
winWidth, winHeight = self.world.getWindowSize()
|
||||
|
||||
# 将屏幕坐标转换为世界坐标
|
||||
mx = 2.0 * x / float(winWidth) - 1.0
|
||||
my = 1.0 - 2.0 * y / float(winHeight)
|
||||
|
||||
# 更新坐标轴拖拽
|
||||
self.world.selection.updateGizmoDrag(x, y)
|
||||
return
|
||||
|
||||
# 更新坐标轴高亮(鼠标悬停效果)
|
||||
if self.world.selection.gizmo and not self.world.selection.isDraggingGizmo:
|
||||
x = evt.get('x', 0)
|
||||
y = evt.get('y', 0)
|
||||
# 减少高亮调试输出,只在需要时输出
|
||||
# 已静默处理,避免控制台刷屏
|
||||
self.world.selection.updateGizmoHighlight(x, y)
|
||||
|
||||
|
||||
# 调用CoreWorld的父类方法处理基础的相机旋转
|
||||
super(type(self.world), self.world).mouseMoveEvent(evt)
|
||||
|
||||
@ -24,6 +24,8 @@ class ImGuiStyleManager:
|
||||
self.world = world
|
||||
self.io = imgui_backend.io
|
||||
self.style = None # 延迟初始化,在apply_style中设置
|
||||
self._icon_cache = {}
|
||||
self._missing_icons = set()
|
||||
|
||||
# 颜色定义 - 与Qt UI保持一致
|
||||
self.colors = {
|
||||
@ -339,6 +341,7 @@ class ImGuiStyleManager:
|
||||
style.scrollbar_rounding = self.sizes['frame_rounding']
|
||||
style.grab_min_size = 10.0
|
||||
style.grab_rounding = self.sizes['frame_rounding']
|
||||
style.window_menu_button_position = imgui.Dir_.none
|
||||
|
||||
# 禁用一些ImGui的默认效果,使其更像Qt
|
||||
style.window_border_size = 1.0
|
||||
@ -377,6 +380,14 @@ class ImGuiStyleManager:
|
||||
flags = self.get_window_flags(window_type)
|
||||
|
||||
return imgui_ctx.begin(name, open, flags)
|
||||
|
||||
def prepare_centered_dialog(self, width, height, cond=imgui.Cond_.appearing):
|
||||
"""Place a modal/dialog in the center of the current main viewport."""
|
||||
viewport = imgui.get_main_viewport()
|
||||
center_x = viewport.pos.x + (viewport.size.x - width) / 2
|
||||
center_y = viewport.pos.y + (viewport.size.y - height) / 2
|
||||
imgui.set_next_window_size((width, height), cond)
|
||||
imgui.set_next_window_pos((center_x, center_y), cond)
|
||||
|
||||
def styled_button(self, label, size=(0, 0)):
|
||||
"""绘制带样式的按钮"""
|
||||
@ -394,24 +405,40 @@ class ImGuiStyleManager:
|
||||
|
||||
def load_icon(self, icon_name):
|
||||
"""加载图标纹理为ImGui可用的格式"""
|
||||
if icon_name in self._icon_cache:
|
||||
return self._icon_cache[icon_name]
|
||||
if icon_name in self._missing_icons:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 构建图标路径
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
icon_path = project_root / "icons" / f"{icon_name}.png"
|
||||
|
||||
if icon_path.exists():
|
||||
# 使用base.imgui.loadTexture方法
|
||||
if hasattr(base, 'imgui') and hasattr(base.imgui, 'loadTexture'):
|
||||
# 转换路径为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
|
||||
else:
|
||||
|
||||
if not icon_path.exists():
|
||||
print(f"⚠ 图标文件不存在: {icon_path}")
|
||||
self._missing_icons.add(icon_name)
|
||||
return None
|
||||
|
||||
base_app = getattr(self.world, "base", None) if self.world else None
|
||||
if base_app is None:
|
||||
try:
|
||||
from direct.showbase.ShowBaseGlobal import base as base_app
|
||||
except Exception:
|
||||
base_app = None
|
||||
|
||||
imgui_runtime = getattr(base_app, "imgui", None) if base_app else None
|
||||
texture_loader = getattr(imgui_runtime, "loadTexture", None)
|
||||
if not callable(texture_loader):
|
||||
print("⚠ ImGui后端未初始化")
|
||||
return None
|
||||
|
||||
# 转换路径为Panda3D兼容格式 (Windows下: D:\... -> /d/...)
|
||||
# 注意: p3dimgui.loadTexture 仅支持 str 或 Texture,不支持 Filename 对象
|
||||
fn = Filename.fromOsSpecific(str(icon_path))
|
||||
texture = texture_loader(fn.getFullpath())
|
||||
self._icon_cache[icon_name] = texture
|
||||
return texture
|
||||
except Exception as e:
|
||||
print(f"⚠ 加载图标失败: {e}")
|
||||
return None
|
||||
@ -419,6 +446,72 @@ class ImGuiStyleManager:
|
||||
def image_button(self, texture_id, size=(32, 32), bg_col=(0, 0, 0, 0), tint_col=(1, 1, 1, 1)):
|
||||
"""绘制图像按钮"""
|
||||
return imgui.image_button(texture_id, size, bg_col, tint_col)
|
||||
|
||||
def draw_stat_chip(self, label, tint=None, text_color=None):
|
||||
"""绘制不可交互的状态胶囊。"""
|
||||
if tint is None:
|
||||
tint = self.colors['button_bg']
|
||||
if text_color is None:
|
||||
text_color = self.colors['text']
|
||||
|
||||
text_size = imgui.calc_text_size(label)
|
||||
horizontal_padding = 8.0
|
||||
vertical_padding = 4.0
|
||||
chip_width = float(text_size.x) + horizontal_padding * 2.0
|
||||
chip_height = float(text_size.y) + vertical_padding * 2.0
|
||||
|
||||
cursor_pos = imgui.get_cursor_screen_pos()
|
||||
draw_list = imgui.get_window_draw_list()
|
||||
bg_color = imgui.color_convert_float4_to_u32(tint)
|
||||
fg_color = imgui.color_convert_float4_to_u32(text_color)
|
||||
|
||||
draw_list.add_rect_filled(
|
||||
cursor_pos,
|
||||
(cursor_pos.x + chip_width, cursor_pos.y + chip_height),
|
||||
bg_color,
|
||||
12.0,
|
||||
)
|
||||
draw_list.add_text(
|
||||
(cursor_pos.x + horizontal_padding, cursor_pos.y + vertical_padding),
|
||||
fg_color,
|
||||
label,
|
||||
)
|
||||
imgui.dummy((chip_width, chip_height))
|
||||
|
||||
def draw_toolbar_button(self, label, active=False, size=(56, 28), tooltip=None, enabled=True):
|
||||
"""绘制统一风格的工具栏按钮。"""
|
||||
if active:
|
||||
button_color = self.colors['primary']
|
||||
hovered_color = self.colors['primary_dark']
|
||||
text_color = (1.0, 1.0, 1.0, 1.0)
|
||||
border_color = self.colors['primary_dark']
|
||||
else:
|
||||
button_color = self.colors['button_bg']
|
||||
hovered_color = self.colors['panel_bg']
|
||||
text_color = self.colors['text']
|
||||
border_color = self.colors['border_secondary']
|
||||
|
||||
imgui.push_style_color(imgui.Col_.button, button_color)
|
||||
imgui.push_style_color(imgui.Col_.button_hovered, hovered_color)
|
||||
imgui.push_style_color(imgui.Col_.button_active, hovered_color)
|
||||
imgui.push_style_color(imgui.Col_.text, text_color)
|
||||
imgui.push_style_color(imgui.Col_.border, border_color)
|
||||
imgui.push_style_var(imgui.StyleVar_.frame_rounding, 8.0)
|
||||
imgui.push_style_var(imgui.StyleVar_.frame_border_size, 1.0)
|
||||
imgui.push_style_var(imgui.StyleVar_.frame_padding, (10.0, 6.0))
|
||||
|
||||
if not enabled:
|
||||
imgui.begin_disabled()
|
||||
clicked = imgui.button(label, size)
|
||||
if not enabled:
|
||||
imgui.end_disabled()
|
||||
|
||||
if tooltip and imgui.is_item_hovered():
|
||||
imgui.set_tooltip(tooltip)
|
||||
|
||||
imgui.pop_style_var(3)
|
||||
imgui.pop_style_color(5)
|
||||
return clicked
|
||||
|
||||
def get_icon_text_button(self, icon_texture, text, size=(0, 0)):
|
||||
"""绘制带图标的文本按钮"""
|
||||
@ -428,4 +521,4 @@ class ImGuiStyleManager:
|
||||
imgui.same_line()
|
||||
|
||||
# 再绘制文本按钮
|
||||
return imgui.button(text, size)
|
||||
return imgui.button(text, size)
|
||||
|
||||
225
core/imgui_webview.py
Normal file
@ -0,0 +1,225 @@
|
||||
"""
|
||||
imgui_webview.py
|
||||
后台 playwright 无头浏览器 + 截图 → Panda3D 纹理,供 ImGui 面板显示。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
import io
|
||||
import time
|
||||
|
||||
|
||||
class ImGuiWebView:
|
||||
"""
|
||||
后台线程运行 playwright Chromium,定期截图并转换为 Panda3D 纹理。
|
||||
ImGui 直接用 imgui.image() 显示纹理,鼠标/滚轮事件转发给浏览器。
|
||||
"""
|
||||
|
||||
def __init__(self, width: int = 1280, height: int = 720):
|
||||
self.view_width = width
|
||||
self.view_height = height
|
||||
|
||||
# 截图数据(bytes)
|
||||
self._screenshot: bytes | None = None
|
||||
self._screenshot_lock = threading.Lock()
|
||||
self.tex_dirty = False # 有新截图待上传 GPU
|
||||
|
||||
# 状态
|
||||
self.current_url = ""
|
||||
self.title = ""
|
||||
self.is_loading = False
|
||||
self.error: str | None = None
|
||||
|
||||
# 待处理指令(由 ImGui 线程写,浏览器线程读)
|
||||
self._cmd_navigate: str | None = None
|
||||
self._cmd_click: tuple | None = None # (x_ratio, y_ratio)
|
||||
self._cmd_scroll: float | None = None # pixels
|
||||
self._cmd_back = False
|
||||
self._cmd_forward = False
|
||||
self._cmd_reload = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 公开控制 API(由 ImGui 线程调用,线程安全)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def start(self, url: str):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._cmd_navigate = url
|
||||
self._thread = threading.Thread(target=self._run, daemon=True,
|
||||
name="imgui-webview")
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
def navigate(self, url: str):
|
||||
if not url.startswith(('http://', 'https://', 'file://')):
|
||||
url = 'https://' + url
|
||||
with self._lock:
|
||||
self._cmd_navigate = url
|
||||
self.is_loading = True
|
||||
|
||||
def click(self, x_ratio: float, y_ratio: float):
|
||||
with self._lock:
|
||||
self._cmd_click = (x_ratio, y_ratio)
|
||||
|
||||
def scroll(self, delta_px: float):
|
||||
with self._lock:
|
||||
self._cmd_scroll = delta_px
|
||||
|
||||
def go_back(self):
|
||||
with self._lock:
|
||||
self._cmd_back = True
|
||||
|
||||
def go_forward(self):
|
||||
with self._lock:
|
||||
self._cmd_forward = True
|
||||
|
||||
def reload(self):
|
||||
with self._lock:
|
||||
self._cmd_reload = True
|
||||
|
||||
def get_screenshot_bytes(self) -> bytes | None:
|
||||
with self._screenshot_lock:
|
||||
return self._screenshot
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 内部线程
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _run(self):
|
||||
import asyncio
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(self._async_run())
|
||||
except Exception as exc:
|
||||
self.error = f"WebView 线程异常: {exc}"
|
||||
import traceback; traceback.print_exc()
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
async def _async_run(self):
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
except ImportError:
|
||||
self.error = (
|
||||
"playwright 未安装。\n"
|
||||
"请运行: pip install playwright\n"
|
||||
"然后运行: playwright install chromium"
|
||||
)
|
||||
self._running = False
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(headless=True)
|
||||
ctx = await browser.new_context(
|
||||
viewport={"width": self.view_width,
|
||||
"height": self.view_height},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/121.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
page = await ctx.new_page()
|
||||
|
||||
# 初次导航
|
||||
start_url = self._cmd_navigate or "about:blank"
|
||||
self._cmd_navigate = None
|
||||
await self._goto(page, start_url)
|
||||
await self._snap(page)
|
||||
|
||||
# 主循环
|
||||
import asyncio
|
||||
while self._running:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
with self._lock:
|
||||
nav_url = self._cmd_navigate; self._cmd_navigate = None
|
||||
clk = self._cmd_click; self._cmd_click = None
|
||||
scr = self._cmd_scroll; self._cmd_scroll = None
|
||||
do_back = self._cmd_back; self._cmd_back = False
|
||||
do_fwd = self._cmd_forward; self._cmd_forward = False
|
||||
do_reload = self._cmd_reload; self._cmd_reload = False
|
||||
|
||||
changed = False
|
||||
|
||||
if nav_url:
|
||||
self.is_loading = True
|
||||
await self._goto(page, nav_url)
|
||||
changed = True
|
||||
self.is_loading = False
|
||||
|
||||
if do_back:
|
||||
await page.go_back()
|
||||
await asyncio.sleep(0.5)
|
||||
changed = True
|
||||
|
||||
if do_fwd:
|
||||
await page.go_forward()
|
||||
await asyncio.sleep(0.5)
|
||||
changed = True
|
||||
|
||||
if do_reload:
|
||||
await page.reload(wait_until="domcontentloaded")
|
||||
changed = True
|
||||
|
||||
if clk is not None:
|
||||
xr, yr = clk
|
||||
x = int(xr * self.view_width)
|
||||
y = int(yr * self.view_height)
|
||||
await page.mouse.click(x, y)
|
||||
await asyncio.sleep(0.4)
|
||||
changed = True
|
||||
|
||||
if scr is not None:
|
||||
await page.evaluate(
|
||||
f"window.scrollBy(0, {int(scr)})"
|
||||
)
|
||||
await asyncio.sleep(0.15)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
self.current_url = page.url
|
||||
try:
|
||||
self.title = await page.title()
|
||||
except Exception:
|
||||
pass
|
||||
await self._snap(page)
|
||||
|
||||
await browser.close()
|
||||
|
||||
except Exception as exc:
|
||||
self.error = str(exc)
|
||||
import traceback; traceback.print_exc()
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
async def _goto(self, page, url: str):
|
||||
import asyncio
|
||||
try:
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=20_000)
|
||||
self.current_url = page.url
|
||||
try:
|
||||
self.title = await page.title()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
print(f"[WebView] 导航失败 {url}: {exc}")
|
||||
|
||||
async def _snap(self, page):
|
||||
"""截图并更新 self._screenshot"""
|
||||
try:
|
||||
data = await page.screenshot(type="png", full_page=False)
|
||||
with self._screenshot_lock:
|
||||
self._screenshot = data
|
||||
self.tex_dirty = True
|
||||
except Exception as exc:
|
||||
print(f"[WebView] 截图失败: {exc}")
|
||||
@ -1,495 +0,0 @@
|
||||
from direct.showbase.ShowBaseGlobal import globalClock
|
||||
from direct.task.TaskManagerGlobal import taskMgr
|
||||
from panda3d.core import Point3, Vec3
|
||||
import math
|
||||
|
||||
|
||||
class PatrolSystem:
|
||||
"""巡检系统类"""
|
||||
|
||||
def __init__(self, world):
|
||||
"""初始化巡检系统
|
||||
|
||||
Args:
|
||||
world: 核心世界对象引用
|
||||
"""
|
||||
self.world = world
|
||||
|
||||
# 巡检状态
|
||||
self.is_patrolling = False
|
||||
self.patrol_points = [] # 巡检点列表 [(pos, hpr, wait_time), ...]
|
||||
self.current_patrol_index = 0
|
||||
self.patrol_task = None
|
||||
|
||||
# 巡检参数
|
||||
self.patrol_speed = 5.0 # 巡检移动速度(单位/秒)
|
||||
self.patrol_turn_speed = 90.0 # 转向速度(度/秒)
|
||||
self.patrol_wait_timer = 0.0
|
||||
self.patrol_state = "moving" # "moving", "turning_to_target", "waiting", "turning_back"
|
||||
|
||||
# 相机状态保存
|
||||
self.original_cam_pos = None
|
||||
self.original_cam_hpr = None
|
||||
|
||||
print("✓ 巡检系统初始化完成")
|
||||
|
||||
def add_patrol_point(self, position, heading=None, wait_time=3.0):
|
||||
if heading is None:
|
||||
if self.patrol_points:
|
||||
last_pos = self.patrol_points[-1][0]
|
||||
direction_x = position[0] - last_pos.x
|
||||
direction_y = position[1] - last_pos.y
|
||||
direction_z = position[2] - last_pos.z
|
||||
|
||||
import math
|
||||
h=math.degrees(math.atan2(-direction_x,-direction_y))
|
||||
|
||||
distance_xy = math.sqrt(direction_x**2+direction_y**2)
|
||||
p = math.degrees(math.atan2(direction_z,distance_xy))
|
||||
p = max(-89,min(89,p))
|
||||
|
||||
r=0
|
||||
|
||||
heading = (h,p,r)
|
||||
|
||||
else:
|
||||
# 使用当前相机朝向
|
||||
current_hpr = self.world.cam.getHpr()
|
||||
heading = (current_hpr.x, current_hpr.y, current_hpr.z)
|
||||
|
||||
pos = Point3(position[0], position[1], position[2])
|
||||
hpr = Vec3(heading[0], heading[1], heading[2])
|
||||
|
||||
self.patrol_points.append((pos, hpr, wait_time))
|
||||
print(f"✓ 添加巡检点 {len(self.patrol_points)}: 位置{position}, 朝向{heading}, 停留{wait_time}秒")
|
||||
|
||||
# 在 PatrolSystem 类中添加以下方法
|
||||
|
||||
def add_auto_heading_patrol_point(self, position, wait_time=3.0):
|
||||
"""添加自动计算朝向的巡检点(朝向路径前进方向)
|
||||
|
||||
Args:
|
||||
position: 相机位置 (x, y, z)
|
||||
wait_time: 在该点停留时间(秒)
|
||||
"""
|
||||
heading = None # 将自动计算朝向
|
||||
|
||||
# 复用原有的 add_patrol_point 方法
|
||||
self.add_patrol_point(position, heading, wait_time)
|
||||
|
||||
def add_patrol_point_looking_at(self, position, look_at_position, wait_time=3.0):
|
||||
"""添加朝向指定位置的巡检点
|
||||
|
||||
Args:
|
||||
position: 相机位置 (x, y, z)
|
||||
look_at_position: 相机朝向的目标位置 (x, y, z)
|
||||
wait_time: 在该点停留时间(秒)
|
||||
"""
|
||||
import math
|
||||
|
||||
# 计算从当前位置到目标位置的方向向量
|
||||
direction_x = look_at_position[0] - position[0]
|
||||
direction_y = look_at_position[1] - position[1]
|
||||
direction_z = look_at_position[2] - position[2]
|
||||
|
||||
# 计算HPR朝向
|
||||
h = math.degrees(math.atan2(-direction_x, -direction_y))
|
||||
|
||||
distance_xy = math.sqrt(direction_x ** 2 + direction_y ** 2)
|
||||
p = math.degrees(math.atan2(direction_z, distance_xy))
|
||||
p = max(-89, min(89, p)) # 限制pitch角度在合理范围内
|
||||
|
||||
r = 0 # roll通常为0
|
||||
|
||||
heading = (h, p, r)
|
||||
self.add_patrol_point(position, heading, wait_time)
|
||||
|
||||
def clear_patrol_points(self):
|
||||
"""清空所有巡检点"""
|
||||
self.patrol_points = []
|
||||
print("✓ 巡检点已清空")
|
||||
|
||||
def set_patrol_speed(self, move_speed, turn_speed=None):
|
||||
"""设置巡检速度
|
||||
|
||||
Args:
|
||||
move_speed: 移动速度(单位/秒)
|
||||
turn_speed: 转向速度(度/秒),如果为None则保持当前值
|
||||
"""
|
||||
self.patrol_speed = move_speed
|
||||
if turn_speed is not None:
|
||||
self.patrol_turn_speed = turn_speed
|
||||
print(f"✓ 巡检速度已设置: 移动{move_speed}, 转向{turn_speed or self.patrol_turn_speed}")
|
||||
|
||||
def start_patrol(self):
|
||||
"""开始巡检"""
|
||||
if not self.patrol_points:
|
||||
print("✗ 没有设置巡检点,无法开始巡检")
|
||||
return False
|
||||
|
||||
if self.is_patrolling:
|
||||
print("⚠ 巡检已在进行中")
|
||||
return True
|
||||
|
||||
# 保存当前相机状态
|
||||
self.original_cam_pos = Point3(self.world.cam.getPos())
|
||||
self.original_cam_hpr = Vec3(self.world.cam.getHpr())
|
||||
|
||||
# 重置巡检状态
|
||||
self.current_patrol_index = 0
|
||||
self.patrol_state = "moving"
|
||||
self.patrol_wait_timer = 0.0
|
||||
self.is_patrolling = True
|
||||
|
||||
# 启动巡检任务
|
||||
if self.patrol_task:
|
||||
taskMgr.remove(self.patrol_task)
|
||||
self.patrol_task = taskMgr.add(self._patrol_task, "patrol_task")
|
||||
|
||||
print(f"✓ 开始巡检,共{len(self.patrol_points)}个巡检点")
|
||||
return True
|
||||
|
||||
def stop_patrol(self):
|
||||
"""停止巡检"""
|
||||
if not self.is_patrolling:
|
||||
print("⚠ 巡检未在进行中")
|
||||
return False
|
||||
|
||||
# 停止巡检任务
|
||||
if self.patrol_task:
|
||||
taskMgr.remove(self.patrol_task)
|
||||
self.patrol_task = None
|
||||
|
||||
self.is_patrolling = False
|
||||
self.patrol_state = "moving"
|
||||
self.patrol_wait_timer = 0.0
|
||||
|
||||
print("✓ 巡检已停止")
|
||||
return True
|
||||
|
||||
def pause_patrol(self):
|
||||
"""暂停巡检"""
|
||||
if not self.is_patrolling:
|
||||
print("⚠ 巡检未在进行中")
|
||||
return False
|
||||
|
||||
if self.patrol_task:
|
||||
taskMgr.remove(self.patrol_task)
|
||||
self.patrol_task = None
|
||||
|
||||
print("✓ 巡检已暂停")
|
||||
return True
|
||||
|
||||
def resume_patrol(self):
|
||||
"""恢复巡检"""
|
||||
if self.is_patrolling:
|
||||
print("⚠ 巡检已在进行中")
|
||||
return False
|
||||
|
||||
if not self.patrol_points:
|
||||
print("✗ 没有设置巡检点")
|
||||
return False
|
||||
|
||||
self.is_patrolling = True
|
||||
self.patrol_task = taskMgr.add(self._patrol_task, "patrol_task")
|
||||
|
||||
print("✓ 巡检已恢复")
|
||||
return True
|
||||
|
||||
def reset_to_original_position(self):
|
||||
"""重置相机到原始位置"""
|
||||
if self.original_cam_pos and self.original_cam_hpr:
|
||||
self.world.cam.setPos(self.original_cam_pos)
|
||||
self.world.cam.setHpr(self.original_cam_hpr)
|
||||
print("✓ 相机已重置到原始位置")
|
||||
return True
|
||||
else:
|
||||
print("✗ 没有保存的原始位置")
|
||||
return False
|
||||
|
||||
def _patrol_task(self, task):
|
||||
"""巡检主任务"""
|
||||
try:
|
||||
if not self.is_patrolling or not self.patrol_points:
|
||||
return task.done
|
||||
|
||||
# 获取当前巡检点
|
||||
current_point = self.patrol_points[self.current_patrol_index]
|
||||
target_pos, target_hpr, wait_time = current_point
|
||||
|
||||
# 根据当前状态执行不同操作
|
||||
if self.patrol_state == "moving":
|
||||
self._handle_moving_state(target_pos)
|
||||
elif self.patrol_state == "turning_to_target":
|
||||
self._handle_turning_to_target_state(target_hpr)
|
||||
elif self.patrol_state == "waiting":
|
||||
self._handle_waiting_state(wait_time)
|
||||
elif self.patrol_state == "turning_back":
|
||||
self._handle_turning_back_state()
|
||||
|
||||
return task.cont
|
||||
|
||||
except Exception as e:
|
||||
print(f"巡检任务出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return task.done
|
||||
|
||||
def _handle_moving_state(self, target_pos):
|
||||
"""处理移动状态"""
|
||||
current_pos = self.world.cam.getPos()
|
||||
distance = (target_pos - current_pos).length()
|
||||
|
||||
if distance < 0.1: # 到达目标点
|
||||
print(f"✓ 到达巡检点 {self.current_patrol_index + 1}")
|
||||
self.patrol_state = "turning_to_target"
|
||||
return
|
||||
|
||||
# 计算移动方向和距离
|
||||
direction = target_pos - current_pos
|
||||
direction.normalize()
|
||||
|
||||
# 计算目标朝向(看向目标点)
|
||||
target_hpr = self._look_at_to_hpr(direction)
|
||||
current_hpr = self.world.cam.getHpr()
|
||||
|
||||
# 平滑转向到目标朝向
|
||||
h_diff = self._normalize_angle(target_hpr.x - current_hpr.x)
|
||||
p_diff = self._normalize_angle(target_hpr.y - current_hpr.y)
|
||||
r_diff = self._normalize_angle(target_hpr.z - current_hpr.z)
|
||||
|
||||
# 计算本帧应转动的角度
|
||||
dt = globalClock.getDt()
|
||||
turn_amount = self.patrol_turn_speed * dt
|
||||
|
||||
# 逐步转向目标角度
|
||||
new_hpr = Vec3(current_hpr)
|
||||
|
||||
if abs(h_diff) > turn_amount:
|
||||
new_hpr.x += turn_amount if h_diff > 0 else -turn_amount
|
||||
else:
|
||||
new_hpr.x = target_hpr.x
|
||||
|
||||
if abs(p_diff) > turn_amount:
|
||||
new_hpr.y += turn_amount if p_diff > 0 else -turn_amount
|
||||
else:
|
||||
new_hpr.y = target_hpr.y
|
||||
|
||||
if abs(r_diff) > turn_amount:
|
||||
new_hpr.z += turn_amount if r_diff > 0 else -turn_amount
|
||||
else:
|
||||
new_hpr.z = target_hpr.z
|
||||
|
||||
self.world.cam.setHpr(new_hpr)
|
||||
|
||||
# 计算本帧应移动的距离
|
||||
move_distance = self.patrol_speed * dt
|
||||
|
||||
# 如果移动距离大于剩余距离,则直接移动到目标点
|
||||
if move_distance >= distance:
|
||||
self.world.cam.setPos(target_pos)
|
||||
else:
|
||||
# 否则按方向移动
|
||||
new_pos = current_pos + direction * move_distance
|
||||
self.world.cam.setPos(new_pos)
|
||||
|
||||
def _handle_turning_to_target_state(self, target_hpr):
|
||||
"""处理转向目标状态"""
|
||||
# 检查是否需要朝向下一个点
|
||||
if target_hpr == "look_next":
|
||||
# 计算朝向下一个点的方向
|
||||
next_index = (self.current_patrol_index + 1) % len(self.patrol_points)
|
||||
next_point_pos = self.patrol_points[next_index][0]
|
||||
|
||||
current_pos = self.world.cam.getPos()
|
||||
direction = next_point_pos - current_pos
|
||||
direction.normalize()
|
||||
|
||||
# 计算目标朝向
|
||||
target_hpr = self._look_at_to_hpr(direction)
|
||||
|
||||
current_hpr = self.world.cam.getHpr()
|
||||
|
||||
# 计算角度差
|
||||
h_diff = self._normalize_angle(target_hpr.x - current_hpr.x)
|
||||
p_diff = self._normalize_angle(target_hpr.y - current_hpr.y)
|
||||
r_diff = self._normalize_angle(target_hpr.z - current_hpr.z)
|
||||
|
||||
# 检查是否已完成转向
|
||||
if abs(h_diff) < 1.0 and abs(p_diff) < 1.0 and abs(r_diff) < 1.0:
|
||||
print(f"✓ 完成转向,开始停留")
|
||||
self.patrol_state = "waiting"
|
||||
self.patrol_wait_timer = 0.0
|
||||
return
|
||||
|
||||
# 计算本帧应转动的角度
|
||||
dt = globalClock.getDt()
|
||||
turn_amount = self.patrol_turn_speed * dt
|
||||
|
||||
# 逐步转向目标角度
|
||||
new_hpr = Vec3(current_hpr)
|
||||
|
||||
if abs(h_diff) > turn_amount:
|
||||
new_hpr.x += turn_amount if h_diff > 0 else -turn_amount
|
||||
else:
|
||||
new_hpr.x = target_hpr.x
|
||||
|
||||
if abs(p_diff) > turn_amount:
|
||||
new_hpr.y += turn_amount if p_diff > 0 else -turn_amount
|
||||
else:
|
||||
new_hpr.y = target_hpr.y
|
||||
|
||||
if abs(r_diff) > turn_amount:
|
||||
new_hpr.z += turn_amount if r_diff > 0 else -turn_amount
|
||||
else:
|
||||
new_hpr.z = target_hpr.z
|
||||
|
||||
self.world.cam.setHpr(new_hpr)
|
||||
|
||||
def _handle_waiting_state(self, wait_time):
|
||||
"""处理等待状态"""
|
||||
self.patrol_wait_timer += globalClock.getDt()
|
||||
|
||||
if self.patrol_wait_timer >= wait_time:
|
||||
print(f"✓ 停留结束,准备转回原朝向")
|
||||
self.patrol_state = "turning_back"
|
||||
|
||||
# 修改 core/patrol_system.py 中的 _handle_turning_back_state 方法
|
||||
|
||||
def _handle_turning_back_state(self):
|
||||
"""处理转回原朝向状态"""
|
||||
# 直接完成转向状态,进入移动状态
|
||||
print(f"✓ 停留结束,开始移动到下一个点")
|
||||
# 移动到下一个巡检点
|
||||
next_index = (self.current_patrol_index + 1) % len(self.patrol_points)
|
||||
self.current_patrol_index = next_index
|
||||
self.patrol_state = "moving"
|
||||
return
|
||||
|
||||
def _normalize_angle(self, angle):
|
||||
"""规范化角度到-180到180度之间"""
|
||||
while angle > 180:
|
||||
angle -= 360
|
||||
while angle < -180:
|
||||
angle += 360
|
||||
return angle
|
||||
|
||||
def _look_at_to_hpr(self, direction):
|
||||
"""将方向向量转换为HPR角度"""
|
||||
# 简化的转换,实际应用中可能需要更精确的计算
|
||||
h = math.degrees(math.atan2(-direction.x, -direction.y))
|
||||
p = math.degrees(math.asin(direction.z))
|
||||
return Vec3(h, p, 0)
|
||||
|
||||
def get_patrol_status(self):
|
||||
"""获取巡检状态信息"""
|
||||
return {
|
||||
"is_patrolling": self.is_patrolling,
|
||||
"current_point": self.current_patrol_index,
|
||||
"total_points": len(self.patrol_points),
|
||||
"state": self.patrol_state,
|
||||
"wait_timer": self.patrol_wait_timer
|
||||
}
|
||||
|
||||
def list_patrol_points(self):
|
||||
"""列出所有巡检点"""
|
||||
if not self.patrol_points:
|
||||
print("没有设置巡检点")
|
||||
return
|
||||
|
||||
print(f"巡检点列表 (共{len(self.patrol_points)}个):")
|
||||
for i, (pos, hpr, wait_time) in enumerate(self.patrol_points):
|
||||
current_marker = " >>>" if i == self.current_patrol_index and self.is_patrolling else ""
|
||||
print(f" {i + 1}. 位置:({pos.x:.1f}, {pos.y:.1f}, {pos.z:.1f}) "
|
||||
f"朝向:({hpr.x:.1f}, {hpr.y:.1f}, {hpr.z:.1f}) "
|
||||
f"停留:{wait_time}秒{current_marker}")
|
||||
|
||||
def remove_patrol_point(self, index):
|
||||
"""移除指定索引的巡检点"""
|
||||
if 0 <= index < len(self.patrol_points):
|
||||
removed_point = self.patrol_points.pop(index)
|
||||
print(
|
||||
f"✓ 移除巡检点 {index + 1}: 位置({removed_point[0].x:.1f}, {removed_point[0].y:.1f}, {removed_point[0].z:.1f})")
|
||||
|
||||
# 调整当前索引
|
||||
if self.current_patrol_index >= len(self.patrol_points) and self.patrol_points:
|
||||
self.current_patrol_index = len(self.patrol_points) - 1
|
||||
elif self.current_patrol_index >= len(self.patrol_points):
|
||||
self.current_patrol_index = 0
|
||||
else:
|
||||
print(f"✗ 无效的巡检点索引: {index}")
|
||||
|
||||
def insert_patrol_point(self, index, position, heading=None, wait_time=3.0):
|
||||
"""在指定位置插入巡检点"""
|
||||
if index < 0 or index > len(self.patrol_points):
|
||||
print(f"✗ 无效的插入位置: {index}")
|
||||
return
|
||||
|
||||
if heading is None:
|
||||
# 使用当前相机朝向
|
||||
current_hpr = self.world.cam.getHpr()
|
||||
heading = (current_hpr.x, current_hpr.y, current_hpr.z)
|
||||
|
||||
pos = Point3(position[0], position[1], position[2])
|
||||
hpr = Vec3(heading[0], heading[1], heading[2])
|
||||
|
||||
self.patrol_points.insert(index, (pos, hpr, wait_time))
|
||||
print(f"✓ 在位置 {index + 1} 插入巡检点: 位置{position}, 朝向{heading}, 停留{wait_time}秒")
|
||||
|
||||
def update_patrol_point(self, index, position=None, heading=None, wait_time=None):
|
||||
"""更新指定巡检点的信息"""
|
||||
if 0 <= index < len(self.patrol_points):
|
||||
pos, hpr, wt = self.patrol_points[index]
|
||||
|
||||
if position is not None:
|
||||
pos = Point3(position[0], position[1], position[2])
|
||||
if heading is not None:
|
||||
hpr = Vec3(heading[0], heading[1], heading[2])
|
||||
if wait_time is not None:
|
||||
wt = wait_time
|
||||
|
||||
self.patrol_points[index] = (pos, hpr, wt)
|
||||
print(f"✓ 更新巡检点 {index + 1}")
|
||||
else:
|
||||
print(f"✗ 无效的巡检点索引: {index}")
|
||||
|
||||
def goto_patrol_point(self, index):
|
||||
"""直接跳转到指定巡检点"""
|
||||
if not self.patrol_points:
|
||||
print("✗ 没有设置巡检点")
|
||||
return False
|
||||
|
||||
if 0 <= index < len(self.patrol_points):
|
||||
pos, hpr, _ = self.patrol_points[index]
|
||||
self.world.cam.setPos(pos)
|
||||
self.world.cam.setHpr(hpr)
|
||||
self.current_patrol_index = index
|
||||
print(f"✓ 跳转到巡检点 {index + 1}")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ 无效的巡检点索引: {index}")
|
||||
return False
|
||||
|
||||
def cleanup(self):
|
||||
"""清理巡检系统资源"""
|
||||
self.stop_patrol()
|
||||
self.clear_patrol_points()
|
||||
self.original_cam_pos = None
|
||||
self.original_cam_hpr = None
|
||||
print("✓ 巡检系统资源已清理")
|
||||
|
||||
|
||||
# 使用示例和便捷函数
|
||||
def create_default_patrol_route(patrol_system):
|
||||
"""创建默认的巡检路线(示例)"""
|
||||
# 清空现有巡检点
|
||||
patrol_system.clear_patrol_points()
|
||||
|
||||
# 添加一些示例巡检点
|
||||
patrol_system.add_patrol_point((0, -20, 5), (0, -15, 0), 2.0) # 点1:前方低位置
|
||||
patrol_system.add_patrol_point((0, 0, 10), (0, -30, 0), 3.0) # 点2:中央高位置
|
||||
patrol_system.add_patrol_point((15, 10, 5), (-45, -10, 0), 2.5) # 点3:右侧位置
|
||||
patrol_system.add_patrol_point((-15, 10, 5), (45, -10, 0), 2.5) # 点4:左侧位置
|
||||
|
||||
print("✓ 默认巡检路线已创建")
|
||||
|
||||
130
core/render_pipeline_tag_state.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""RenderPipeline tag state helpers for the editor runtime.
|
||||
|
||||
The stock native TagStateManager used by RenderPipeline creates pass-specific
|
||||
tag states from an empty RenderState. That works for static effects, but it
|
||||
drops material attributes when we edit materials live in the editor. For
|
||||
transparent forward materials this means the forward pass can no longer see
|
||||
the current MaterialAttrib.
|
||||
|
||||
This module installs a Python-side tag state manager that preserves the
|
||||
NodePath state when building tag states, and re-registers the already-created
|
||||
pipeline cameras.
|
||||
"""
|
||||
|
||||
from panda3d.core import BitMask32, ColorWriteAttrib, RenderState, ShaderAttrib
|
||||
|
||||
|
||||
class EditorTagStateManager:
|
||||
"""Tag-state manager that preserves NodePath state when applying effects."""
|
||||
|
||||
class StateContainer:
|
||||
def __init__(self, tag_name, mask, write_color):
|
||||
self.cameras = []
|
||||
self.tag_states = {}
|
||||
self.tag_name = tag_name
|
||||
self.mask = BitMask32.bit(mask)
|
||||
self.write_color = write_color
|
||||
|
||||
def __init__(self, main_cam_node):
|
||||
self._main_cam_node = main_cam_node
|
||||
self._main_cam_node.node().set_camera_mask(BitMask32.bit(1))
|
||||
self.containers = {
|
||||
"shadow": self.StateContainer("Shadows", 2, False),
|
||||
"voxelize": self.StateContainer("Voxelize", 3, False),
|
||||
"envmap": self.StateContainer("Envmap", 4, True),
|
||||
"forward": self.StateContainer("Forward", 5, True),
|
||||
}
|
||||
|
||||
def get_mask(self, container_name):
|
||||
if container_name == "gbuffer":
|
||||
return BitMask32.bit(1)
|
||||
return self.containers[container_name].mask
|
||||
|
||||
def apply_state(self, container_name, np, shader, name, sort):
|
||||
container = self.containers[container_name]
|
||||
state = np.get_state()
|
||||
|
||||
if not container.write_color:
|
||||
state = state.set_attrib(
|
||||
ColorWriteAttrib.make(ColorWriteAttrib.C_off), 10000
|
||||
)
|
||||
|
||||
state = state.set_attrib(ShaderAttrib.make(shader, sort), sort)
|
||||
container.tag_states[name] = state
|
||||
np.set_tag(container.tag_name, name)
|
||||
|
||||
for camera in container.cameras:
|
||||
camera.set_tag_state(name, state)
|
||||
|
||||
def cleanup_states(self):
|
||||
self._main_cam_node.node().clear_tag_states()
|
||||
for container in self.containers.values():
|
||||
for camera in container.cameras:
|
||||
camera.clear_tag_states()
|
||||
container.tag_states = {}
|
||||
|
||||
def register_camera(self, container_name, source):
|
||||
container = self.containers[container_name]
|
||||
source.set_tag_state_key(container.tag_name)
|
||||
source.set_camera_mask(container.mask)
|
||||
state = RenderState.make_empty()
|
||||
if not container.write_color:
|
||||
state = state.set_attrib(
|
||||
ColorWriteAttrib.make(ColorWriteAttrib.C_off), 10000
|
||||
)
|
||||
source.set_initial_state(state)
|
||||
container.cameras.append(source)
|
||||
|
||||
def unregister_camera(self, container_name, source):
|
||||
container = self.containers[container_name]
|
||||
if source not in container.cameras:
|
||||
return
|
||||
container.cameras.remove(source)
|
||||
source.clear_tag_states()
|
||||
source.set_initial_state(RenderState.make_empty())
|
||||
|
||||
|
||||
def install_editor_tag_state_manager(render_pipeline, base):
|
||||
"""Replace the native tag manager with an editor-safe Python variant."""
|
||||
if not render_pipeline or not base:
|
||||
return None
|
||||
|
||||
if isinstance(render_pipeline.tag_mgr, EditorTagStateManager):
|
||||
return render_pipeline.tag_mgr
|
||||
|
||||
try:
|
||||
render_pipeline.tag_mgr.cleanup_states()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tag_mgr = EditorTagStateManager(base.cam)
|
||||
|
||||
for stage in render_pipeline.stage_mgr.stages:
|
||||
stage_name = type(stage).__name__
|
||||
|
||||
if hasattr(stage, "forward_cam"):
|
||||
tag_mgr.register_camera("forward", stage.forward_cam)
|
||||
|
||||
if stage_name == "EnvironmentCaptureStage" and hasattr(stage, "cameras"):
|
||||
for camera_np in stage.cameras:
|
||||
tag_mgr.register_camera("envmap", camera_np.node())
|
||||
|
||||
if hasattr(stage, "voxel_cam"):
|
||||
tag_mgr.register_camera("voxelize", stage.voxel_cam)
|
||||
|
||||
if stage_name in (
|
||||
"PSSMDistShadowStage",
|
||||
"PSSMSceneShadowStage",
|
||||
"SkyAOCaptureStage",
|
||||
) and hasattr(stage, "camera"):
|
||||
tag_mgr.register_camera("shadow", stage.camera)
|
||||
|
||||
pssm_plugin = render_pipeline.plugin_mgr.instances.get("pssm")
|
||||
if pssm_plugin and hasattr(pssm_plugin, "camera_rig"):
|
||||
split_count = pssm_plugin.get_setting("split_count")
|
||||
for index in range(split_count):
|
||||
camera_np = pssm_plugin.camera_rig.get_camera(index)
|
||||
tag_mgr.register_camera("shadow", camera_np.node())
|
||||
|
||||
render_pipeline.tag_mgr = tag_mgr
|
||||
return tag_mgr
|
||||
@ -14,15 +14,23 @@ import time
|
||||
|
||||
class ResourceManager:
|
||||
"""ImGui资源管理器类"""
|
||||
|
||||
DEFAULT_ASSET_SUBDIRS = (
|
||||
"Models",
|
||||
"Textures",
|
||||
"Audio",
|
||||
"Video",
|
||||
"UI",
|
||||
"Scripts",
|
||||
)
|
||||
|
||||
def __init__(self, world):
|
||||
self.world = world
|
||||
self.project_root = Path(__file__).resolve().parent.parent
|
||||
self.project_path: Optional[Path] = None
|
||||
|
||||
# 当前浏览路径,默认从Resources目录开始
|
||||
self.current_path = self.project_root / "Resources"
|
||||
if not self.current_path.exists():
|
||||
self.current_path = self.project_root
|
||||
# 当前浏览路径,默认从统一的 Assets 结构开始
|
||||
self.current_path = self._get_default_root_path()
|
||||
|
||||
# 历史记录,用于前进后退导航
|
||||
self.navigation_history: List[Path] = [self.current_path]
|
||||
@ -55,6 +63,45 @@ class ResourceManager:
|
||||
|
||||
# 文件图标映射(Unicode Emoji)
|
||||
self._init_icon_map()
|
||||
|
||||
def _get_default_root_path(self) -> Path:
|
||||
assets_root = None
|
||||
project_manager = getattr(self.world, "project_manager", None)
|
||||
current_project_path = getattr(project_manager, "current_project_path", None)
|
||||
if current_project_path:
|
||||
assets_root = Path(current_project_path) / "Assets"
|
||||
elif self.project_path:
|
||||
assets_root = self.project_path / "Assets"
|
||||
else:
|
||||
assets_root = self.project_root / "Assets"
|
||||
|
||||
if assets_root:
|
||||
self._ensure_assets_structure(assets_root)
|
||||
if assets_root.exists():
|
||||
return assets_root.resolve()
|
||||
|
||||
legacy_resources = self.project_root / "Resources"
|
||||
if legacy_resources.exists():
|
||||
return legacy_resources.resolve()
|
||||
return self.project_root.resolve()
|
||||
|
||||
def _ensure_assets_structure(self, assets_root: Path):
|
||||
"""确保资源浏览器默认使用统一的 Assets 目录结构。"""
|
||||
try:
|
||||
assets_root.mkdir(parents=True, exist_ok=True)
|
||||
for subdir in self.DEFAULT_ASSET_SUBDIRS:
|
||||
(assets_root / subdir).mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
# 目录创建失败时,保留后续兼容回退逻辑。
|
||||
pass
|
||||
|
||||
def set_project_path(self, project_path: str):
|
||||
self.project_path = Path(project_path).resolve() if project_path else None
|
||||
self.current_path = self._get_default_root_path()
|
||||
self.navigation_history = [self.current_path]
|
||||
self.history_index = 0
|
||||
self.selected_files.clear()
|
||||
self.focused_file = None
|
||||
|
||||
def _init_icon_map(self):
|
||||
"""初始化文件图标映射(使用PNG图标文件)"""
|
||||
@ -389,6 +436,12 @@ class ResourceManager:
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
if src.suffix.lower() == '.fbx':
|
||||
fbm_src = src.with_name(src.stem + '.fbm')
|
||||
if fbm_src.exists() and fbm_src.is_dir():
|
||||
fbm_dst = destination_root / fbm_src.name
|
||||
if not fbm_dst.exists():
|
||||
shutil.copytree(fbm_src, fbm_dst)
|
||||
imported.append(dst)
|
||||
except Exception as e:
|
||||
errors.append(f"导入失败 {src}: {e}")
|
||||
|
||||
@ -72,7 +72,7 @@ class ScriptBase(ABC):
|
||||
class ScriptComponent:
|
||||
"""脚本组件 - 挂载到游戏对象上的脚本实例"""
|
||||
|
||||
def __init__(self, script_instance: ScriptBase, game_object, script_manager):
|
||||
def __init__(self, script_instance: ScriptBase, game_object, script_manager, script_key: Optional[str] = None):
|
||||
self.script_instance = script_instance
|
||||
self.game_object = game_object
|
||||
self.script_manager = script_manager
|
||||
@ -80,6 +80,7 @@ class ScriptComponent:
|
||||
|
||||
# 保存脚本名称,便于UI显示
|
||||
self.script_name = script_instance.__class__.__name__
|
||||
self.script_key = script_key or self.script_name
|
||||
|
||||
# 设置脚本实例的引用
|
||||
script_instance.gameObject = game_object
|
||||
@ -226,7 +227,7 @@ class ScriptLoader:
|
||||
break
|
||||
|
||||
if script_class is None:
|
||||
print(f"脚本文件中没有找到继承自ScriptBase的类: {script_path}")
|
||||
#print(f"脚本文件中没有找到继承自ScriptBase的类: {script_path}")
|
||||
return None
|
||||
|
||||
# 保存模块和类信息
|
||||
@ -234,7 +235,7 @@ class ScriptLoader:
|
||||
self.script_classes[script_name] = script_class
|
||||
self.file_mtimes[script_path] = os.path.getmtime(script_path)
|
||||
|
||||
print(f"✓ 成功加载脚本: {script_name} 从 {script_path}")
|
||||
#print(f"✓ 成功加载脚本: {script_name} 从 {script_path}")
|
||||
return script_class
|
||||
|
||||
except Exception as e:
|
||||
@ -248,7 +249,7 @@ class ScriptLoader:
|
||||
# 移除所有使用此脚本的组件
|
||||
components_to_remove = []
|
||||
for component in self.script_manager.engine.script_components:
|
||||
if component.script_instance.__class__.__name__ == script_name:
|
||||
if self.script_manager._script_matches(component, script_name):
|
||||
components_to_remove.append(component)
|
||||
|
||||
for component in components_to_remove:
|
||||
@ -265,6 +266,24 @@ class ScriptLoader:
|
||||
del self.script_classes[script_name]
|
||||
|
||||
print(f"✓ 脚本已卸载: {script_name}")
|
||||
|
||||
def clear(self, unload_components: bool = False):
|
||||
"""清空当前加载的脚本缓存。"""
|
||||
if unload_components:
|
||||
for script_name in list(self.loaded_modules.keys()):
|
||||
try:
|
||||
self.unload_script(script_name)
|
||||
except Exception as e:
|
||||
print(f"卸载脚本失败 {script_name}: {e}")
|
||||
else:
|
||||
for module in list(self.loaded_modules.values()):
|
||||
module_name = getattr(module, "__name__", "")
|
||||
if module_name and module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
self.loaded_modules.clear()
|
||||
self.script_classes.clear()
|
||||
self.file_mtimes.clear()
|
||||
|
||||
def reload_script(self, script_path: str) -> Optional[type]:
|
||||
"""重新加载脚本(热重载)"""
|
||||
@ -312,7 +331,7 @@ class ScriptLoader:
|
||||
scripts_dir = self.script_manager.scripts_directory
|
||||
if os.path.exists(scripts_dir):
|
||||
for file_name in os.listdir(scripts_dir):
|
||||
if file_name.endswith('.py'):
|
||||
if file_name.endswith(('.py', '.pyc')):
|
||||
base_name = os.path.splitext(file_name)[0]
|
||||
if base_name == script_name:
|
||||
return os.path.join(scripts_dir, file_name)
|
||||
@ -417,7 +436,7 @@ class ScriptManager:
|
||||
self.script_templates: Dict[str, type] = {} # 脚本名 -> 脚本类
|
||||
|
||||
# 脚本目录
|
||||
self.scripts_directory = "scripts"
|
||||
self.scripts_directory = self._normalize_scripts_directory("scripts")
|
||||
self._ensure_scripts_directory()
|
||||
|
||||
# 热重载监控
|
||||
@ -425,6 +444,159 @@ class ScriptManager:
|
||||
self.hot_reload_task = None
|
||||
|
||||
print("✓ 脚本管理系统初始化完成")
|
||||
|
||||
def _normalize_scripts_directory(self, directory: str) -> str:
|
||||
directory = directory or "scripts"
|
||||
return os.path.normpath(os.path.abspath(directory))
|
||||
|
||||
def get_project_path(self) -> Optional[str]:
|
||||
project_manager = getattr(self.world, "project_manager", None)
|
||||
project_path = getattr(project_manager, "current_project_path", None)
|
||||
if project_path:
|
||||
return os.path.normpath(project_path)
|
||||
|
||||
project_path = getattr(self.world, "project_path", None)
|
||||
if project_path:
|
||||
return os.path.normpath(project_path)
|
||||
|
||||
return None
|
||||
|
||||
def get_project_scripts_directory(self, project_path: Optional[str] = None) -> Optional[str]:
|
||||
project_path = project_path or self.get_project_path()
|
||||
if not project_path:
|
||||
return None
|
||||
return os.path.normpath(os.path.join(project_path, "Assets", "Scripts"))
|
||||
|
||||
def get_script_relative_path(self, script_path: str) -> str:
|
||||
if not script_path:
|
||||
return ""
|
||||
|
||||
script_path = os.path.normpath(os.path.abspath(script_path))
|
||||
project_path = self.get_project_path()
|
||||
if not project_path:
|
||||
return ""
|
||||
|
||||
try:
|
||||
relative_path = os.path.relpath(script_path, project_path)
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
if relative_path.startswith(".."):
|
||||
return ""
|
||||
|
||||
return relative_path.replace("\\", "/")
|
||||
|
||||
def resolve_script_path(self, script_info: Dict[str, Any]) -> str:
|
||||
if not isinstance(script_info, dict):
|
||||
return ""
|
||||
|
||||
project_path = self.get_project_path()
|
||||
candidates = []
|
||||
script_guid = str(script_info.get("script_guid", "") or "").strip()
|
||||
|
||||
if script_guid:
|
||||
try:
|
||||
project_manager = getattr(self.world, "project_manager", None)
|
||||
asset_database = project_manager.get_asset_database() if project_manager and hasattr(project_manager, "get_asset_database") else None
|
||||
asset_record = asset_database.get_asset(script_guid) if asset_database else {}
|
||||
asset_path = str(asset_record.get("asset_path", "") or "")
|
||||
if asset_path and project_path:
|
||||
asset_abs_path = os.path.normpath(os.path.join(project_path, asset_path.replace("/", os.sep)))
|
||||
candidates.append(asset_abs_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for key in ("project_relative_path", "relative_path", "path", "file"):
|
||||
raw_value = str(script_info.get(key, "") or "").strip()
|
||||
if not raw_value:
|
||||
continue
|
||||
|
||||
normalized_value = raw_value.replace("/", os.sep)
|
||||
if os.path.isabs(normalized_value):
|
||||
candidates.append(os.path.normpath(normalized_value))
|
||||
continue
|
||||
|
||||
if project_path:
|
||||
candidates.append(os.path.normpath(os.path.join(project_path, normalized_value)))
|
||||
|
||||
candidates.append(os.path.normpath(os.path.join(self.scripts_directory, normalized_value)))
|
||||
|
||||
lower_value = normalized_value.lower()
|
||||
if not lower_value.endswith((".py", ".pyc")):
|
||||
candidates.append(os.path.normpath(os.path.join(self.scripts_directory, f"{normalized_value}.py")))
|
||||
candidates.append(os.path.normpath(os.path.join(self.scripts_directory, f"{normalized_value}.pyc")))
|
||||
elif lower_value.endswith(".py"):
|
||||
candidates.append(os.path.normpath(os.path.join(self.scripts_directory, f"{os.path.splitext(normalized_value)[0]}.pyc")))
|
||||
elif lower_value.endswith(".pyc"):
|
||||
candidates.append(os.path.normpath(os.path.join(self.scripts_directory, f"{os.path.splitext(normalized_value)[0]}.py")))
|
||||
|
||||
script_name = str(script_info.get("name", "") or "").strip()
|
||||
if script_name:
|
||||
candidates.append(os.path.normpath(os.path.join(self.scripts_directory, f"{script_name}.py")))
|
||||
candidates.append(os.path.normpath(os.path.join(self.scripts_directory, f"{script_name}.pyc")))
|
||||
|
||||
seen = set()
|
||||
for candidate in candidates:
|
||||
if not candidate or candidate in seen:
|
||||
continue
|
||||
seen.add(candidate)
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
|
||||
if script_name:
|
||||
return self.loader.find_script_file(script_name) or ""
|
||||
|
||||
return ""
|
||||
|
||||
def build_script_reference(self, script_name: str, script_file: str = "") -> Dict[str, Any]:
|
||||
reference = {"name": script_name}
|
||||
|
||||
resolved_script_path = script_file or self.loader.find_script_file(script_name) or ""
|
||||
if resolved_script_path:
|
||||
resolved_script_path = os.path.normpath(os.path.abspath(resolved_script_path))
|
||||
relative_path = self.get_script_relative_path(resolved_script_path)
|
||||
if relative_path:
|
||||
reference["project_relative_path"] = relative_path
|
||||
reference["file"] = relative_path
|
||||
else:
|
||||
reference["file"] = resolved_script_path
|
||||
|
||||
try:
|
||||
project_manager = getattr(self.world, "project_manager", None)
|
||||
if project_manager and hasattr(project_manager, "register_project_asset"):
|
||||
asset_record = project_manager.register_project_asset(resolved_script_path)
|
||||
if asset_record and asset_record.get("guid"):
|
||||
reference["script_guid"] = asset_record["guid"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return reference
|
||||
|
||||
def set_scripts_directory(
|
||||
self,
|
||||
directory: str,
|
||||
*,
|
||||
create: bool = True,
|
||||
reload_scripts: bool = True,
|
||||
) -> str:
|
||||
normalized_directory = self._normalize_scripts_directory(directory)
|
||||
if normalized_directory == self.scripts_directory and (
|
||||
os.path.exists(normalized_directory) or not create
|
||||
):
|
||||
return self.scripts_directory
|
||||
|
||||
self.scripts_directory = normalized_directory
|
||||
if create:
|
||||
self._ensure_scripts_directory()
|
||||
|
||||
self.loader.clear(unload_components=False)
|
||||
self.script_templates.clear()
|
||||
|
||||
if reload_scripts and os.path.exists(self.scripts_directory):
|
||||
self.load_all_scripts_from_directory()
|
||||
|
||||
print(f"✓ 当前脚本目录已切换到: {self.scripts_directory}")
|
||||
return self.scripts_directory
|
||||
|
||||
def _ensure_scripts_directory(self):
|
||||
"""确保脚本目录存在"""
|
||||
@ -510,6 +682,19 @@ class ExampleScript(ScriptBase):
|
||||
self.engine.stop_engine()
|
||||
self.stop_hot_reload()
|
||||
print("✓ 脚本系统已停止")
|
||||
|
||||
def reset_scene_state(self):
|
||||
"""Clear all mounted script components before loading/replacing a scene."""
|
||||
try:
|
||||
for component in list(self.engine.script_components):
|
||||
try:
|
||||
self.engine.remove_script_component(component)
|
||||
except Exception as e:
|
||||
print(f"移除脚本组件失败: {e}")
|
||||
self.object_scripts.clear()
|
||||
print("✓ 脚本场景状态已清空")
|
||||
except Exception as e:
|
||||
print(f"清空脚本场景状态失败: {e}")
|
||||
|
||||
def start_hot_reload(self):
|
||||
"""启动热重载监控"""
|
||||
@ -534,7 +719,11 @@ class ExampleScript(ScriptBase):
|
||||
|
||||
def create_script_file(self, script_name: str, template: str = "basic") -> str:
|
||||
"""创建新的脚本文件"""
|
||||
script_path = os.path.join(self.scripts_directory, f"{script_name}.py")
|
||||
script_base_name = os.path.splitext(script_name.strip())[0]
|
||||
script_path = os.path.join(self.scripts_directory, f"{script_base_name}.py")
|
||||
|
||||
if not os.path.exists(self.scripts_directory):
|
||||
os.makedirs(self.scripts_directory)
|
||||
|
||||
if os.path.exists(script_path):
|
||||
print(f"脚本文件已存在: {script_path}")
|
||||
@ -542,21 +731,33 @@ class ExampleScript(ScriptBase):
|
||||
|
||||
# 根据模板创建脚本
|
||||
if template == "basic":
|
||||
script_content = self._get_basic_script_template(script_name)
|
||||
script_content = self._get_basic_script_template(script_base_name)
|
||||
elif template == "movement":
|
||||
script_content = self._get_movement_script_template(script_name)
|
||||
script_content = self._get_movement_script_template(script_base_name)
|
||||
else:
|
||||
script_content = self._get_basic_script_template(script_name)
|
||||
script_content = self._get_basic_script_template(script_base_name)
|
||||
|
||||
with open(script_path, 'w', encoding='utf-8') as f:
|
||||
f.write(script_content)
|
||||
|
||||
print(f"✓ 创建脚本文件: {script_path}")
|
||||
return script_path
|
||||
|
||||
def _build_script_class_name(self, script_name: str) -> str:
|
||||
normalized_parts = []
|
||||
for raw_part in script_name.replace('-', '_').split('_'):
|
||||
part = ''.join(ch for ch in raw_part if ch.isalnum())
|
||||
if part:
|
||||
normalized_parts.append(part.capitalize())
|
||||
|
||||
class_name = ''.join(normalized_parts) or "GeneratedScript"
|
||||
if class_name[0].isdigit():
|
||||
class_name = f"Script{class_name}"
|
||||
return class_name
|
||||
|
||||
def _get_basic_script_template(self, script_name: str) -> str:
|
||||
"""获取基础脚本模板"""
|
||||
class_name = ''.join(word.capitalize() for word in script_name.split('_'))
|
||||
class_name = self._build_script_class_name(script_name)
|
||||
|
||||
return f'''#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
@ -590,7 +791,7 @@ class {class_name}(ScriptBase):
|
||||
|
||||
def _get_movement_script_template(self, script_name: str) -> str:
|
||||
"""获取移动脚本模板"""
|
||||
class_name = ''.join(word.capitalize() for word in script_name.split('_'))
|
||||
class_name = self._build_script_class_name(script_name)
|
||||
|
||||
return f'''#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
@ -643,19 +844,32 @@ class {class_name}(ScriptBase):
|
||||
"""从目录加载所有脚本"""
|
||||
if directory is None:
|
||||
directory = self.scripts_directory
|
||||
else:
|
||||
directory = self._normalize_scripts_directory(directory)
|
||||
|
||||
if not os.path.exists(directory):
|
||||
print(f"脚本目录不存在: {directory}")
|
||||
return []
|
||||
|
||||
loaded_scripts = []
|
||||
for filename in os.listdir(directory):
|
||||
if filename.endswith('.py') and not filename.startswith('__'):
|
||||
script_path = os.path.join(directory, filename)
|
||||
script_class = self.load_script_from_file(script_path)
|
||||
if script_class:
|
||||
script_name = os.path.splitext(filename)[0]
|
||||
loaded_scripts.append(script_name)
|
||||
seen_script_names = set()
|
||||
for filename in sorted(os.listdir(directory)):
|
||||
if filename.startswith('__') or not filename.endswith(('.py', '.pyc')):
|
||||
continue
|
||||
|
||||
script_name = os.path.splitext(filename)[0]
|
||||
if script_name in seen_script_names:
|
||||
continue
|
||||
|
||||
preferred_path = os.path.join(directory, f"{script_name}.py")
|
||||
script_path = preferred_path if os.path.exists(preferred_path) else os.path.join(directory, filename)
|
||||
if not os.path.exists(script_path):
|
||||
continue
|
||||
|
||||
script_class = self.load_script_from_file(script_path)
|
||||
if script_class:
|
||||
loaded_scripts.append(script_name)
|
||||
seen_script_names.add(script_name)
|
||||
|
||||
print(f"✓ 从目录 {directory} 加载了 {len(loaded_scripts)} 个脚本")
|
||||
return loaded_scripts
|
||||
@ -675,7 +889,7 @@ class {class_name}(ScriptBase):
|
||||
script_instance = script_class()
|
||||
|
||||
# 创建脚本组件
|
||||
script_component = ScriptComponent(script_instance, game_object, self)
|
||||
script_component = ScriptComponent(script_instance, game_object, self, script_key=script_name)
|
||||
|
||||
# 添加到对象的脚本列表
|
||||
if game_object not in self.object_scripts:
|
||||
@ -702,7 +916,7 @@ class {class_name}(ScriptBase):
|
||||
removed = False
|
||||
|
||||
for component in script_components[:]: # 复制列表以避免修改时出错
|
||||
if component.script_instance.__class__.__name__ == script_name:
|
||||
if self._script_matches(component, script_name):
|
||||
# 从引擎移除
|
||||
self.engine.remove_script_component(component)
|
||||
# 从对象脚本列表移除
|
||||
@ -721,6 +935,13 @@ class {class_name}(ScriptBase):
|
||||
|
||||
return removed
|
||||
|
||||
def _script_matches(self, component: ScriptComponent, script_identifier: str) -> bool:
|
||||
return script_identifier in {
|
||||
getattr(component, "script_key", None),
|
||||
getattr(component, "script_name", None),
|
||||
component.script_instance.__class__.__name__,
|
||||
}
|
||||
|
||||
def _update_node_script_tags_after_removal(self, game_object, removed_script_name):
|
||||
"""在移除脚本后更新节点标签"""
|
||||
try:
|
||||
@ -739,13 +960,8 @@ class {class_name}(ScriptBase):
|
||||
script_info_list = []
|
||||
for script_component in remaining_scripts:
|
||||
script_name = script_component.script_name
|
||||
script_class = script_component.script_instance.__class__
|
||||
script_file = self.loader.find_script_file(script_name) or ""
|
||||
|
||||
script_info_list.append({
|
||||
"name": script_name,
|
||||
"file": script_file
|
||||
})
|
||||
script_info_list.append(self.build_script_reference(script_name, script_file))
|
||||
|
||||
import json
|
||||
game_object.setTag("has_scripts", "true")
|
||||
@ -763,7 +979,7 @@ class {class_name}(ScriptBase):
|
||||
"""获取对象上的特定脚本"""
|
||||
scripts = self.get_scripts_on_object(game_object)
|
||||
for script in scripts:
|
||||
if script.script_instance.__class__.__name__ == script_name:
|
||||
if self._script_matches(script, script_name):
|
||||
return script
|
||||
return None
|
||||
|
||||
@ -783,7 +999,7 @@ class {class_name}(ScriptBase):
|
||||
"name": script_name,
|
||||
"class": script_class,
|
||||
"doc": script_class.__doc__,
|
||||
"file": inspect.getfile(script_class) if hasattr(script_class, '__file__') else None,
|
||||
"file": self.loader.find_script_file(script_name) or inspect.getsourcefile(script_class),
|
||||
"methods": [method for method in dir(script_class) if not method.startswith('_')]
|
||||
}
|
||||
|
||||
@ -793,6 +1009,18 @@ class {class_name}(ScriptBase):
|
||||
if script_info and script_info["file"]:
|
||||
return self.loader.reload_script(script_info["file"]) is not None
|
||||
return False
|
||||
|
||||
def set_hot_reload_enabled(self, enabled: bool):
|
||||
"""切换热重载并同步后台监控任务。"""
|
||||
enabled = bool(enabled)
|
||||
if self.hot_reload_enabled == enabled:
|
||||
return
|
||||
|
||||
self.hot_reload_enabled = enabled
|
||||
if enabled:
|
||||
self.start_hot_reload()
|
||||
else:
|
||||
self.stop_hot_reload()
|
||||
|
||||
# ==================== 调试功能 ====================
|
||||
|
||||
@ -858,4 +1086,4 @@ def get_script_api():
|
||||
__all__ = [
|
||||
'ScriptBase', 'ScriptComponent', 'ScriptEngine',
|
||||
'ScriptLoader', 'ScriptAPI', 'ScriptManager'
|
||||
]
|
||||
]
|
||||
|
||||
1441
core/selection.py
303
core/selection_outline.py
Normal file
@ -0,0 +1,303 @@
|
||||
from direct.task.TaskManagerGlobal import taskMgr
|
||||
from panda3d.core import (
|
||||
BitMask32,
|
||||
Camera,
|
||||
FrameBufferProperties,
|
||||
GraphicsOutput,
|
||||
GraphicsPipe,
|
||||
NodePath,
|
||||
Shader,
|
||||
Texture,
|
||||
Vec4,
|
||||
WindowProperties,
|
||||
)
|
||||
|
||||
|
||||
class SelectionOutlineManager:
|
||||
"""Selection mask manager feeding RenderPipeline SelectionOutlineStage."""
|
||||
|
||||
OUTLINE_PREFIX = "selectionOutline"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
enabled=True,
|
||||
outline_color=Vec4(1.0, 0.55, 0.0, 1.0),
|
||||
outline_width_px=2.0,
|
||||
fill_alpha=0.0,
|
||||
max_targets=128,
|
||||
):
|
||||
self.app = app
|
||||
self.enabled = bool(enabled)
|
||||
self.outline_color = Vec4(outline_color)
|
||||
self.outline_width_px = max(0.0, float(outline_width_px))
|
||||
self.fill_alpha = max(0.0, min(1.0, float(fill_alpha)))
|
||||
self.max_targets = max(1, int(max_targets))
|
||||
|
||||
self._task_name = "selection_outline_mask_sync"
|
||||
self._tracked = [] # [(source_np, clone_np), ...]
|
||||
self._stage_missing_warned = False
|
||||
|
||||
self._mask_root = NodePath(f"{self.OUTLINE_PREFIX}_mask_root")
|
||||
self._mask_buffer = None
|
||||
self._mask_texture = None
|
||||
self._mask_cam = None
|
||||
self._mask_cam_np = None
|
||||
self._mask_shader = self._build_mask_shader()
|
||||
self._buffer_size = (0, 0)
|
||||
|
||||
@staticmethod
|
||||
def _is_empty(np):
|
||||
if not np:
|
||||
return True
|
||||
if hasattr(np, "isEmpty"):
|
||||
return np.isEmpty()
|
||||
if hasattr(np, "is_empty"):
|
||||
return np.is_empty()
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def is_outline_node(cls, node_path):
|
||||
if not node_path or cls._is_empty(node_path):
|
||||
return False
|
||||
name = node_path.getName() if hasattr(node_path, "getName") else ""
|
||||
if name.startswith(cls.OUTLINE_PREFIX):
|
||||
return True
|
||||
try:
|
||||
if node_path.hasPythonTag("selection_outline"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def set_enabled(self, enabled):
|
||||
self.enabled = bool(enabled)
|
||||
if not self.enabled:
|
||||
self.clear()
|
||||
self._apply_stage_inputs()
|
||||
|
||||
def set_targets(self, targets):
|
||||
if not self.enabled:
|
||||
self.clear()
|
||||
self._apply_stage_inputs()
|
||||
return
|
||||
|
||||
self._ensure_mask_resources()
|
||||
self.clear()
|
||||
|
||||
if not targets:
|
||||
self._apply_stage_inputs()
|
||||
return
|
||||
|
||||
seen = set()
|
||||
valid = []
|
||||
for target in targets:
|
||||
if self._is_empty(target) or self.is_outline_node(target):
|
||||
continue
|
||||
key = str(target)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
valid.append(target)
|
||||
if len(valid) >= self.max_targets:
|
||||
break
|
||||
|
||||
for source in valid:
|
||||
self._clone_target(source)
|
||||
|
||||
if self._tracked:
|
||||
self._start_sync_task()
|
||||
self._sync_once()
|
||||
print(f"[SelectionOutline] targets={len(self._tracked)} active")
|
||||
else:
|
||||
print("[SelectionOutline] no valid targets for outline")
|
||||
|
||||
self._apply_stage_inputs()
|
||||
|
||||
def clear(self):
|
||||
self._stop_sync_task()
|
||||
for _, clone_np in self._tracked:
|
||||
if not self._is_empty(clone_np):
|
||||
clone_np.removeNode()
|
||||
self._tracked = []
|
||||
self._apply_stage_inputs()
|
||||
|
||||
def cleanup(self):
|
||||
self.clear()
|
||||
self._destroy_mask_resources()
|
||||
|
||||
def _build_mask_shader(self):
|
||||
return Shader.make(
|
||||
Shader.SL_GLSL,
|
||||
"""
|
||||
#version 430
|
||||
in vec4 p3d_Vertex;
|
||||
uniform mat4 p3d_ModelViewProjectionMatrix;
|
||||
void main() {
|
||||
gl_Position = p3d_ModelViewProjectionMatrix * p3d_Vertex;
|
||||
}
|
||||
""",
|
||||
"""
|
||||
#version 430
|
||||
out vec4 result;
|
||||
void main() {
|
||||
result = vec4(1.0, 1.0, 1.0, 1.0);
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
def _clone_target(self, source):
|
||||
try:
|
||||
clone_np = source.copyTo(self._mask_root)
|
||||
if self._is_empty(clone_np):
|
||||
return
|
||||
if not self._node_has_geom(clone_np):
|
||||
clone_np.removeNode()
|
||||
return
|
||||
clone_np.setName(f"{self.OUTLINE_PREFIX}_{source.getName()}")
|
||||
clone_np.setPythonTag("selection_outline", True)
|
||||
clone_np.setCollideMask(BitMask32.allOff())
|
||||
clone_np.setMat(self.app.render, source.getMat(self.app.render))
|
||||
self._tracked.append((source, clone_np))
|
||||
except Exception as exc:
|
||||
print(f"[SelectionOutline] clone failed: {exc}")
|
||||
|
||||
def _node_has_geom(self, np):
|
||||
if self._is_empty(np):
|
||||
return False
|
||||
try:
|
||||
node = np.node()
|
||||
if node and hasattr(node, "isGeomNode") and node.isGeomNode():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return not np.find("**/+GeomNode").isEmpty()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _start_sync_task(self):
|
||||
taskMgr.remove(self._task_name)
|
||||
taskMgr.add(self._sync_task, self._task_name)
|
||||
|
||||
def _stop_sync_task(self):
|
||||
taskMgr.remove(self._task_name)
|
||||
|
||||
def _sync_task(self, task):
|
||||
self._sync_once()
|
||||
return task.cont
|
||||
|
||||
def _sync_once(self):
|
||||
if not self.enabled:
|
||||
self._apply_stage_inputs()
|
||||
return
|
||||
|
||||
self._ensure_mask_resources()
|
||||
alive = []
|
||||
for source, clone_np in self._tracked:
|
||||
if self._is_empty(source) or self._is_empty(clone_np):
|
||||
if not self._is_empty(clone_np):
|
||||
clone_np.removeNode()
|
||||
continue
|
||||
clone_np.setMat(self.app.render, source.getMat(self.app.render))
|
||||
alive.append((source, clone_np))
|
||||
self._tracked = alive
|
||||
self._apply_stage_inputs()
|
||||
|
||||
def _get_stage(self):
|
||||
rp = getattr(self.app, "render_pipeline", None)
|
||||
if not rp or not getattr(rp, "stage_mgr", None):
|
||||
return None
|
||||
return rp.stage_mgr.get_stage("SelectionOutlineStage")
|
||||
|
||||
def _apply_stage_inputs(self):
|
||||
stage = self._get_stage()
|
||||
if not stage:
|
||||
if not self._stage_missing_warned:
|
||||
print("[SelectionOutline] SelectionOutlineStage not found; plugin may be disabled.")
|
||||
self._stage_missing_warned = True
|
||||
return
|
||||
|
||||
self._stage_missing_warned = False
|
||||
stage.set_outline_style(
|
||||
color=self.outline_color,
|
||||
width_px=self.outline_width_px,
|
||||
fill_alpha=self.fill_alpha,
|
||||
)
|
||||
stage.set_mask_texture(self._mask_texture)
|
||||
stage.set_enabled_outline(self.enabled and bool(self._tracked))
|
||||
|
||||
def _get_window_size(self):
|
||||
if not getattr(self.app, "win", None):
|
||||
return 1, 1
|
||||
return max(1, self.app.win.getXSize()), max(1, self.app.win.getYSize())
|
||||
|
||||
def _ensure_mask_resources(self):
|
||||
size = self._get_window_size()
|
||||
if size != self._buffer_size:
|
||||
self._destroy_mask_resources()
|
||||
self._buffer_size = size
|
||||
if self._mask_buffer:
|
||||
return
|
||||
|
||||
if not getattr(self.app, "graphicsEngine", None) or not getattr(self.app, "win", None):
|
||||
return
|
||||
|
||||
w, h = self._buffer_size
|
||||
win_props = WindowProperties()
|
||||
win_props.setSize(w, h)
|
||||
fb_props = FrameBufferProperties()
|
||||
fb_props.setRgbaBits(8, 8, 8, 8)
|
||||
fb_props.setDepthBits(24)
|
||||
|
||||
self._mask_buffer = self.app.graphicsEngine.make_output(
|
||||
self.app.pipe,
|
||||
"selection_outline_mask",
|
||||
-80,
|
||||
fb_props,
|
||||
win_props,
|
||||
GraphicsPipe.BFRefuseWindow,
|
||||
self.app.win.getGsg(),
|
||||
self.app.win,
|
||||
)
|
||||
if not self._mask_buffer:
|
||||
print("[SelectionOutline] failed to create mask buffer")
|
||||
return
|
||||
|
||||
self._mask_texture = Texture("selection_outline_mask_tex")
|
||||
self._mask_texture.setMinfilter(Texture.FTNearest)
|
||||
self._mask_texture.setMagfilter(Texture.FTNearest)
|
||||
self._mask_buffer.addRenderTexture(self._mask_texture, GraphicsOutput.RTMBindOrCopy)
|
||||
self._mask_buffer.setClearColor(Vec4(0, 0, 0, 0))
|
||||
self._mask_buffer.setClearColorActive(True)
|
||||
self._mask_buffer.setActive(True)
|
||||
|
||||
self._mask_cam = Camera("selection_outline_mask_camera")
|
||||
self._mask_cam.setScene(self._mask_root)
|
||||
self._mask_cam.setLens(self.app.camLens)
|
||||
self._mask_cam_np = self.app.cam.attachNewNode(self._mask_cam)
|
||||
|
||||
dr = self._mask_buffer.makeDisplayRegion()
|
||||
dr.setCamera(self._mask_cam_np)
|
||||
|
||||
state_np = NodePath("selection_outline_mask_state")
|
||||
state_np.setShader(self._mask_shader, 10000)
|
||||
state_np.setLightOff(1)
|
||||
state_np.setMaterialOff(1)
|
||||
state_np.setTextureOff(1)
|
||||
state_np.setColorOff(1)
|
||||
self._mask_cam.setInitialState(state_np.getState())
|
||||
|
||||
def _destroy_mask_resources(self):
|
||||
if self._mask_cam_np and not self._is_empty(self._mask_cam_np):
|
||||
self._mask_cam_np.removeNode()
|
||||
self._mask_cam_np = None
|
||||
self._mask_cam = None
|
||||
|
||||
if self._mask_buffer and getattr(self.app, "graphicsEngine", None):
|
||||
try:
|
||||
self.app.graphicsEngine.removeWindow(self._mask_buffer)
|
||||
except Exception:
|
||||
pass
|
||||
self._mask_buffer = None
|
||||
self._mask_texture = None
|
||||
@ -3,29 +3,25 @@ import time
|
||||
import urllib
|
||||
|
||||
from panda3d.core import GeoMipTerrain, PNMImage, Texture, Vec3, NodePath
|
||||
from panda3d.core import Filename, Material, ColorAttrib, AmbientLight, DirectionalLight
|
||||
import os
|
||||
|
||||
from scene import util
|
||||
from panda3d.core import Filename, Material, ColorAttrib, AmbientLight, DirectionalLight
|
||||
import os
|
||||
|
||||
from scene import util
|
||||
from core.editor_context import get_editor_context
|
||||
|
||||
|
||||
class TerrainManager:
|
||||
"""地形管理类"""
|
||||
|
||||
def __init__(self, world):
|
||||
self.world = world
|
||||
self.terrains = []
|
||||
def __init__(self, world):
|
||||
self.world = world
|
||||
self._editor_context = get_editor_context(world)
|
||||
self.terrains = []
|
||||
|
||||
# core/terrain_manager.py
|
||||
def _get_tree_widget(self):
|
||||
"""安全获取树形控件"""
|
||||
try:
|
||||
if (hasattr(self.world, 'interface_manager') and
|
||||
hasattr(self.world.interface_manager, 'treeWidget')):
|
||||
return self.world.interface_manager.treeWidget
|
||||
except AttributeError:
|
||||
pass
|
||||
return None
|
||||
def _get_tree_widget(self):
|
||||
"""安全获取树形控件"""
|
||||
return self._editor_context.get_tree_widget()
|
||||
|
||||
def createTerrainFromHeightMap(self, heightmap_path, scale=(1, 1, 1)):
|
||||
"""从高度图创建地形"""
|
||||
@ -133,30 +129,22 @@ class TerrainManager:
|
||||
terrain_node.setPythonTag("selectable", True)
|
||||
|
||||
# 保存地形信息(包括高度图的副本)
|
||||
terrain_info = {
|
||||
'terrain': terrain,
|
||||
'node': terrain_node,
|
||||
'heightmap': heightmap_path,
|
||||
'heightfield': height_image, # 保存高度图副本
|
||||
terrain_info = {
|
||||
'terrain': terrain,
|
||||
'node': terrain_node,
|
||||
'heightmap': heightmap_path,
|
||||
'heightfield': height_image, # 保存高度图副本
|
||||
'scale': scale,
|
||||
'name': node_name
|
||||
}
|
||||
}
|
||||
|
||||
self.terrains.append(terrain_info)
|
||||
terrain_node.setPythonTag("terrain_info", terrain_info)
|
||||
|
||||
parent_name = parent_item.text(0) if parent_item else "root"
|
||||
print(f"✅ 为 {parent_name} 创建高度图地形: {terrain_name}")
|
||||
|
||||
self.terrains.append(terrain_info)
|
||||
|
||||
parent_name = parent_item.text(0) if parent_item else "root"
|
||||
print(f"✅ 为 {parent_name} 创建高度图地形: {terrain_name}")
|
||||
|
||||
# 在Qt树形控件中添加对应节点
|
||||
qt_item = None
|
||||
if tree_widget and parent_item:
|
||||
qt_item = tree_widget.add_node_to_tree_widget(terrain_node, parent_item, "TERRAIN_NODE")
|
||||
|
||||
if qt_item:
|
||||
created_terrains.append((terrain_node, qt_item))
|
||||
else:
|
||||
created_terrains.append((terrain_node, None))
|
||||
print("⚠️ Qt树节点添加跳过或失败,但Panda3D对象已创建")
|
||||
created_terrains.append((terrain_node, None))
|
||||
|
||||
# 添加到场景管理器(ImGui环境使用)
|
||||
if hasattr(self.world, 'scene_manager') and self.world.scene_manager:
|
||||
@ -179,13 +167,8 @@ class TerrainManager:
|
||||
print("❌ 没有成功创建任何高度图地形")
|
||||
return None
|
||||
|
||||
# 选中最后创建的光源
|
||||
if created_terrains:
|
||||
last_light_np, last_qt_item = created_terrains[-1]
|
||||
if last_qt_item:
|
||||
tree_widget.setCurrentItem(last_qt_item)
|
||||
# 更新选择和属性面板
|
||||
tree_widget.update_selection_and_properties(last_light_np, last_qt_item)
|
||||
if created_terrains:
|
||||
pass
|
||||
|
||||
print(f"🎉 总共创建了 {len(created_terrains)} 个高度图地形")
|
||||
|
||||
@ -282,30 +265,22 @@ class TerrainManager:
|
||||
terrain_node.setPythonTag("selectable", True)
|
||||
|
||||
# 保存地形信息(包括高度图)
|
||||
terrain_info = {
|
||||
'terrain': terrain,
|
||||
'node': terrain_node,
|
||||
'heightmap': None,
|
||||
'heightfield': height_image, # 保存高度图
|
||||
terrain_info = {
|
||||
'terrain': terrain,
|
||||
'node': terrain_node,
|
||||
'heightmap': None,
|
||||
'heightfield': height_image, # 保存高度图
|
||||
'scale': (size[0], size[1], 50),
|
||||
'name': node_name
|
||||
}
|
||||
}
|
||||
|
||||
self.terrains.append(terrain_info)
|
||||
terrain_node.setPythonTag("terrain_info", terrain_info)
|
||||
|
||||
parent_name = parent_item.text(0) if parent_item else "root"
|
||||
print(f"✅ 为 {parent_name} 创建平面地形: {terrain_name}")
|
||||
|
||||
self.terrains.append(terrain_info)
|
||||
|
||||
parent_name = parent_item.text(0) if parent_item else "root"
|
||||
print(f"✅ 为 {parent_name} 创建平面地形: {terrain_name}")
|
||||
|
||||
# 在Qt树形控件中添加对应节点
|
||||
qt_item = None
|
||||
if tree_widget and parent_item:
|
||||
qt_item = tree_widget.add_node_to_tree_widget(terrain_node, parent_item, "TERRAIN_NODE")
|
||||
|
||||
if qt_item:
|
||||
created_terrains.append((terrain_node, qt_item))
|
||||
else:
|
||||
created_terrains.append((terrain_node, None))
|
||||
print("⚠️ Qt树节点添加跳过或失败,但Panda3D对象已创建")
|
||||
created_terrains.append((terrain_node, None))
|
||||
|
||||
# 添加到场景管理器(ImGui环境使用)
|
||||
if hasattr(self.world, 'scene_manager') and self.world.scene_manager:
|
||||
@ -328,13 +303,8 @@ class TerrainManager:
|
||||
print("❌ 没有成功创建任何平面地形")
|
||||
return None
|
||||
|
||||
# 选中最后创建的光源
|
||||
if created_terrains:
|
||||
last_light_np, last_qt_item = created_terrains[-1]
|
||||
if last_qt_item:
|
||||
tree_widget.setCurrentItem(last_qt_item)
|
||||
# 更新选择和属性面板
|
||||
tree_widget.update_selection_and_properties(last_light_np, last_qt_item)
|
||||
if created_terrains:
|
||||
pass
|
||||
|
||||
print(f"🎉 总共创建了 {len(created_terrains)} 个平面地形")
|
||||
|
||||
|
||||
@ -9,11 +9,12 @@ class ToolManager:
|
||||
|
||||
def setCurrentTool(self, tool):
|
||||
"""设置当前工具"""
|
||||
self.currentTool = tool
|
||||
|
||||
print(f"\n=== 工具切换 ===")
|
||||
print(f"当前工具: {tool}")
|
||||
print(f"选中节点: {self.world.selection.selectedNode.getName() if self.world.selection.selectedNode else '无'}")
|
||||
self.currentTool = tool
|
||||
|
||||
print(f"\n=== 工具切换 ===")
|
||||
print(f"当前工具: {tool}")
|
||||
selected_node = self.world._get_selection_node() if hasattr(self.world, "_get_selection_node") else self.world.selection.selectedNode
|
||||
print(f"选中节点: {selected_node.getName() if selected_node else '无'}")
|
||||
|
||||
# 根据工具类型启用对应的方法
|
||||
if tool == "选择":
|
||||
|
||||
@ -91,10 +91,10 @@ class VRTestMode:
|
||||
self.vr_manager._disable_main_cam()
|
||||
|
||||
# 设置高帧率用于测试
|
||||
if hasattr(self.vr_manager.world, 'qtWidget') and self.vr_manager.world.qtWidget:
|
||||
if hasattr(self.vr_manager.world.qtWidget, 'synchronizer'):
|
||||
self.vr_manager.world.qtWidget.synchronizer.setInterval(int(1000/144))
|
||||
print("✓ 测试模式:Qt Timer设置为144Hz")
|
||||
host_widget = getattr(self.vr_manager.world, "host_widget", None)
|
||||
if host_widget and hasattr(host_widget, "synchronizer"):
|
||||
host_widget.synchronizer.setInterval(int(1000 / 144))
|
||||
print("✓ 测试模式:渲染同步器设置为144Hz")
|
||||
|
||||
# 初始化测试显示系统
|
||||
if not self._initialize_test_display():
|
||||
|
||||
@ -1893,12 +1893,11 @@ class VRManager(DirectObject):
|
||||
print(" 注意:Submit后立即调用WaitGetPoses是错误实现")
|
||||
self.set_prediction_time(11.0) # 11ms预测时间 - OpenVR标准值,平衡准确性和延迟
|
||||
|
||||
# 🚀 动态调整Qt Timer频率以支持VR
|
||||
if hasattr(self.world, 'qtWidget') and self.world.qtWidget:
|
||||
if hasattr(self.world.qtWidget, 'synchronizer'):
|
||||
# 设置为144Hz,让OpenVR控制实际渲染节奏
|
||||
self.world.qtWidget.synchronizer.setInterval(int(1000/144))
|
||||
print("✓ Qt Timer调整为144Hz,让OpenVR控制VR渲染节奏")
|
||||
# 🚀 动态调整宿主同步器频率以支持VR
|
||||
host_widget = getattr(self.world, "host_widget", None)
|
||||
if host_widget and hasattr(host_widget, "synchronizer"):
|
||||
host_widget.synchronizer.setInterval(int(1000 / 144))
|
||||
print("✓ 渲染同步器调整为144Hz,让OpenVR控制VR渲染节奏")
|
||||
|
||||
# 🔧 关键修复:检测并重建缺失的手柄visualizer
|
||||
# 当渲染模式切换时,visualizer可能被清理但控制器对象仍存在
|
||||
@ -1952,11 +1951,11 @@ class VRManager(DirectObject):
|
||||
# 恢复主相机
|
||||
self._enable_main_cam()
|
||||
|
||||
# 恢复Qt Timer到60FPS
|
||||
if hasattr(self.world, 'qtWidget') and self.world.qtWidget:
|
||||
if hasattr(self.world.qtWidget, 'synchronizer'):
|
||||
self.world.qtWidget.synchronizer.setInterval(int(1000/60))
|
||||
print("✓ Qt Timer恢复为60Hz")
|
||||
# 恢复渲染同步器到60FPS
|
||||
host_widget = getattr(self.world, "host_widget", None)
|
||||
if host_widget and hasattr(host_widget, "synchronizer"):
|
||||
host_widget.synchronizer.setInterval(int(1000 / 60))
|
||||
print("✓ 渲染同步器恢复为60Hz")
|
||||
|
||||
print("✅ VR模式已禁用,手柄模型已隐藏")
|
||||
|
||||
|
||||
198
core/world.py
@ -9,7 +9,8 @@ from direct.actor.Actor import Actor
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
|
||||
from panda3d.core import (CardMaker, Vec4, Vec3, AmbientLight, DirectionalLight,
|
||||
Point3, WindowProperties, Material, LColor, loadPrcFileData)
|
||||
Point3, WindowProperties, Material, LColor, Shader,
|
||||
TransparencyAttrib, loadPrcFileData, GraphicsPipeSelection)
|
||||
from direct.showbase.ShowBase import ShowBase
|
||||
from direct.showbase.ShowBaseGlobal import globalClock
|
||||
from scene.scene_manager import SceneManager
|
||||
@ -24,6 +25,7 @@ from ssbo_component.ssbo_editor import SSBOEditor
|
||||
|
||||
# 从渲染管线工具模块导入全局函数
|
||||
from core.render_pipeline_utils import get_render_pipeline, set_render_pipeline
|
||||
from core.render_pipeline_tag_state import install_editor_tag_state_manager
|
||||
|
||||
# 尝试导入插件管理器(如果存在)
|
||||
try:
|
||||
@ -40,11 +42,16 @@ class CoreWorld(ShowBase):
|
||||
global _global_render_pipeline
|
||||
|
||||
# 初始化基础属性
|
||||
self.qtWidget = None # Qt部件引用(用于获取准确的渲染区域尺寸)
|
||||
self.host_widget = None # 外部宿主窗口引用(用于获取准确渲染区域尺寸)
|
||||
desktop_size = self._get_desktop_window_size()
|
||||
if not is_fullscreen and (width, height) == (1380, 750) and desktop_size:
|
||||
width, height = desktop_size
|
||||
print(f"✓ 使用桌面分辨率启动窗口: {width} x {height}")
|
||||
|
||||
# 设置基本配置
|
||||
loadPrcFileData("", "show-frame-rate-meter 0")
|
||||
loadPrcFileData("", "window-type onscreen")
|
||||
loadPrcFileData("", "window-title MetaCore")
|
||||
loadPrcFileData("", f"win-size {width} {height}")
|
||||
loadPrcFileData("", "win-fixed-size #f") # 允许窗口调整大小
|
||||
|
||||
@ -75,8 +82,17 @@ class CoreWorld(ShowBase):
|
||||
# 初始化 ShowBase
|
||||
ShowBase.__init__(self)
|
||||
|
||||
try:
|
||||
props = WindowProperties()
|
||||
props.setTitle("MetaCore")
|
||||
self.win.requestProperties(props)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 创建渲染管线
|
||||
self.render_pipeline.create(self)
|
||||
install_editor_tag_state_manager(self.render_pipeline, self)
|
||||
self._setupForwardTransparencyOverlay()
|
||||
set_render_pipeline(self.render_pipeline)
|
||||
|
||||
# 设置相机
|
||||
@ -112,6 +128,50 @@ class CoreWorld(ShowBase):
|
||||
self._setupGround()
|
||||
self._loadFont()
|
||||
|
||||
@staticmethod
|
||||
def _get_desktop_window_size():
|
||||
"""Query the current desktop display mode for a better default window size."""
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class RECT(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("left", wintypes.LONG),
|
||||
("top", wintypes.LONG),
|
||||
("right", wintypes.LONG),
|
||||
("bottom", wintypes.LONG),
|
||||
]
|
||||
|
||||
work_area = RECT()
|
||||
spi_get_work_area = 0x0030
|
||||
if ctypes.windll.user32.SystemParametersInfoW(
|
||||
spi_get_work_area, 0, ctypes.byref(work_area), 0
|
||||
):
|
||||
width = int(work_area.right - work_area.left)
|
||||
height = int(work_area.bottom - work_area.top)
|
||||
if width > 0 and height > 0:
|
||||
return width, height
|
||||
except Exception as e:
|
||||
print(f"⚠ 获取 Windows 工作区尺寸失败,回退到显示模式尺寸: {e}")
|
||||
|
||||
try:
|
||||
pipe = GraphicsPipeSelection.getGlobalPtr().makeDefaultPipe()
|
||||
if not pipe:
|
||||
return None
|
||||
info = pipe.getDisplayInformation()
|
||||
current_mode = info.getCurrentDisplayModeIndex()
|
||||
if current_mode < 0:
|
||||
return None
|
||||
width = int(info.getDisplayModeWidth(current_mode))
|
||||
height = int(info.getDisplayModeHeight(current_mode))
|
||||
if width > 0 and height > 0:
|
||||
return width, height
|
||||
except Exception as e:
|
||||
print(f"⚠ 获取桌面分辨率失败,继续使用默认窗口尺寸: {e}")
|
||||
return None
|
||||
|
||||
def _handle_transform_error(self):
|
||||
"""处理TransformState相关的错误"""
|
||||
try:
|
||||
@ -122,6 +182,87 @@ class CoreWorld(ShowBase):
|
||||
except Exception as e:
|
||||
print(f"清理缓存时出错: {e}")
|
||||
|
||||
def _setupForwardTransparencyOverlay(self):
|
||||
"""Composite the forward transparency result back onto the final frame.
|
||||
|
||||
The bundled RP forward merge stage is unreliable in this editor build
|
||||
during live material edits, but the forward color buffer itself is
|
||||
correct. We therefore blend that buffer as a fullscreen overlay and use
|
||||
the scene/forward depth textures to keep transparent objects behind
|
||||
opaque geometry clipped.
|
||||
"""
|
||||
self.forward_transparency_overlay = None
|
||||
|
||||
try:
|
||||
forward_stage = next(
|
||||
(
|
||||
stage for stage in self.render_pipeline.stage_mgr.stages
|
||||
if type(stage).__name__ == "ForwardStage"
|
||||
),
|
||||
None,
|
||||
)
|
||||
gbuffer_stage = next(
|
||||
(
|
||||
stage for stage in self.render_pipeline.stage_mgr.stages
|
||||
if type(stage).__name__ == "GBufferStage"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not forward_stage or not gbuffer_stage:
|
||||
return
|
||||
|
||||
vert = """
|
||||
#version 330
|
||||
uniform mat4 p3d_ModelViewProjectionMatrix;
|
||||
in vec4 p3d_Vertex;
|
||||
in vec2 p3d_MultiTexCoord0;
|
||||
out vec2 texcoord;
|
||||
void main() {
|
||||
gl_Position = p3d_ModelViewProjectionMatrix * p3d_Vertex;
|
||||
texcoord = p3d_MultiTexCoord0;
|
||||
}
|
||||
"""
|
||||
frag = """
|
||||
#version 330
|
||||
uniform sampler2D ForwardColor;
|
||||
uniform sampler2D ForwardDepth;
|
||||
uniform sampler2D SceneDepth;
|
||||
in vec2 texcoord;
|
||||
out vec4 o_color;
|
||||
void main() {
|
||||
vec4 forward_color = texture(ForwardColor, texcoord);
|
||||
float alpha = clamp(forward_color.a, 0.0, 1.0);
|
||||
if (alpha <= 1e-4) {
|
||||
discard;
|
||||
}
|
||||
|
||||
float forward_depth = texture(ForwardDepth, texcoord).x;
|
||||
float scene_depth = texture(SceneDepth, texcoord).x;
|
||||
if (scene_depth > 1e-6 && forward_depth > scene_depth + 1e-5) {
|
||||
discard;
|
||||
}
|
||||
|
||||
o_color = vec4(forward_color.rgb, alpha);
|
||||
}
|
||||
"""
|
||||
|
||||
cm = CardMaker("ForwardTransparencyOverlay")
|
||||
cm.setFrameFullscreenQuad()
|
||||
overlay = self.render2d.attachNewNode(cm.generate())
|
||||
overlay.setShader(Shader.make(Shader.SL_GLSL, vert, frag))
|
||||
overlay.setShaderInput("ForwardColor", forward_stage.target.color_tex)
|
||||
overlay.setShaderInput("ForwardDepth", forward_stage.target.depth_tex)
|
||||
overlay.setShaderInput("SceneDepth", gbuffer_stage.target.depth_tex)
|
||||
overlay.setTransparency(TransparencyAttrib.M_alpha)
|
||||
overlay.setDepthTest(False)
|
||||
overlay.setDepthWrite(False)
|
||||
overlay.setBin("fixed", 1)
|
||||
self.forward_transparency_overlay = overlay
|
||||
print("✓ 前向透明叠加层初始化完成")
|
||||
except Exception as e:
|
||||
print(f"⚠ 初始化前向透明叠加层失败: {e}")
|
||||
|
||||
def _setupResourcePaths(self):
|
||||
"""设置Panda3D资源搜索路径,确保能正确找到Resources文件夹中的模型和贴图"""
|
||||
try:
|
||||
@ -184,6 +325,7 @@ class CoreWorld(ShowBase):
|
||||
print(f"✓ 创建并添加子目录: {subdir}")
|
||||
|
||||
# 设置纹理搜索路径
|
||||
texture_path = None
|
||||
try:
|
||||
from panda3d.core import getTexturePath
|
||||
texture_path = getTexturePath()
|
||||
@ -193,12 +335,13 @@ class CoreWorld(ShowBase):
|
||||
# 新版本 Panda3D 中 getTexturePath 可能不可用
|
||||
print(" 注意: getTexturePath 不可用,使用默认纹理路径")
|
||||
|
||||
for subdir in ['textures', 'materials', 'icons']:
|
||||
subdir_path = os.path.join(resources_dir, subdir)
|
||||
if os.path.exists(subdir_path):
|
||||
subdir_filename = Filename.from_os_specific(subdir_path)
|
||||
if not texture_path.findFile(subdir_filename):
|
||||
texture_path.appendDirectory(subdir_filename)
|
||||
if texture_path is not None:
|
||||
for subdir in ['textures', 'materials', 'icons']:
|
||||
subdir_path = os.path.join(resources_dir, subdir)
|
||||
if os.path.exists(subdir_path):
|
||||
subdir_filename = Filename.from_os_specific(subdir_path)
|
||||
if not texture_path.findFile(subdir_filename):
|
||||
texture_path.appendDirectory(subdir_filename)
|
||||
|
||||
print(f"✓ 资源路径设置完成")
|
||||
print(f" 项目根目录: {project_root}")
|
||||
@ -686,16 +829,17 @@ class CoreWorld(ShowBase):
|
||||
print(f"警告: 加载中文字体时发生错误: {e}")
|
||||
self.chinese_font = None
|
||||
|
||||
def setQtWidget(self, widget):
|
||||
"""设置Qt部件引用"""
|
||||
self.qtWidget = widget
|
||||
print(f"✓ 设置Qt部件引用: {widget}")
|
||||
def setHostWidget(self, widget):
|
||||
"""设置外部宿主窗口引用。"""
|
||||
self.host_widget = widget
|
||||
print(f"✓ 设置宿主窗口引用: {widget}")
|
||||
|
||||
def getWindowSize(self):
|
||||
"""获取准确的窗口尺寸"""
|
||||
if self.qtWidget:
|
||||
# 优先使用Qt部件的实际尺寸
|
||||
width, height = self.qtWidget.getActualSize()
|
||||
widget = self.host_widget
|
||||
if widget and hasattr(widget, "getActualSize"):
|
||||
# 优先使用宿主窗口的实际尺寸
|
||||
width, height = widget.getActualSize()
|
||||
if width > 0 and height > 0:
|
||||
return width, height
|
||||
|
||||
@ -754,30 +898,28 @@ class CoreWorld(ShowBase):
|
||||
self.lastMouseX = evt['x']
|
||||
self.lastMouseY = evt['y']
|
||||
#
|
||||
# # 通过 Qt 窗口隐藏光标并捕获鼠标
|
||||
# # 通过宿主窗口隐藏光标并捕获鼠标
|
||||
# try:
|
||||
# if hasattr(self, 'qtWidget') and self.qtWidget:
|
||||
# from PyQt5.QtCore import Qt
|
||||
# self.qtWidget.setCursor(Qt.BlankCursor)
|
||||
# if hasattr(self, 'host_widget') and self.host_widget:
|
||||
# self.host_widget.setCursorHidden(True)
|
||||
# # 捕获鼠标,使其无法离开窗口
|
||||
# self.qtWidget.grabMouse()
|
||||
# self.host_widget.grabMouse()
|
||||
# except Exception as e:
|
||||
# print(f"通过 Qt 隐藏光标时出错: {e}")
|
||||
# print(f"隐藏光标时出错: {e}")
|
||||
|
||||
def mouseReleaseEventRight(self, evt):
|
||||
"""处理鼠标右键释放事件"""
|
||||
#print("右键释放")
|
||||
self.mouseRightPressed = False
|
||||
|
||||
# # 恢复 Qt 窗口光标并释放鼠标捕获
|
||||
# # 恢复宿主窗口光标并释放鼠标捕获
|
||||
# try:
|
||||
# if hasattr(self, 'qtWidget') and self.qtWidget:
|
||||
# from PyQt5.QtCore import Qt
|
||||
# self.qtWidget.unsetCursor() # 恢复默认光标
|
||||
# if hasattr(self, 'host_widget') and self.host_widget:
|
||||
# self.host_widget.setCursorHidden(False) # 恢复默认光标
|
||||
# # 释放鼠标捕获
|
||||
# self.qtWidget.releaseMouse()
|
||||
# self.host_widget.releaseMouse()
|
||||
# except Exception as e:
|
||||
# print(f"恢复 Qt 光标时出错: {e}")
|
||||
# print(f"恢复光标时出错: {e}")
|
||||
|
||||
def mouseMoveEvent(self, evt):
|
||||
"""处理鼠标移动事件 - 只处理相机旋转"""
|
||||
@ -966,7 +1108,7 @@ class CoreWorld(ShowBase):
|
||||
return None
|
||||
|
||||
# 创建材质编辑器组件
|
||||
# 使用纯 Panda3D 实现,不依赖 PyQt5
|
||||
# 使用纯 Panda3D 实现,不依赖外部 GUI 框架
|
||||
print("材质编辑器组件已创建(使用 Panda3D 原生实现)")
|
||||
|
||||
material_widget.setLayout(layout)
|
||||
|
||||
4027
gui/gui_manager.py
106
imgui.ini
@ -24,34 +24,33 @@ Size=832,45
|
||||
Collapsed=0
|
||||
|
||||
[Window][工具栏]
|
||||
Pos=241,20
|
||||
Size=908,74
|
||||
Pos=255,20
|
||||
Size=1398,383
|
||||
Collapsed=0
|
||||
DockId=0x0000000D,0
|
||||
|
||||
[Window][场景树]
|
||||
Pos=0,20
|
||||
Size=239,468
|
||||
Collapsed=0
|
||||
DockId=0x00000007,0
|
||||
|
||||
[Window][属性面板]
|
||||
Pos=1151,20
|
||||
Size=229,730
|
||||
Size=308,588
|
||||
Collapsed=0
|
||||
DockId=0x00000003,0
|
||||
|
||||
[Window][控制台]
|
||||
Pos=0,490
|
||||
Size=239,260
|
||||
[Window][属性面板]
|
||||
Pos=1574,20
|
||||
Size=346,989
|
||||
Collapsed=0
|
||||
DockId=0x00000008,0
|
||||
DockId=0x00000002,0
|
||||
|
||||
[Window][控制台]
|
||||
Pos=0,610
|
||||
Size=308,399
|
||||
Collapsed=0
|
||||
DockId=0x00000004,0
|
||||
|
||||
[Window][脚本管理]
|
||||
Pos=1540,20
|
||||
Size=380,390
|
||||
Pos=1950,20
|
||||
Size=610,995
|
||||
Collapsed=0
|
||||
DockId=0x00000003,1
|
||||
|
||||
[Window][中文显示测试]
|
||||
Pos=60,60
|
||||
@ -60,7 +59,7 @@ Collapsed=0
|
||||
|
||||
[Window][WindowOverViewport_11111111]
|
||||
Pos=0,20
|
||||
Size=1380,730
|
||||
Size=1920,989
|
||||
Collapsed=0
|
||||
|
||||
[Window][测试窗口1]
|
||||
@ -89,18 +88,18 @@ Size=600,500
|
||||
Collapsed=0
|
||||
|
||||
[Window][打开项目]
|
||||
Pos=710,304
|
||||
Pos=702,300
|
||||
Size=500,400
|
||||
Collapsed=0
|
||||
|
||||
[Window][导入模型]
|
||||
Pos=660,254
|
||||
Pos=660,245
|
||||
Size=600,500
|
||||
Collapsed=0
|
||||
|
||||
[Window][资源管理器]
|
||||
Pos=241,464
|
||||
Size=908,286
|
||||
Pos=310,610
|
||||
Size=1262,399
|
||||
Collapsed=0
|
||||
DockId=0x00000006,0
|
||||
|
||||
@ -120,8 +119,8 @@ Size=88,226
|
||||
Collapsed=0
|
||||
|
||||
[Window][创建点光源]
|
||||
Pos=60,60
|
||||
Size=109,274
|
||||
Pos=750,324
|
||||
Size=420,360
|
||||
Collapsed=0
|
||||
|
||||
[Window][创建GUI标签]
|
||||
@ -130,12 +129,12 @@ Size=93,226
|
||||
Collapsed=0
|
||||
|
||||
[Window][创建聚光灯]
|
||||
Pos=60,60
|
||||
Size=89,250
|
||||
Pos=750,344
|
||||
Size=420,320
|
||||
Collapsed=0
|
||||
|
||||
[Window][颜色选择器]
|
||||
Pos=810,304
|
||||
Pos=878,354
|
||||
Size=300,400
|
||||
Collapsed=0
|
||||
|
||||
@ -150,10 +149,10 @@ Size=101,226
|
||||
Collapsed=0
|
||||
|
||||
[Window][LUI编辑器]
|
||||
Pos=1628,412
|
||||
Size=292,597
|
||||
Pos=2214,20
|
||||
Size=346,1331
|
||||
Collapsed=0
|
||||
DockId=0x00000004,0
|
||||
DockId=0x00000002,1
|
||||
|
||||
[Window][LUI测试控制面板]
|
||||
Pos=6,10
|
||||
@ -201,22 +200,41 @@ Size=600,400
|
||||
Collapsed=0
|
||||
|
||||
[Window][Web面板]
|
||||
Pos=373,98
|
||||
Size=942,580
|
||||
Pos=1438,20
|
||||
Size=610,748
|
||||
Collapsed=0
|
||||
DockId=0x00000005,2
|
||||
|
||||
[Window][项目另存为]
|
||||
Pos=730,384
|
||||
Size=460,240
|
||||
Collapsed=0
|
||||
|
||||
[Window][打包项目]
|
||||
Pos=754,392
|
||||
Size=540,320
|
||||
Collapsed=0
|
||||
|
||||
[Window][关于 EG]
|
||||
Pos=794,422
|
||||
Size=460,260
|
||||
Collapsed=0
|
||||
|
||||
[Window][关于 MetaCore]
|
||||
Pos=790,422
|
||||
Size=460,260
|
||||
Collapsed=0
|
||||
|
||||
[Docking][Data]
|
||||
DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1380,730 Split=X
|
||||
DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1689,989 Split=X
|
||||
DockNode ID=0x00000009 Parent=0x00000001 SizeRef=239,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=1448,989 Split=Y
|
||||
DockNode ID=0x0000000D Parent=0x0000000A SizeRef=1318,74 HiddenTabBar=1 Selected=0x43A39006
|
||||
DockNode ID=0x0000000E Parent=0x0000000A SizeRef=1318,913 Split=Y
|
||||
DockNode ID=0x00000005 Parent=0x0000000E SizeRef=1341,625 CentralNode=1
|
||||
DockNode ID=0x00000006 Parent=0x0000000E SizeRef=1341,286 Selected=0x3A2E05C3
|
||||
DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=229,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
|
||||
DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1920,989 Split=X
|
||||
DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1572,1012 Split=X
|
||||
DockNode ID=0x00000007 Parent=0x00000001 SizeRef=308,1084 Split=Y Selected=0xE0015051
|
||||
DockNode ID=0x00000003 Parent=0x00000007 SizeRef=339,588 Selected=0xE0015051
|
||||
DockNode ID=0x00000004 Parent=0x00000007 SizeRef=339,399 Selected=0x5428E753
|
||||
DockNode ID=0x00000008 Parent=0x00000001 SizeRef=1262,1084 Split=Y
|
||||
DockNode ID=0x00000005 Parent=0x00000008 SizeRef=2048,683 Split=Y
|
||||
DockNode ID=0x0000000D Parent=0x00000005 SizeRef=1318,383 HiddenTabBar=1 Selected=0x43A39006
|
||||
DockNode ID=0x0000000E Parent=0x00000005 SizeRef=1318,363 CentralNode=1 Selected=0xE0015051
|
||||
DockNode ID=0x00000006 Parent=0x00000008 SizeRef=2048,399 Selected=0x3A2E05C3
|
||||
DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=346,1012 Selected=0x5DB6FF37
|
||||
|
||||
|
||||
1
index_name.txt
Normal file
@ -0,0 +1 @@
|
||||
index-b2d982.boo
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "new",
|
||||
"path": "D:\\PythonProject\\CH_EG\\EG\\new",
|
||||
"created_at": "2025-09-09 16:47:27",
|
||||
"last_modified": "2025-09-11 09:28:24",
|
||||
"version": "1.0.0",
|
||||
"engine_version": "1.0.0"
|
||||
}
|
||||
75
packaging/debian/README.md
Normal file
@ -0,0 +1,75 @@
|
||||
# EG `.deb` 打包
|
||||
|
||||
这套脚本的目标不是重新编译项目,而是把已经生成好的 PyInstaller 发行目录再封装成 Debian 安装包。
|
||||
|
||||
默认输入目录是:
|
||||
|
||||
```text
|
||||
dist/EGEditor
|
||||
```
|
||||
|
||||
默认输出目录是:
|
||||
|
||||
```text
|
||||
dist-deb
|
||||
```
|
||||
|
||||
## 最常用流程
|
||||
|
||||
1. 先生成 PyInstaller 发行目录:
|
||||
|
||||
```bash
|
||||
python -m PyInstaller --clean packaging/eg_editor.spec
|
||||
```
|
||||
|
||||
2. 再生成 `.deb`:
|
||||
|
||||
```bash
|
||||
bash packaging/debian/build_deb.sh 0.1.0
|
||||
```
|
||||
|
||||
生成结果类似:
|
||||
|
||||
```text
|
||||
dist-deb/eg-editor_0.1.0_amd64.deb
|
||||
```
|
||||
|
||||
## 安装与卸载
|
||||
|
||||
安装:
|
||||
|
||||
```bash
|
||||
sudo apt install ./dist-deb/eg-editor_0.1.0_amd64.deb
|
||||
```
|
||||
|
||||
卸载:
|
||||
|
||||
```bash
|
||||
sudo apt remove eg-editor
|
||||
```
|
||||
|
||||
## 安装后路径
|
||||
|
||||
- 程序主体:`/opt/eg-editor`
|
||||
- 命令行启动:`/usr/bin/eg-editor`
|
||||
- 桌面启动器:`/usr/share/applications/eg-editor.desktop`
|
||||
- 图标:`/usr/share/pixmaps/eg-editor.png`
|
||||
|
||||
## 可选环境变量
|
||||
|
||||
- `APP_DIR`:指定要封装的 PyInstaller 目录
|
||||
- `PACKAGE_NAME`:Debian 包名,默认 `eg-editor`
|
||||
- `DISPLAY_NAME`:桌面显示名称
|
||||
- `ARCH`:包架构,默认自动读取 `dpkg-architecture`
|
||||
- `MAINTAINER`:维护者字段
|
||||
- `OUTPUT_DIR`:`.deb` 输出目录
|
||||
- `ICON_SOURCE`:图标文件路径
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
APP_DIR=/path/to/dist/EGEditor \
|
||||
DISPLAY_NAME="EG Editor" \
|
||||
MAINTAINER="hello <hello@example.com>" \
|
||||
bash packaging/debian/build_deb.sh 0.1.0
|
||||
```
|
||||
78
packaging/debian/build_deb.sh
Executable file
@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
APP_DIR="${APP_DIR:-$ROOT_DIR/dist/EGEditor}"
|
||||
PACKAGE_NAME="${PACKAGE_NAME:-eg-editor}"
|
||||
DISPLAY_NAME="${DISPLAY_NAME:-EG Editor}"
|
||||
VERSION="${1:-${VERSION:-0.1.0}}"
|
||||
ARCH="${ARCH:-$(dpkg-architecture -qDEB_HOST_ARCH 2>/dev/null || echo amd64)}"
|
||||
MAINTAINER="${MAINTAINER:-EG Team <noreply@example.com>}"
|
||||
DESCRIPTION="${DESCRIPTION:-EG 3D editor and engine packaged from the PyInstaller distribution directory.}"
|
||||
|
||||
INSTALL_DIR="/opt/${PACKAGE_NAME}"
|
||||
STAGE_BASE="$ROOT_DIR/build/deb/${PACKAGE_NAME}_${VERSION}_${ARCH}"
|
||||
PKG_ROOT="$STAGE_BASE/${PACKAGE_NAME}"
|
||||
OUTPUT_DIR="${OUTPUT_DIR:-$ROOT_DIR/dist-deb}"
|
||||
OUTPUT_FILE="$OUTPUT_DIR/${PACKAGE_NAME}_${VERSION}_${ARCH}.deb"
|
||||
ICON_SOURCE="${ICON_SOURCE:-$ROOT_DIR/icons/logo.png}"
|
||||
|
||||
if [[ ! -x "$APP_DIR/EGEditor" ]]; then
|
||||
echo "error: executable not found: $APP_DIR/EGEditor" >&2
|
||||
echo "hint: build the PyInstaller bundle first, or set APP_DIR=/path/to/EGEditor" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ICON_SOURCE" ]]; then
|
||||
echo "error: icon not found: $ICON_SOURCE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$STAGE_BASE"
|
||||
mkdir -p \
|
||||
"$PKG_ROOT/DEBIAN" \
|
||||
"$PKG_ROOT$INSTALL_DIR" \
|
||||
"$PKG_ROOT/usr/bin" \
|
||||
"$PKG_ROOT/usr/share/applications" \
|
||||
"$PKG_ROOT/usr/share/pixmaps" \
|
||||
"$OUTPUT_DIR"
|
||||
|
||||
cp -a "$APP_DIR/." "$PKG_ROOT$INSTALL_DIR/"
|
||||
cp "$ICON_SOURCE" "$PKG_ROOT/usr/share/pixmaps/${PACKAGE_NAME}.png"
|
||||
|
||||
cat > "$PKG_ROOT/usr/bin/${PACKAGE_NAME}" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
exec "${INSTALL_DIR}/EGEditor" "\$@"
|
||||
EOF
|
||||
chmod 0755 "$PKG_ROOT/usr/bin/${PACKAGE_NAME}"
|
||||
|
||||
cat > "$PKG_ROOT/usr/share/applications/${PACKAGE_NAME}.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=${DISPLAY_NAME}
|
||||
Comment=Launch ${DISPLAY_NAME}
|
||||
Exec=${PACKAGE_NAME}
|
||||
Icon=${PACKAGE_NAME}
|
||||
Terminal=false
|
||||
Categories=Graphics;Development;
|
||||
StartupNotify=true
|
||||
EOF
|
||||
chmod 0644 "$PKG_ROOT/usr/share/applications/${PACKAGE_NAME}.desktop"
|
||||
|
||||
cat > "$PKG_ROOT/DEBIAN/control" <<EOF
|
||||
Package: ${PACKAGE_NAME}
|
||||
Version: ${VERSION}
|
||||
Section: graphics
|
||||
Priority: optional
|
||||
Architecture: ${ARCH}
|
||||
Maintainer: ${MAINTAINER}
|
||||
Depends: libc6 (>= 2.35), libgl1
|
||||
Description: ${DISPLAY_NAME}
|
||||
${DESCRIPTION}
|
||||
EOF
|
||||
chmod 0644 "$PKG_ROOT/DEBIAN/control"
|
||||
|
||||
dpkg-deb --build --root-owner-group "$PKG_ROOT" "$OUTPUT_FILE"
|
||||
|
||||
echo "built: $OUTPUT_FILE"
|
||||
180
packaging/eg_editor.spec
Normal file
@ -0,0 +1,180 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules
|
||||
|
||||
|
||||
def _has_module(module_name):
|
||||
try:
|
||||
return importlib.util.find_spec(module_name) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _safe_collect_submodules(package_name):
|
||||
try:
|
||||
return collect_submodules(package_name)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _safe_collect_dynamic_libs(package_name):
|
||||
try:
|
||||
return collect_dynamic_libs(package_name)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _append_data_dir(datas, project_root, relative_path, bundle_target=None):
|
||||
source = project_root / relative_path
|
||||
if source.exists():
|
||||
datas.append((str(source), bundle_target or relative_path.replace("\\", "/")))
|
||||
|
||||
|
||||
def _append_package_data_dir(datas, package_name, relative_path, bundle_target):
|
||||
spec = importlib.util.find_spec(package_name)
|
||||
if not spec or not spec.origin:
|
||||
return
|
||||
package_root = Path(spec.origin).resolve().parent
|
||||
source = package_root / relative_path
|
||||
if source.exists():
|
||||
datas.append((str(source), bundle_target))
|
||||
|
||||
|
||||
SPEC_DIR = Path(SPEC).resolve().parent
|
||||
PROJECT_ROOT = SPEC_DIR.parent
|
||||
THIRD_PARTY_DIR = PROJECT_ROOT / "third_party"
|
||||
RENDER_PIPELINE_DIR = PROJECT_ROOT / "RenderPipelineFile"
|
||||
ENTRY_SCRIPT = PROJECT_ROOT / "Start_Run.py"
|
||||
|
||||
for candidate in (PROJECT_ROOT, THIRD_PARTY_DIR, RENDER_PIPELINE_DIR):
|
||||
candidate_str = str(candidate)
|
||||
if candidate.exists() and candidate_str not in sys.path:
|
||||
sys.path.insert(0, candidate_str)
|
||||
|
||||
pathex = [
|
||||
str(PROJECT_ROOT),
|
||||
str(THIRD_PARTY_DIR),
|
||||
str(RENDER_PIPELINE_DIR),
|
||||
]
|
||||
|
||||
datas = []
|
||||
for relative_path, bundle_target in (
|
||||
("Resources", "Resources"),
|
||||
("RenderPipelineFile", "RenderPipelineFile"),
|
||||
("config", "config"),
|
||||
("vr_actions", "vr_actions"),
|
||||
("icons", "icons"),
|
||||
("scripts", "scripts"),
|
||||
("templates", "templates"),
|
||||
("ui/Skins", "ui/Skins"),
|
||||
("ssbo_component/shaders", "ssbo_component/shaders"),
|
||||
("ssbo_component/effects", "ssbo_component/effects"),
|
||||
):
|
||||
_append_data_dir(datas, PROJECT_ROOT, relative_path, bundle_target)
|
||||
|
||||
imgui_ini = PROJECT_ROOT / "imgui.ini"
|
||||
if imgui_ini.exists():
|
||||
datas.append((str(imgui_ini), "."))
|
||||
|
||||
demo_dir = PROJECT_ROOT / "demo"
|
||||
if demo_dir.exists():
|
||||
datas.append((str(demo_dir), "demo"))
|
||||
|
||||
_append_package_data_dir(datas, "panda3d", "etc", "panda3d/etc")
|
||||
|
||||
binaries = []
|
||||
for extension_suffix in importlib.machinery.EXTENSION_SUFFIXES:
|
||||
local_lui_binary = PROJECT_ROOT / "ui" / f"lui{extension_suffix}"
|
||||
if local_lui_binary.exists():
|
||||
binaries.append((str(local_lui_binary), "ui"))
|
||||
break
|
||||
|
||||
for package_name in ("panda3d", "imgui_bundle"):
|
||||
binaries += _safe_collect_dynamic_libs(package_name)
|
||||
|
||||
if _has_module("openvr"):
|
||||
binaries += _safe_collect_dynamic_libs("openvr")
|
||||
|
||||
hiddenimports = sorted(
|
||||
set(
|
||||
[
|
||||
"pyassimp",
|
||||
"p3dimgui",
|
||||
"p3dimgui.backend",
|
||||
"p3dimgui.shaders",
|
||||
"rpcore",
|
||||
"rpplugins",
|
||||
"rplibs",
|
||||
"RenderPipelineFile.rpcore",
|
||||
"RenderPipelineFile.rpplugins",
|
||||
"RenderPipelineFile.rplibs",
|
||||
]
|
||||
+ _safe_collect_submodules("core")
|
||||
+ _safe_collect_submodules("scene")
|
||||
+ _safe_collect_submodules("project")
|
||||
+ _safe_collect_submodules("ui")
|
||||
+ _safe_collect_submodules("ssbo_component")
|
||||
+ _safe_collect_submodules("TransformGizmo")
|
||||
+ _safe_collect_submodules("p3dimgui")
|
||||
+ _safe_collect_submodules("rpcore")
|
||||
+ _safe_collect_submodules("rpplugins")
|
||||
+ _safe_collect_submodules("rplibs")
|
||||
+ _safe_collect_submodules("RenderPipelineFile.rpcore")
|
||||
+ _safe_collect_submodules("RenderPipelineFile.rpplugins")
|
||||
+ _safe_collect_submodules("RenderPipelineFile.rplibs")
|
||||
+ (["openvr"] if _has_module("openvr") else [])
|
||||
+ (["pyassimp"] if _has_module("pyassimp") else [])
|
||||
)
|
||||
)
|
||||
|
||||
excludes = [
|
||||
"PyQt5",
|
||||
"PyQt6",
|
||||
"PySide2",
|
||||
"PySide6",
|
||||
"tkinter",
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
[str(ENTRY_SCRIPT)],
|
||||
pathex=pathex,
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=excludes,
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
noarchive=False,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="EGEditor",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
console=False,
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
name="EGEditor",
|
||||
)
|
||||
362
project/asset_database.py
Normal file
@ -0,0 +1,362 @@
|
||||
"""Asset database and import cache helpers for MetaCore project v2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
from panda3d.core import Filename
|
||||
|
||||
from project.project_schema import (
|
||||
ASSET_DB_SCHEMA_VERSION,
|
||||
ProjectLayout,
|
||||
detect_asset_type,
|
||||
generate_guid,
|
||||
get_asset_subdir_for_type,
|
||||
normalize_path,
|
||||
relative_project_path,
|
||||
)
|
||||
|
||||
|
||||
class AssetDatabase:
|
||||
def __init__(self, project_root: str, world=None):
|
||||
self.layout = ProjectLayout(project_root)
|
||||
self.world = world
|
||||
self.data = {
|
||||
"schema_version": ASSET_DB_SCHEMA_VERSION,
|
||||
"assets": {},
|
||||
"path_to_guid": {},
|
||||
}
|
||||
self.ensure_database()
|
||||
|
||||
def _get_all_meta_files(self) -> list[str]:
|
||||
meta_files = []
|
||||
if not os.path.exists(self.layout.assets_root):
|
||||
return meta_files
|
||||
for root, _, files in os.walk(self.layout.assets_root):
|
||||
for file_name in files:
|
||||
if file_name.endswith(".meta"):
|
||||
meta_files.append(os.path.join(root, file_name))
|
||||
return meta_files
|
||||
|
||||
def _rebuild_path_index(self):
|
||||
rebuilt = {}
|
||||
for asset_guid, asset_record in (self.data.get("assets", {}) or {}).items():
|
||||
asset_path = str((asset_record or {}).get("asset_path", "") or "").replace("\\", "/")
|
||||
if asset_path:
|
||||
rebuilt[asset_path] = asset_guid
|
||||
self.data["path_to_guid"] = rebuilt
|
||||
|
||||
def _sync_assets_from_meta_scan(self):
|
||||
assets = self.data.setdefault("assets", {})
|
||||
changed = False
|
||||
|
||||
for meta_path in self._get_all_meta_files():
|
||||
meta_payload = self._read_meta(meta_path)
|
||||
asset_guid = str(meta_payload.get("guid", "") or "").strip()
|
||||
if not asset_guid:
|
||||
continue
|
||||
|
||||
asset_path = meta_path[:-5]
|
||||
if not os.path.exists(asset_path):
|
||||
continue
|
||||
|
||||
relative_asset_path = relative_project_path(self.layout.project_root, asset_path)
|
||||
if not relative_asset_path:
|
||||
continue
|
||||
|
||||
asset_type = str(meta_payload.get("asset_type") or detect_asset_type(asset_path))
|
||||
source_hash = self._hash_file(asset_path)
|
||||
record = dict(assets.get(asset_guid, {}) or {})
|
||||
previous_asset_path = str(record.get("asset_path", "") or "")
|
||||
|
||||
record.update(
|
||||
{
|
||||
"guid": asset_guid,
|
||||
"asset_path": relative_asset_path,
|
||||
"asset_type": asset_type,
|
||||
"meta_path": relative_project_path(self.layout.project_root, meta_path),
|
||||
"source_hash": source_hash,
|
||||
"importer": str(meta_payload.get("importer") or f"{asset_type}_importer"),
|
||||
"import_settings": meta_payload.get("import_settings", {}) or {},
|
||||
"dependency_guids": meta_payload.get("dependency_guids", []) or [],
|
||||
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
)
|
||||
|
||||
assets[asset_guid] = record
|
||||
if previous_asset_path != relative_asset_path:
|
||||
changed = True
|
||||
|
||||
self._rebuild_path_index()
|
||||
return changed
|
||||
|
||||
def ensure_database(self):
|
||||
os.makedirs(self.layout.library_root, exist_ok=True)
|
||||
if os.path.exists(self.layout.asset_db_path):
|
||||
try:
|
||||
with open(self.layout.asset_db_path, "r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
if isinstance(payload, dict):
|
||||
self.data["schema_version"] = payload.get("schema_version", ASSET_DB_SCHEMA_VERSION)
|
||||
self.data["assets"] = payload.get("assets", {}) or {}
|
||||
self.data["path_to_guid"] = payload.get("path_to_guid", {}) or {}
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取 AssetDB 失败,已重建为空数据库: {e}")
|
||||
self._sync_assets_from_meta_scan()
|
||||
self.save()
|
||||
|
||||
def save(self):
|
||||
os.makedirs(os.path.dirname(self.layout.asset_db_path), exist_ok=True)
|
||||
with open(self.layout.asset_db_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, ensure_ascii=False, indent=4)
|
||||
|
||||
def get_asset(self, asset_guid: str) -> dict:
|
||||
record = dict(self.data.get("assets", {}).get(asset_guid, {}) or {})
|
||||
if not record:
|
||||
self._sync_assets_from_meta_scan()
|
||||
record = dict(self.data.get("assets", {}).get(asset_guid, {}) or {})
|
||||
if not record:
|
||||
return {}
|
||||
|
||||
asset_path = record.get("asset_path", "")
|
||||
asset_abs_path = os.path.join(self.layout.project_root, asset_path.replace("/", os.sep)) if asset_path else ""
|
||||
if asset_abs_path and not os.path.exists(asset_abs_path):
|
||||
if self._sync_assets_from_meta_scan():
|
||||
record = dict(self.data.get("assets", {}).get(asset_guid, {}) or {})
|
||||
return record
|
||||
|
||||
def find_by_relative_path(self, relative_path: str) -> dict:
|
||||
relative_path = str(relative_path or "").replace("\\", "/")
|
||||
asset_guid = self.data.get("path_to_guid", {}).get(relative_path)
|
||||
if not asset_guid:
|
||||
self._sync_assets_from_meta_scan()
|
||||
asset_guid = self.data.get("path_to_guid", {}).get(relative_path)
|
||||
if not asset_guid:
|
||||
return {}
|
||||
return self.get_asset(asset_guid)
|
||||
|
||||
def find_by_absolute_path(self, asset_path: str) -> dict:
|
||||
relative_path = relative_project_path(self.layout.project_root, asset_path)
|
||||
if not relative_path:
|
||||
return {}
|
||||
return self.find_by_relative_path(relative_path)
|
||||
|
||||
def _hash_file(self, file_path: str) -> str:
|
||||
file_path = normalize_path(file_path)
|
||||
digest = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
def _read_meta(self, meta_path: str) -> dict:
|
||||
if not os.path.exists(meta_path):
|
||||
return {}
|
||||
try:
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取资源 meta 失败 {meta_path}: {e}")
|
||||
return {}
|
||||
|
||||
def _write_meta(self, meta_path: str, payload: dict):
|
||||
os.makedirs(os.path.dirname(meta_path), exist_ok=True)
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=4)
|
||||
|
||||
def _copy_into_assets(self, source_path: str, preferred_subdir: str = "") -> str:
|
||||
source_path = normalize_path(source_path)
|
||||
asset_type = detect_asset_type(source_path)
|
||||
subdir = preferred_subdir or get_asset_subdir_for_type(asset_type)
|
||||
target_dir = os.path.join(self.layout.assets_root, subdir)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
file_name = os.path.basename(source_path)
|
||||
target_path = os.path.join(target_dir, file_name)
|
||||
name_root, name_ext = os.path.splitext(file_name)
|
||||
index = 1
|
||||
while os.path.exists(target_path) and self._hash_file(target_path) != self._hash_file(source_path):
|
||||
target_path = os.path.join(target_dir, f"{name_root}_{index}{name_ext}")
|
||||
index += 1
|
||||
|
||||
if not os.path.exists(target_path):
|
||||
shutil.copy2(source_path, target_path)
|
||||
return target_path
|
||||
|
||||
def _build_model_import_cache(self, asset_record: dict):
|
||||
if not self.world or not getattr(self.world, "loader", None):
|
||||
return
|
||||
|
||||
asset_path = os.path.join(self.layout.project_root, asset_record["asset_path"].replace("/", os.sep))
|
||||
if not os.path.exists(asset_path):
|
||||
return
|
||||
|
||||
cache_dir = self.layout.imported_asset_dir(asset_record["guid"])
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
|
||||
model_bam_path = os.path.join(cache_dir, "model.bam")
|
||||
hierarchy_path = os.path.join(cache_dir, "hierarchy.json")
|
||||
materials_path = os.path.join(cache_dir, "materials.json")
|
||||
import_info_path = os.path.join(cache_dir, "import_info.json")
|
||||
|
||||
try:
|
||||
model_np = self.world.loader.loadModel(Filename.fromOsSpecific(asset_path))
|
||||
except Exception as e:
|
||||
print(f"⚠️ 生成模型导入缓存失败 {asset_path}: {e}")
|
||||
return
|
||||
|
||||
if not model_np or model_np.isEmpty():
|
||||
return
|
||||
|
||||
try:
|
||||
model_np.writeBamFile(Filename.fromOsSpecific(model_bam_path))
|
||||
except Exception as e:
|
||||
print(f"⚠️ 写入模型缓存 BAM 失败 {asset_path}: {e}")
|
||||
return
|
||||
|
||||
hierarchy = []
|
||||
materials = []
|
||||
|
||||
def _walk(node, parent_key=""):
|
||||
try:
|
||||
node_name = node.getName()
|
||||
except Exception:
|
||||
node_name = "node"
|
||||
try:
|
||||
children = list(node.getChildren())
|
||||
except Exception:
|
||||
children = []
|
||||
node_key = parent_key
|
||||
hierarchy.append(
|
||||
{
|
||||
"key": node_key,
|
||||
"name": node_name,
|
||||
"child_count": len(children),
|
||||
}
|
||||
)
|
||||
for index, child in enumerate(children):
|
||||
_walk(child, f"{node_key}/{index}" if node_key else str(index))
|
||||
|
||||
def _collect_materials(root_np):
|
||||
for geom_np in root_np.findAllMatches("**/+GeomNode"):
|
||||
try:
|
||||
geom_node = geom_np.node()
|
||||
except Exception:
|
||||
continue
|
||||
for geom_index in range(geom_node.getNumGeoms()):
|
||||
materials.append(
|
||||
{
|
||||
"node_name": geom_np.getName(),
|
||||
"geom_index": geom_index,
|
||||
"material_name": "",
|
||||
}
|
||||
)
|
||||
|
||||
_walk(model_np)
|
||||
_collect_materials(model_np)
|
||||
|
||||
with open(hierarchy_path, "w", encoding="utf-8") as f:
|
||||
json.dump(hierarchy, f, ensure_ascii=False, indent=4)
|
||||
with open(materials_path, "w", encoding="utf-8") as f:
|
||||
json.dump(materials, f, ensure_ascii=False, indent=4)
|
||||
with open(import_info_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"asset_guid": asset_record["guid"],
|
||||
"asset_path": asset_record["asset_path"],
|
||||
"asset_type": asset_record["asset_type"],
|
||||
"source_hash": asset_record["source_hash"],
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=4,
|
||||
)
|
||||
|
||||
asset_record["imported_cache"] = {
|
||||
"root": relative_project_path(self.layout.project_root, cache_dir),
|
||||
"model_bam": relative_project_path(self.layout.project_root, model_bam_path),
|
||||
"hierarchy": relative_project_path(self.layout.project_root, hierarchy_path),
|
||||
"materials": relative_project_path(self.layout.project_root, materials_path),
|
||||
"import_info": relative_project_path(self.layout.project_root, import_info_path),
|
||||
}
|
||||
|
||||
def register_asset(
|
||||
self,
|
||||
asset_path: str,
|
||||
preferred_subdir: str = "",
|
||||
copy_into_assets: bool = False,
|
||||
build_import_cache: bool = False,
|
||||
) -> dict:
|
||||
asset_path = normalize_path(asset_path)
|
||||
if not os.path.exists(asset_path):
|
||||
return {}
|
||||
|
||||
if copy_into_assets or not relative_project_path(self.layout.project_root, asset_path).startswith("Assets/"):
|
||||
asset_path = self._copy_into_assets(asset_path, preferred_subdir=preferred_subdir)
|
||||
|
||||
relative_asset_path = relative_project_path(self.layout.project_root, asset_path)
|
||||
if not relative_asset_path:
|
||||
return {}
|
||||
|
||||
meta_path = self.layout.meta_path_for_asset(asset_path)
|
||||
meta_payload = self._read_meta(meta_path)
|
||||
asset_guid = str(meta_payload.get("guid") or generate_guid())
|
||||
asset_type = str(meta_payload.get("asset_type") or detect_asset_type(asset_path))
|
||||
source_hash = self._hash_file(asset_path)
|
||||
|
||||
record = self.data.get("assets", {}).get(asset_guid, {}) or {}
|
||||
record.update(
|
||||
{
|
||||
"guid": asset_guid,
|
||||
"asset_path": relative_asset_path,
|
||||
"asset_type": asset_type,
|
||||
"meta_path": relative_project_path(self.layout.project_root, meta_path),
|
||||
"source_hash": source_hash,
|
||||
"importer": f"{asset_type}_importer",
|
||||
"import_settings": meta_payload.get("import_settings", {}) or {},
|
||||
"dependency_guids": meta_payload.get("dependency_guids", []) or [],
|
||||
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
)
|
||||
|
||||
if asset_type == "model" and build_import_cache:
|
||||
self._build_model_import_cache(record)
|
||||
|
||||
meta_payload = {
|
||||
"guid": asset_guid,
|
||||
"asset_type": asset_type,
|
||||
"source_hash": source_hash,
|
||||
"importer": record["importer"],
|
||||
"import_settings": record["import_settings"],
|
||||
"dependency_guids": record["dependency_guids"],
|
||||
}
|
||||
self._write_meta(meta_path, meta_payload)
|
||||
|
||||
self.data.setdefault("assets", {})[asset_guid] = record
|
||||
self._rebuild_path_index()
|
||||
self.save()
|
||||
return dict(record)
|
||||
|
||||
def import_asset(self, source_path: str, preferred_subdir: str = "", build_import_cache: bool = False) -> dict:
|
||||
return self.register_asset(
|
||||
source_path,
|
||||
preferred_subdir=preferred_subdir,
|
||||
copy_into_assets=True,
|
||||
build_import_cache=build_import_cache,
|
||||
)
|
||||
|
||||
def ensure_project_assets_registered(self):
|
||||
self._sync_assets_from_meta_scan()
|
||||
for root, _, files in os.walk(self.layout.assets_root):
|
||||
for file_name in files:
|
||||
if file_name.endswith(".meta"):
|
||||
continue
|
||||
asset_path = os.path.join(root, file_name)
|
||||
self.register_asset(asset_path, copy_into_assets=False, build_import_cache=False)
|
||||
219
project/project_schema.py
Normal file
@ -0,0 +1,219 @@
|
||||
"""Project layout and schema helpers for MetaCore project v2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
ENGINE_NAME = "MetaCore"
|
||||
PROJECT_SCHEMA_VERSION = 2
|
||||
ASSET_DB_SCHEMA_VERSION = 1
|
||||
METASCENE_SCHEMA_VERSION = 2
|
||||
RUNTIME_SCENE_SCHEMA_VERSION = 2
|
||||
MANIFEST_SCHEMA_VERSION = 1
|
||||
SCENE_DESCRIPTION_EXTENSION = ".metascene"
|
||||
|
||||
|
||||
MODEL_EXTENSIONS = {
|
||||
".bam",
|
||||
".egg",
|
||||
".obj",
|
||||
".fbx",
|
||||
".gltf",
|
||||
".glb",
|
||||
".ply",
|
||||
".stl",
|
||||
}
|
||||
TEXTURE_EXTENSIONS = {
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".bmp",
|
||||
".tga",
|
||||
".tif",
|
||||
".tiff",
|
||||
".dds",
|
||||
".hdr",
|
||||
".exr",
|
||||
}
|
||||
AUDIO_EXTENSIONS = {".wav", ".mp3", ".ogg", ".flac"}
|
||||
VIDEO_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".wmv", ".webm"}
|
||||
SCRIPT_EXTENSIONS = {".py", ".pyc"}
|
||||
UI_EXTENSIONS = {".json", ".ttf", ".otf", ".woff", ".woff2"}
|
||||
|
||||
|
||||
def generate_guid() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
return os.path.normpath(os.path.abspath(path))
|
||||
|
||||
|
||||
def relative_project_path(project_root: str, target_path: str) -> str:
|
||||
project_root = normalize_path(project_root)
|
||||
target_path = normalize_path(target_path)
|
||||
try:
|
||||
relative_path = os.path.relpath(target_path, project_root)
|
||||
except ValueError:
|
||||
return ""
|
||||
if relative_path.startswith(".."):
|
||||
return ""
|
||||
return relative_path.replace("\\", "/")
|
||||
|
||||
|
||||
def detect_asset_type(file_path: str) -> str:
|
||||
extension = os.path.splitext(str(file_path or ""))[1].lower()
|
||||
if extension in MODEL_EXTENSIONS:
|
||||
return "model"
|
||||
if extension in TEXTURE_EXTENSIONS:
|
||||
return "texture"
|
||||
if extension in AUDIO_EXTENSIONS:
|
||||
return "audio"
|
||||
if extension in VIDEO_EXTENSIONS:
|
||||
return "video"
|
||||
if extension in SCRIPT_EXTENSIONS:
|
||||
return "script"
|
||||
if extension in UI_EXTENSIONS:
|
||||
return "ui"
|
||||
return "other"
|
||||
|
||||
|
||||
def get_asset_subdir_for_type(asset_type: str) -> str:
|
||||
return {
|
||||
"model": "Models",
|
||||
"texture": "Textures",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"ui": "UI",
|
||||
"script": "Scripts",
|
||||
"other": "Misc",
|
||||
}.get(asset_type, "Misc")
|
||||
|
||||
|
||||
def default_build_settings() -> dict:
|
||||
return {
|
||||
"enabled_scene_guids": [],
|
||||
"output_format": "directory",
|
||||
"windows_player": True,
|
||||
"active_profile": "default",
|
||||
"profiles": {
|
||||
"default": {
|
||||
"name": "Default",
|
||||
"target_platform": "windows",
|
||||
"output_format": "directory",
|
||||
"output_dir": "Builds",
|
||||
"windows": {
|
||||
"enabled": True,
|
||||
"exe_name": "",
|
||||
"windowed": True,
|
||||
},
|
||||
"runtime": {
|
||||
"startup_scene_guid": "",
|
||||
"enabled_scene_guids": [],
|
||||
"script_mode": "pyc",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectLayout:
|
||||
project_root: str
|
||||
|
||||
def __post_init__(self):
|
||||
object.__setattr__(self, "project_root", normalize_path(self.project_root))
|
||||
|
||||
@property
|
||||
def project_file(self) -> str:
|
||||
return os.path.join(self.project_root, "project.json")
|
||||
|
||||
@property
|
||||
def assets_root(self) -> str:
|
||||
return os.path.join(self.project_root, "Assets")
|
||||
|
||||
@property
|
||||
def scenes_root(self) -> str:
|
||||
return os.path.join(self.project_root, "Scenes")
|
||||
|
||||
@property
|
||||
def library_root(self) -> str:
|
||||
return os.path.join(self.project_root, "Library")
|
||||
|
||||
@property
|
||||
def builds_root(self) -> str:
|
||||
return os.path.join(self.project_root, "Builds")
|
||||
|
||||
@property
|
||||
def asset_db_path(self) -> str:
|
||||
return os.path.join(self.library_root, "AssetDB.json")
|
||||
|
||||
@property
|
||||
def imported_root(self) -> str:
|
||||
return os.path.join(self.library_root, "Imported")
|
||||
|
||||
@property
|
||||
def build_cache_root(self) -> str:
|
||||
return os.path.join(self.library_root, "BuildCache")
|
||||
|
||||
@property
|
||||
def scripts_root(self) -> str:
|
||||
return os.path.join(self.assets_root, "Scripts")
|
||||
|
||||
@property
|
||||
def models_root(self) -> str:
|
||||
return os.path.join(self.assets_root, "Models")
|
||||
|
||||
@property
|
||||
def textures_root(self) -> str:
|
||||
return os.path.join(self.assets_root, "Textures")
|
||||
|
||||
@property
|
||||
def audio_root(self) -> str:
|
||||
return os.path.join(self.assets_root, "Audio")
|
||||
|
||||
@property
|
||||
def video_root(self) -> str:
|
||||
return os.path.join(self.assets_root, "Video")
|
||||
|
||||
@property
|
||||
def ui_root(self) -> str:
|
||||
return os.path.join(self.assets_root, "UI")
|
||||
|
||||
@property
|
||||
def misc_root(self) -> str:
|
||||
return os.path.join(self.assets_root, "Misc")
|
||||
|
||||
def meta_path_for_asset(self, asset_abs_path: str) -> str:
|
||||
return f"{normalize_path(asset_abs_path)}.meta"
|
||||
|
||||
def imported_asset_dir(self, asset_guid: str) -> str:
|
||||
return os.path.join(self.imported_root, asset_guid)
|
||||
|
||||
def build_cache_dir(self, scene_guid: str) -> str:
|
||||
return os.path.join(self.build_cache_root, scene_guid)
|
||||
|
||||
def scene_file(self, scene_name: str) -> str:
|
||||
return os.path.join(self.scenes_root, f"{scene_name}{SCENE_DESCRIPTION_EXTENSION}")
|
||||
|
||||
|
||||
def ensure_project_directories(layout: ProjectLayout) -> None:
|
||||
for path in (
|
||||
layout.assets_root,
|
||||
layout.models_root,
|
||||
layout.textures_root,
|
||||
layout.audio_root,
|
||||
layout.video_root,
|
||||
layout.ui_root,
|
||||
layout.scripts_root,
|
||||
layout.misc_root,
|
||||
layout.scenes_root,
|
||||
layout.library_root,
|
||||
layout.imported_root,
|
||||
layout.build_cache_root,
|
||||
layout.builds_root,
|
||||
):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
674
project/scene_description.py
Normal file
@ -0,0 +1,674 @@
|
||||
"""Scene description and runtime scene helpers for MetaCore project v2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from project.project_schema import (
|
||||
ENGINE_NAME,
|
||||
MANIFEST_SCHEMA_VERSION,
|
||||
METASCENE_SCHEMA_VERSION,
|
||||
RUNTIME_SCENE_SCHEMA_VERSION,
|
||||
relative_project_path,
|
||||
)
|
||||
|
||||
|
||||
def load_json(file_path: str, default_value):
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return default_value
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取 JSON 失败 {file_path}: {e}")
|
||||
return default_value
|
||||
|
||||
|
||||
def save_json(file_path: str, payload):
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=4)
|
||||
|
||||
|
||||
def _migrate_scene_description_v1_to_v2(payload: dict) -> dict:
|
||||
payload = dict(payload or {})
|
||||
|
||||
scene_components = dict(payload.get("scene_components", {}) or {})
|
||||
camera_component = dict(scene_components.get("camera", {}) or payload.get("camera", {}) or {})
|
||||
gui_component = dict(scene_components.get("gui", {}) or {})
|
||||
gui_elements = list(gui_component.get("elements", []) or payload.get("gui", []) or [])
|
||||
lui_component = dict(scene_components.get("lui", {}) or payload.get("lui", {}) or {})
|
||||
scene_components["camera"] = camera_component
|
||||
scene_components["gui"] = {"elements": gui_elements}
|
||||
scene_components["lui"] = lui_component
|
||||
payload["scene_components"] = scene_components
|
||||
payload["camera"] = camera_component
|
||||
payload["gui"] = gui_elements
|
||||
payload["lui"] = lui_component
|
||||
|
||||
normalized_nodes = []
|
||||
for raw_node in list(payload.get("nodes", []) or []):
|
||||
node = dict(raw_node or {})
|
||||
components = dict(node.get("components", {}) or {})
|
||||
runtime_interactive = bool(
|
||||
(components.get("metadata", {}) or {}).get("runtime_interactive", node.get("runtime_interactive", False))
|
||||
)
|
||||
scripts = list((components.get("scripts", {}) or {}).get("entries", []) or node.get("scripts", []) or [])
|
||||
model_component = dict(components.get("model", {}) or {})
|
||||
if not model_component and (node.get("asset_guid") or node.get("asset_path") or node.get("imported_node_key")):
|
||||
model_component = {
|
||||
"asset_guid": node.get("asset_guid", ""),
|
||||
"asset_path": node.get("asset_path", ""),
|
||||
"imported_node_key": node.get("imported_node_key", ""),
|
||||
"is_model_root": bool((node.get("tags", {}) or {}).get("is_model_root")),
|
||||
}
|
||||
components["model"] = model_component
|
||||
components["scripts"] = {"entries": scripts} if scripts else {}
|
||||
components["metadata"] = dict(components.get("metadata", {}) or {})
|
||||
components["metadata"]["runtime_interactive"] = runtime_interactive
|
||||
components["metadata"].setdefault("node_class", node.get("node_class", ""))
|
||||
node["components"] = components
|
||||
node["runtime_interactive"] = runtime_interactive
|
||||
node["scripts"] = scripts
|
||||
if model_component:
|
||||
node["asset_guid"] = str(model_component.get("asset_guid", "") or node.get("asset_guid", "") or "")
|
||||
node["asset_path"] = str(model_component.get("asset_path", "") or node.get("asset_path", "") or "")
|
||||
node["imported_node_key"] = str(model_component.get("imported_node_key", "") or node.get("imported_node_key", "") or "")
|
||||
normalized_nodes.append(node)
|
||||
|
||||
payload["nodes"] = normalized_nodes
|
||||
payload["schema_version"] = 2
|
||||
return payload
|
||||
|
||||
|
||||
def normalize_scene_description(payload: dict) -> dict:
|
||||
payload = dict(payload or {})
|
||||
version = int(payload.get("schema_version", 1) or 1)
|
||||
original_version = version
|
||||
|
||||
while version < METASCENE_SCHEMA_VERSION:
|
||||
if version == 1:
|
||||
payload = _migrate_scene_description_v1_to_v2(payload)
|
||||
version = 2
|
||||
continue
|
||||
break
|
||||
|
||||
payload["schema_version"] = METASCENE_SCHEMA_VERSION
|
||||
if original_version != METASCENE_SCHEMA_VERSION:
|
||||
print(f"ℹ️ 场景描述已从 schema v{original_version} 迁移到 v{METASCENE_SCHEMA_VERSION}")
|
||||
return payload
|
||||
|
||||
|
||||
def _migrate_runtime_scene_v1_to_v2(payload: dict) -> dict:
|
||||
payload = dict(payload or {})
|
||||
scene_components = dict(payload.get("scene_components", {}) or {})
|
||||
camera_component = dict(scene_components.get("camera", {}) or payload.get("camera", {}) or {})
|
||||
gui_component = dict(scene_components.get("gui", {}) or {})
|
||||
gui_elements = list(gui_component.get("elements", []) or payload.get("gui", []) or [])
|
||||
lui_component = dict(scene_components.get("lui", {}) or payload.get("lui", {}) or {})
|
||||
payload["scene_components"] = {
|
||||
"camera": camera_component,
|
||||
"gui": {"elements": gui_elements},
|
||||
"lui": lui_component,
|
||||
}
|
||||
payload["camera"] = camera_component
|
||||
payload["gui"] = gui_elements
|
||||
payload["lui"] = lui_component
|
||||
payload["nodes"] = normalize_scene_description({"nodes": payload.get("nodes", []) or []}).get("nodes", [])
|
||||
payload["schema_version"] = 2
|
||||
return payload
|
||||
|
||||
|
||||
def normalize_runtime_scene(payload: dict) -> dict:
|
||||
payload = dict(payload or {})
|
||||
version = int(payload.get("schema_version", 1) or 1)
|
||||
original_version = version
|
||||
|
||||
while version < RUNTIME_SCENE_SCHEMA_VERSION:
|
||||
if version == 1:
|
||||
payload = _migrate_runtime_scene_v1_to_v2(payload)
|
||||
version = 2
|
||||
continue
|
||||
break
|
||||
|
||||
payload["schema_version"] = RUNTIME_SCENE_SCHEMA_VERSION
|
||||
if original_version != RUNTIME_SCENE_SCHEMA_VERSION:
|
||||
print(f"ℹ️ 运行时场景已从 schema v{original_version} 迁移到 v{RUNTIME_SCENE_SCHEMA_VERSION}")
|
||||
return payload
|
||||
|
||||
|
||||
def _node_path_id(parent_id: str, index: int) -> str:
|
||||
return f"{parent_id}/{index}" if parent_id else str(index)
|
||||
|
||||
|
||||
def _collect_light_component(node):
|
||||
if not node.hasTag("light_type"):
|
||||
return {}
|
||||
component = {"type": node.getTag("light_type")}
|
||||
for tag_name, output_name in (
|
||||
("light_energy", "energy"),
|
||||
("light_radius", "radius"),
|
||||
("light_fov", "fov"),
|
||||
("stored_energy", "stored_energy"),
|
||||
):
|
||||
if node.hasTag(tag_name):
|
||||
component[output_name] = node.getTag(tag_name)
|
||||
return component
|
||||
|
||||
|
||||
def _collect_model_component(node, asset_guid: str, asset_path: str, imported_node_key: str) -> dict:
|
||||
if not asset_guid and not node.hasTag("is_model_root"):
|
||||
return {}
|
||||
return {
|
||||
"asset_guid": asset_guid,
|
||||
"asset_path": asset_path,
|
||||
"imported_node_key": imported_node_key,
|
||||
"is_model_root": bool(node.hasTag("is_model_root")),
|
||||
}
|
||||
|
||||
|
||||
def _collect_script_component(scripts: list[dict]) -> dict:
|
||||
return {"entries": list(scripts or [])} if scripts else {}
|
||||
|
||||
|
||||
def _collect_node_metadata_component(node, runtime_interactive: bool) -> dict:
|
||||
metadata = {
|
||||
"node_class": node.node().getClassType().getName() if node.node() else "",
|
||||
"runtime_interactive": runtime_interactive,
|
||||
}
|
||||
for tag_name in (
|
||||
"has_animations",
|
||||
"has_animations_checked",
|
||||
"can_create_actor_from_memory",
|
||||
"saved_has_animations",
|
||||
"saved_can_create_actor_from_memory",
|
||||
):
|
||||
if node.hasTag(tag_name):
|
||||
metadata[tag_name] = node.getTag(tag_name)
|
||||
return metadata
|
||||
|
||||
|
||||
def _collect_material_override_component(node):
|
||||
material_tags = {}
|
||||
for tag_name in (
|
||||
"material_ambient",
|
||||
"material_diffuse",
|
||||
"material_specular",
|
||||
"material_emission",
|
||||
"material_shininess",
|
||||
"material_basecolor",
|
||||
"material_roughness",
|
||||
"material_metallic",
|
||||
"material_ior",
|
||||
"material_effect_metallic_enabled",
|
||||
"material_effect_default_texture_enabled",
|
||||
"material_effect_parallax_enabled",
|
||||
"material_render_effect_signature",
|
||||
"material_texture_diffuse",
|
||||
"material_texture_normal",
|
||||
"material_texture_ior",
|
||||
"material_texture_roughness",
|
||||
"material_texture_parallax",
|
||||
"material_texture_metallic",
|
||||
"material_texture_emission",
|
||||
"material_texture_ao",
|
||||
"material_texture_alpha",
|
||||
"material_texture_detail",
|
||||
"material_texture_gloss",
|
||||
):
|
||||
if node.hasTag(tag_name):
|
||||
material_tags[tag_name] = node.getTag(tag_name)
|
||||
return material_tags
|
||||
|
||||
|
||||
def _resolve_imported_node_key(node, fallback_key: str) -> str:
|
||||
if node.hasTag("is_model_root"):
|
||||
for tag_name in ("imported_node_key", "source_model_node_key", "ssbo_tree_key", "tree_item_key"):
|
||||
if node.hasTag(tag_name):
|
||||
tag_value = str(node.getTag(tag_name) or "").strip()
|
||||
if tag_value:
|
||||
return tag_value
|
||||
return ""
|
||||
for tag_name in ("imported_node_key", "source_model_node_key", "ssbo_tree_key", "tree_item_key"):
|
||||
if node.hasTag(tag_name):
|
||||
tag_value = str(node.getTag(tag_name) or "").strip()
|
||||
if tag_value:
|
||||
return tag_value
|
||||
return fallback_key
|
||||
|
||||
|
||||
def _should_serialize_child_nodes(node, asset_guid: str, scripts: list[dict], runtime_interactive: bool, light_component: dict, material_component: dict) -> bool:
|
||||
if not node:
|
||||
return False
|
||||
if asset_guid:
|
||||
return True
|
||||
if scripts:
|
||||
return True
|
||||
if runtime_interactive:
|
||||
return True
|
||||
if light_component:
|
||||
return True
|
||||
if material_component:
|
||||
return True
|
||||
if node.hasTag("scene_transform_dirty") and node.getTag("scene_transform_dirty").lower() == "true":
|
||||
return True
|
||||
if node.hasTag("scene_material_dirty") and node.getTag("scene_material_dirty").lower() == "true":
|
||||
return True
|
||||
if node.hasTag("user_visible") and node.getTag("user_visible").lower() != "true":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _build_scene_components(camera_state: dict, gui_elements: list, lui_snapshot: dict) -> dict:
|
||||
return {
|
||||
"camera": dict(camera_state or {}),
|
||||
"gui": {"elements": list(gui_elements or [])},
|
||||
"lui": dict(lui_snapshot or {}),
|
||||
}
|
||||
|
||||
|
||||
def _build_scene_description_payload(
|
||||
*,
|
||||
asset_database,
|
||||
project_root: str,
|
||||
scene_guid: str,
|
||||
scene_name: str,
|
||||
scene_file_rel: str,
|
||||
root_nodes,
|
||||
cache_bam_path: str = "",
|
||||
cache_gui_path: str = "",
|
||||
cache_lui_path: str = "",
|
||||
gui_elements: list | None = None,
|
||||
lui_snapshot: dict | None = None,
|
||||
camera_state: dict | None = None,
|
||||
):
|
||||
gui_elements = list(gui_elements or [])
|
||||
lui_snapshot = dict(lui_snapshot or {})
|
||||
camera_state = dict(camera_state or {})
|
||||
|
||||
node_entries = []
|
||||
referenced_asset_guids = set()
|
||||
referenced_script_guids = set()
|
||||
|
||||
subtree_serializable_cache = {}
|
||||
|
||||
def _node_is_serializable(node) -> bool:
|
||||
if not node or node.isEmpty():
|
||||
return False
|
||||
|
||||
cache_key = id(node)
|
||||
if cache_key in subtree_serializable_cache:
|
||||
return subtree_serializable_cache[cache_key]
|
||||
|
||||
runtime_interactive = (
|
||||
node.hasTag("runtime_interactive")
|
||||
and node.getTag("runtime_interactive").lower() == "true"
|
||||
)
|
||||
scripts = []
|
||||
if node.hasTag("scripts_info"):
|
||||
try:
|
||||
scripts = json.loads(node.getTag("scripts_info"))
|
||||
except Exception:
|
||||
scripts = []
|
||||
|
||||
asset_guid = ""
|
||||
if node.hasTag("is_model_root"):
|
||||
asset_guid = "root"
|
||||
|
||||
light_component = _collect_light_component(node)
|
||||
material_component = _collect_material_override_component(node)
|
||||
|
||||
serializable_here = _should_serialize_child_nodes(
|
||||
node,
|
||||
asset_guid,
|
||||
scripts,
|
||||
runtime_interactive,
|
||||
light_component,
|
||||
material_component,
|
||||
)
|
||||
if serializable_here:
|
||||
subtree_serializable_cache[cache_key] = True
|
||||
return True
|
||||
|
||||
try:
|
||||
children = list(node.getChildren())
|
||||
except Exception:
|
||||
children = []
|
||||
result = any(_node_is_serializable(child) for child in children if child and not child.isEmpty())
|
||||
subtree_serializable_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def walk_nodes(children, parent_id="", force_include=False):
|
||||
for index, child in enumerate(list(children or [])):
|
||||
if not child or child.isEmpty():
|
||||
continue
|
||||
node_id = _node_path_id(parent_id, index)
|
||||
child_force_include = bool(force_include)
|
||||
if not child_force_include:
|
||||
try:
|
||||
child_force_include = (
|
||||
child.hasTag("is_model_root")
|
||||
and child.hasTag("ssbo_managed")
|
||||
and child.getTag("ssbo_managed").strip().lower() in ("1", "true", "yes", "on")
|
||||
)
|
||||
except Exception:
|
||||
child_force_include = False
|
||||
|
||||
# If we are force-including (e.g. ssbo_managed model skeleton),
|
||||
# we must include this node regardless of its individual "serializable" status.
|
||||
if not child_force_include and not _node_is_serializable(child):
|
||||
continue
|
||||
|
||||
node_name = child.getName()
|
||||
imported_node_key = _resolve_imported_node_key(child, node_id)
|
||||
runtime_interactive = (
|
||||
child.hasTag("runtime_interactive")
|
||||
and child.getTag("runtime_interactive").lower() == "true"
|
||||
)
|
||||
scripts = []
|
||||
if child.hasTag("scripts_info"):
|
||||
try:
|
||||
scripts = json.loads(child.getTag("scripts_info"))
|
||||
except Exception:
|
||||
scripts = []
|
||||
|
||||
asset_guid = ""
|
||||
asset_path = ""
|
||||
if child.hasTag("is_model_root"):
|
||||
for tag_name in ("model_path", "saved_model_path", "original_path"):
|
||||
if child.hasTag(tag_name):
|
||||
candidate = child.getTag(tag_name)
|
||||
if candidate:
|
||||
asset_record = asset_database.register_asset(candidate, copy_into_assets=False)
|
||||
if asset_record:
|
||||
asset_guid = asset_record.get("guid", "")
|
||||
asset_path = asset_record.get("asset_path", "")
|
||||
referenced_asset_guids.add(asset_guid)
|
||||
break
|
||||
|
||||
for script_info in scripts:
|
||||
script_guid = str(script_info.get("script_guid", "") or "").strip()
|
||||
asset_record = asset_database.get_asset(script_guid) if script_guid else {}
|
||||
if not asset_record:
|
||||
project_relative_path = str(script_info.get("project_relative_path", "") or "")
|
||||
if project_relative_path:
|
||||
script_abs_path = os.path.join(project_root, project_relative_path.replace("/", os.sep))
|
||||
asset_record = asset_database.register_asset(script_abs_path, copy_into_assets=False)
|
||||
if asset_record:
|
||||
resolved_guid = asset_record.get("guid", "")
|
||||
if resolved_guid:
|
||||
script_info["script_guid"] = resolved_guid
|
||||
referenced_script_guids.add(resolved_guid)
|
||||
asset_path_for_script = str(asset_record.get("asset_path", "") or "")
|
||||
if asset_path_for_script:
|
||||
script_info["project_relative_path"] = asset_path_for_script
|
||||
script_info["file"] = asset_path_for_script
|
||||
|
||||
light_component = _collect_light_component(child)
|
||||
material_component = _collect_material_override_component(child)
|
||||
|
||||
node_entries.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"parent_id": parent_id or None,
|
||||
"name": node_name,
|
||||
"imported_node_key": imported_node_key,
|
||||
"asset_guid": asset_guid,
|
||||
"asset_path": asset_path,
|
||||
"node_class": child.node().getClassType().getName() if child.node() else "",
|
||||
"transform": {
|
||||
"position": [child.getX(), child.getY(), child.getZ()],
|
||||
"rotation": [child.getH(), child.getP(), child.getR()],
|
||||
"scale": [child.getSx(), child.getSy(), child.getSz()],
|
||||
},
|
||||
"visibility": {
|
||||
"user_visible": (
|
||||
child.getTag("user_visible").lower() == "true"
|
||||
if child.hasTag("user_visible")
|
||||
else True
|
||||
)
|
||||
},
|
||||
"runtime_interactive": runtime_interactive,
|
||||
"scripts": scripts,
|
||||
"components": {
|
||||
"model": _collect_model_component(child, asset_guid, asset_path, imported_node_key),
|
||||
"scripts": _collect_script_component(scripts),
|
||||
"light": light_component,
|
||||
"material_overrides": material_component,
|
||||
"metadata": _collect_node_metadata_component(child, runtime_interactive),
|
||||
},
|
||||
"tags": {
|
||||
tag_name: child.getTag(tag_name)
|
||||
for tag_name in child.getTagKeys()
|
||||
},
|
||||
}
|
||||
)
|
||||
# Recursive step: if force_include is True, we must always recurse to capture the full skeleton.
|
||||
if (
|
||||
child_force_include
|
||||
or _should_serialize_child_nodes(
|
||||
child,
|
||||
asset_guid,
|
||||
scripts,
|
||||
runtime_interactive,
|
||||
light_component,
|
||||
material_component,
|
||||
)
|
||||
or any(
|
||||
_node_is_serializable(grandchild)
|
||||
for grandchild in list(child.getChildren()) if grandchild and not grandchild.isEmpty()
|
||||
)
|
||||
):
|
||||
try:
|
||||
child_nodes = list(child.getChildren())
|
||||
except Exception:
|
||||
child_nodes = []
|
||||
walk_nodes(child_nodes, node_id, force_include=child_force_include)
|
||||
|
||||
walk_nodes(root_nodes)
|
||||
|
||||
description = {
|
||||
"schema_version": METASCENE_SCHEMA_VERSION,
|
||||
"scene_guid": scene_guid,
|
||||
"scene_name": scene_name,
|
||||
"scene_file": scene_file_rel,
|
||||
"saved_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"cache": {
|
||||
"scene_bam": relative_project_path(project_root, cache_bam_path),
|
||||
"gui_json": relative_project_path(project_root, cache_gui_path),
|
||||
"lui_json": relative_project_path(project_root, cache_lui_path),
|
||||
},
|
||||
"referenced_asset_guids": sorted(guid for guid in referenced_asset_guids if guid),
|
||||
"referenced_script_guids": sorted(guid for guid in referenced_script_guids if guid),
|
||||
"scene_components": _build_scene_components(camera_state, gui_elements, lui_snapshot),
|
||||
"camera": camera_state,
|
||||
"gui": gui_elements,
|
||||
"lui": lui_snapshot,
|
||||
"nodes": node_entries,
|
||||
}
|
||||
|
||||
return description
|
||||
def build_scene_description_from_world(
|
||||
*,
|
||||
asset_database,
|
||||
project_root: str,
|
||||
scene_guid: str,
|
||||
scene_name: str,
|
||||
scene_file_rel: str,
|
||||
root_nodes,
|
||||
cache_bam_path: str = "",
|
||||
cache_gui_path: str = "",
|
||||
cache_lui_path: str = "",
|
||||
gui_elements: list | None = None,
|
||||
lui_snapshot: dict | None = None,
|
||||
camera_state: dict | None = None,
|
||||
):
|
||||
return _build_scene_description_payload(
|
||||
asset_database=asset_database,
|
||||
project_root=project_root,
|
||||
scene_guid=scene_guid,
|
||||
scene_name=scene_name,
|
||||
scene_file_rel=scene_file_rel,
|
||||
root_nodes=root_nodes,
|
||||
cache_bam_path=cache_bam_path,
|
||||
cache_gui_path=cache_gui_path,
|
||||
cache_lui_path=cache_lui_path,
|
||||
gui_elements=gui_elements,
|
||||
lui_snapshot=lui_snapshot,
|
||||
camera_state=camera_state,
|
||||
)
|
||||
|
||||
|
||||
def build_runtime_scene(scene_description: dict):
|
||||
nodes = list(scene_description.get("nodes", []) or [])
|
||||
scene_components = dict(scene_description.get("scene_components", {}) or {})
|
||||
camera_component = dict(scene_components.get("camera", {}) or scene_description.get("camera", {}) or {})
|
||||
gui_component = dict(scene_components.get("gui", {}) or {})
|
||||
gui_elements = list(gui_component.get("elements", []) or scene_description.get("gui", []) or [])
|
||||
lui_component = dict(scene_components.get("lui", {}) or scene_description.get("lui", {}) or {})
|
||||
interactive_model_names = []
|
||||
interactive_node_ids = []
|
||||
static_model_names = []
|
||||
static_node_ids = []
|
||||
for node in nodes:
|
||||
if not node.get("asset_guid"):
|
||||
continue
|
||||
metadata_component = dict((node.get("components", {}) or {}).get("metadata", {}) or {})
|
||||
has_animations = str(
|
||||
metadata_component.get(
|
||||
"has_animations",
|
||||
metadata_component.get(
|
||||
"saved_has_animations",
|
||||
(node.get("tags", {}) or {}).get(
|
||||
"has_animations",
|
||||
(node.get("tags", {}) or {}).get("saved_has_animations", ""),
|
||||
),
|
||||
),
|
||||
)
|
||||
or ""
|
||||
).lower() == "true"
|
||||
can_create_actor = str(
|
||||
metadata_component.get(
|
||||
"can_create_actor_from_memory",
|
||||
metadata_component.get(
|
||||
"saved_can_create_actor_from_memory",
|
||||
(node.get("tags", {}) or {}).get(
|
||||
"can_create_actor_from_memory",
|
||||
(node.get("tags", {}) or {}).get("saved_can_create_actor_from_memory", ""),
|
||||
),
|
||||
),
|
||||
)
|
||||
or ""
|
||||
).lower() == "true"
|
||||
if has_animations or can_create_actor:
|
||||
interactive_node_ids.append(node.get("node_id", ""))
|
||||
interactive_model_names.append(node.get("name", ""))
|
||||
continue
|
||||
if node.get("runtime_interactive"):
|
||||
interactive_node_ids.append(node.get("node_id", ""))
|
||||
interactive_model_names.append(node.get("name", ""))
|
||||
continue
|
||||
if node.get("scripts"):
|
||||
interactive_node_ids.append(node.get("node_id", ""))
|
||||
interactive_model_names.append(node.get("name", ""))
|
||||
continue
|
||||
static_node_ids.append(node.get("node_id", ""))
|
||||
static_model_names.append(node.get("name", ""))
|
||||
|
||||
return {
|
||||
"schema_version": RUNTIME_SCENE_SCHEMA_VERSION,
|
||||
"scene_guid": scene_description.get("scene_guid", ""),
|
||||
"scene_name": scene_description.get("scene_name", ""),
|
||||
"cook": {
|
||||
"interactive_node_ids": sorted({node_id for node_id in interactive_node_ids if node_id}),
|
||||
"interactive_model_names": sorted({name for name in interactive_model_names if name}),
|
||||
"static_node_ids": sorted({node_id for node_id in static_node_ids if node_id}),
|
||||
"static_model_names": sorted({name for name in static_model_names if name}),
|
||||
},
|
||||
"interactive_model_names": sorted({name for name in interactive_model_names if name}),
|
||||
"scene_components": {
|
||||
"camera": camera_component,
|
||||
"gui": {"elements": gui_elements},
|
||||
"lui": lui_component,
|
||||
},
|
||||
"camera": camera_component,
|
||||
"gui": gui_elements,
|
||||
"lui": lui_component,
|
||||
"nodes": nodes,
|
||||
"referenced_asset_guids": scene_description.get("referenced_asset_guids", []) or [],
|
||||
"referenced_script_guids": scene_description.get("referenced_script_guids", []) or [],
|
||||
}
|
||||
|
||||
|
||||
def build_scene_cook_manifest(scene_description: dict, runtime_scene: dict):
|
||||
runtime_cook = dict(runtime_scene.get("cook", {}) or {})
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"scene_guid": scene_description.get("scene_guid", ""),
|
||||
"scene_name": scene_description.get("scene_name", ""),
|
||||
"source_scene_file": scene_description.get("scene_file", ""),
|
||||
"node_count": len(scene_description.get("nodes", []) or []),
|
||||
"interactive_node_ids": list(runtime_cook.get("interactive_node_ids", []) or []),
|
||||
"interactive_model_names": list(runtime_cook.get("interactive_model_names", []) or []),
|
||||
"static_node_ids": list(runtime_cook.get("static_node_ids", []) or []),
|
||||
"static_model_names": list(runtime_cook.get("static_model_names", []) or []),
|
||||
"referenced_asset_guids": list(scene_description.get("referenced_asset_guids", []) or []),
|
||||
"referenced_script_guids": list(scene_description.get("referenced_script_guids", []) or []),
|
||||
"scene_components": dict(scene_description.get("scene_components", {}) or {}),
|
||||
"cache": dict(scene_description.get("cache", {}) or {}),
|
||||
"saved_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
|
||||
def build_runtime_manifest(project_config: dict, runtime_scenes: list[dict], asset_records: list[dict]):
|
||||
scene_guids = [scene.get("scene_guid", "") for scene in runtime_scenes]
|
||||
startup_scene_guid = project_config.get("startup_scene_guid", "")
|
||||
if startup_scene_guid not in scene_guids:
|
||||
startup_scene_guid = scene_guids[0] if scene_guids else ""
|
||||
|
||||
total_interactive_nodes = 0
|
||||
total_static_nodes = 0
|
||||
total_interactive_models = 0
|
||||
total_static_models = 0
|
||||
for scene in runtime_scenes:
|
||||
cook = dict(scene.get("cook", {}) or {})
|
||||
total_interactive_nodes += len(list(cook.get("interactive_node_ids", []) or []))
|
||||
total_static_nodes += len(list(cook.get("static_node_ids", []) or []))
|
||||
total_interactive_models += len(list(cook.get("interactive_model_names", []) or []))
|
||||
total_static_models += len(list(cook.get("static_model_names", []) or []))
|
||||
|
||||
return {
|
||||
"schema_version": MANIFEST_SCHEMA_VERSION,
|
||||
"project_name": project_config.get("name", f"{ENGINE_NAME}Project"),
|
||||
"startup_scene_guid": startup_scene_guid,
|
||||
"scene_guids": scene_guids,
|
||||
"scenes": [
|
||||
{
|
||||
"guid": scene.get("scene_guid", ""),
|
||||
"name": scene.get("scene_name", ""),
|
||||
"runtime_path": f"scenes/{scene.get('scene_guid', '')}.runtime.json",
|
||||
}
|
||||
for scene in runtime_scenes
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"guid": asset.get("guid", ""),
|
||||
"asset_type": asset.get("asset_type", ""),
|
||||
"asset_path": asset.get("asset_path", ""),
|
||||
"imported_cache": asset.get("imported_cache", {}) or {},
|
||||
}
|
||||
for asset in asset_records
|
||||
if asset
|
||||
],
|
||||
"cook_summary": {
|
||||
"scene_count": len(runtime_scenes),
|
||||
"asset_count": len(asset_records),
|
||||
"interactive_node_count": total_interactive_nodes,
|
||||
"static_node_count": total_static_nodes,
|
||||
"interactive_model_count": total_interactive_models,
|
||||
"static_model_count": total_static_models,
|
||||
},
|
||||
"build_settings": project_config.get("build_settings", {}) or {},
|
||||
}
|
||||
2405
project/webgl_packager.py
Normal file
@ -1,90 +0,0 @@
|
||||
# 项目部署指南
|
||||
|
||||
## 🚀 在新电脑上运行此项目
|
||||
|
||||
### 方法1:使用Conda(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 安装Miniconda
|
||||
wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh
|
||||
bash Miniconda3-latest-Linux-x86_64.sh
|
||||
|
||||
# 2. 克隆项目
|
||||
git clone <your-repo-url>
|
||||
cd eg
|
||||
|
||||
# 3. 创建conda环境
|
||||
conda env create -f environment.yml
|
||||
|
||||
# 4. 激活环境
|
||||
conda activate eg-project
|
||||
|
||||
# 5. 运行项目
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 方法2:使用virtualenv + pip
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone <your-repo-url>
|
||||
cd eg
|
||||
|
||||
# 2. 创建虚拟环境
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# 或 venv\Scripts\activate # Windows
|
||||
|
||||
# 3. 安装依赖
|
||||
pip install -r clean-requirements.txt
|
||||
|
||||
# 4. 运行项目
|
||||
python main.py
|
||||
```
|
||||
|
||||
## 📁 需要复制的文件
|
||||
|
||||
**必须复制:**
|
||||
- `main.py` - 主程序
|
||||
- `gui_preview_window.py` - GUI窗口
|
||||
- `demo/` - 演示文件夹
|
||||
- `environment.yml` - Conda环境配置
|
||||
- `clean-requirements.txt` - 核心依赖
|
||||
|
||||
**不要复制:**
|
||||
- `.conda/` - 虚拟环境文件夹
|
||||
- `__pycache__/` - Python缓存
|
||||
- `requirements.txt` - 包含系统包的混乱依赖
|
||||
|
||||
## 🔧 Cursor IDE 设置
|
||||
|
||||
1. **打开项目**:在Cursor中打开项目文件夹
|
||||
2. **选择解释器**:
|
||||
- `Ctrl+Shift+P` → "Python: Select Interpreter"
|
||||
- 选择虚拟环境中的Python路径
|
||||
3. **验证环境**:检查状态栏显示正确的Python版本
|
||||
|
||||
## 📦 项目依赖说明
|
||||
|
||||
- **Panda3D**: 3D图形引擎
|
||||
- **PyQt5/PySide6**: GUI框架
|
||||
- **Pillow**: 图像处理
|
||||
- **python-dotenv**: 环境变量管理
|
||||
- **pyassimp**: 3D模型加载
|
||||
|
||||
## 🌍 跨平台注意事项
|
||||
|
||||
- Linux/Mac: 使用 `source venv/bin/activate`
|
||||
- Windows: 使用 `venv\Scripts\activate`
|
||||
- 某些依赖可能需要系统级安装(如OpenGL库)
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
**Q: 无法安装Panda3D?**
|
||||
A: 确保系统有OpenGL支持,或使用conda安装
|
||||
|
||||
**Q: PyQt5无法运行?**
|
||||
A: 可能需要安装系统级GUI依赖
|
||||
|
||||
**Q: 导入错误?**
|
||||
A: 检查Python版本(需要3.10+)
|
||||
@ -1,6 +1,7 @@
|
||||
Panda3D>=1.10.15
|
||||
PyQt5>=5.15.9
|
||||
PySide6>=6.8.1
|
||||
Pillow>=9.0.1
|
||||
python-dotenv>=1.0.1
|
||||
pyassimp>=5.2.5
|
||||
Panda3D==1.10.15
|
||||
imgui-bundle
|
||||
Pillow>=9.0.1
|
||||
numpy>=1.24
|
||||
aiohttp>=3.9
|
||||
openvr==2.2.0
|
||||
pyassimp>=5.2.5
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
# This file may be used to create an environment using:
|
||||
# $ conda create --name <env> --file <this file>
|
||||
# platform: linux-64
|
||||
# created-by: conda 25.5.1
|
||||
@EXPLICIT
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/_libgcc_mutex-0.1-main.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/ca-certificates-2025.2.25-h06a4308_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/ld_impl_linux-64-2.40-h12ee557_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/libstdcxx-ng-11.2.0-h1234567_1.conda
|
||||
https://repo.anaconda.com/pkgs/main/noarch/tzdata-2025b-h04d1e81_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/libgomp-11.2.0-h1234567_1.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/_openmp_mutex-5.1-1_gnu.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/libgcc-ng-11.2.0-h1234567_1.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/bzip2-1.0.8-h5eee18b_6.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/expat-2.7.1-h6a678d5_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/libffi-3.4.4-h6a678d5_1.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/libuuid-1.41.5-h5eee18b_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/ncurses-6.4-h6a678d5_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/openssl-3.0.16-h5eee18b_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/pthread-stubs-0.3-h0ce48e5_1.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/xorg-libxau-1.0.12-h9b100fa_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/xorg-libxdmcp-1.1.5-h9b100fa_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/xorg-xorgproto-2024.1-h5eee18b_1.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/xz-5.6.4-h5eee18b_1.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/zlib-1.2.13-h5eee18b_1.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/libxcb-1.17.0-h9b100fa_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/readline-8.2-h5eee18b_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/sqlite-3.45.3-h5eee18b_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/xorg-libx11-1.8.12-h9b100fa_1.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/tk-8.6.14-h993c535_1.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/python-3.10.18-h1a3bd86_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/setuptools-78.1.1-py310h06a4308_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/linux-64/wheel-0.45.1-py310h06a4308_0.conda
|
||||
https://repo.anaconda.com/pkgs/main/noarch/pip-25.1-pyhc872135_2.conda
|
||||
@ -1,15 +0,0 @@
|
||||
name: eg-project
|
||||
channels:
|
||||
- conda-forge
|
||||
- defaults
|
||||
dependencies:
|
||||
- python=3.10
|
||||
- pip
|
||||
- pip:
|
||||
- Panda3D>=1.10.15
|
||||
- PyQt5>=5.15.9
|
||||
- PySide6>=6.8.1
|
||||
- Pillow>=9.0.1
|
||||
- python-dotenv>=1.0.1
|
||||
- pyassimp>=5.2.5
|
||||
- six
|
||||
@ -1,102 +1,14 @@
|
||||
apturl==0.5.2
|
||||
bcrypt==3.2.0
|
||||
beautifulsoup4==4.10.0
|
||||
blinker==1.4
|
||||
Brlapi==0.8.3
|
||||
certifi==2020.6.20
|
||||
chardet==4.0.0
|
||||
click==8.0.3
|
||||
colorama==0.4.4
|
||||
command-not-found==0.3
|
||||
cryptography==3.4.8
|
||||
cupshelpers==1.0
|
||||
dbus-python==1.2.18
|
||||
defer==1.0.6
|
||||
DirectFolderBrowser==22.1
|
||||
DirectGuiDesigner==22.4.1
|
||||
DirectGuiExtension==22.9
|
||||
distro==1.7.0
|
||||
distro-info==1.1+ubuntu0.2
|
||||
duplicity==0.8.21
|
||||
fasteners==0.14.1
|
||||
future==0.18.2
|
||||
html5lib==1.1
|
||||
httplib2==0.20.2
|
||||
idna==3.3
|
||||
importlib-metadata==4.6.4
|
||||
jeepney==0.7.1
|
||||
keyring==23.5.0
|
||||
language-selector==0.1
|
||||
launchpadlib==1.10.16
|
||||
lazr.restfulclient==0.14.4
|
||||
lazr.uri==1.0.6
|
||||
lockfile==0.12.2
|
||||
louis==3.20.0
|
||||
lxml==4.8.0
|
||||
macaroonbakery==1.3.1
|
||||
Mako==1.1.3
|
||||
MarkupSafe==2.0.1
|
||||
monotonic==1.6
|
||||
more-itertools==8.10.0
|
||||
netifaces==0.11.0
|
||||
oauthlib==3.2.0
|
||||
olefile==0.46
|
||||
Panda3D==1.10.15
|
||||
panda3d-frame==22.10.post1
|
||||
Panda3DNodeEditor==22.5
|
||||
paramiko==2.9.3
|
||||
pexpect==4.8.0
|
||||
Pillow==9.0.1
|
||||
platformdirs==4.3.6
|
||||
protobuf==3.12.4
|
||||
ptyprocess==0.7.0
|
||||
pyassimp==5.2.5
|
||||
pycairo==1.20.1
|
||||
pycups==2.0.1
|
||||
Pygments==2.11.2
|
||||
PyGObject==3.42.1
|
||||
PyJWT==2.3.0
|
||||
pymacaroons==0.13.0
|
||||
PyNaCl==1.5.0
|
||||
pyparsing==2.4.7
|
||||
PyQt5==5.15.9
|
||||
pyqt5-plugins==5.15.9.2.3
|
||||
PyQt5-Qt5==5.15.2
|
||||
pyqt5-tools==5.15.9.3.3
|
||||
PyQt5_sip==12.16.1
|
||||
pyRFC3339==1.1
|
||||
PySide6==6.8.1
|
||||
PySide6_Addons==6.8.1
|
||||
PySide6_Essentials==6.8.1
|
||||
python-apt==2.4.0+ubuntu4
|
||||
python-dateutil==2.8.1
|
||||
python-debian==0.1.43+ubuntu1.1
|
||||
python-dotenv==1.0.1
|
||||
pytz==2022.1
|
||||
pyxdg==0.27
|
||||
PyYAML==5.4.1
|
||||
QPanda3D==0.2.10
|
||||
qt5-applications==5.15.2.2.3
|
||||
qt5-tools==5.15.2.1.3
|
||||
reportlab==3.6.8
|
||||
requests==2.25.1
|
||||
SceneEditor==22.5
|
||||
screen-resolution-extra==0.0.0
|
||||
SecretStorage==3.3.1
|
||||
shiboken6==6.8.1
|
||||
six==1.16.0
|
||||
soupsieve==2.3.1
|
||||
systemd-python==234
|
||||
ubuntu-drivers-common==0.0.0
|
||||
ubuntu-pro-client==8001
|
||||
ufw==0.36.1
|
||||
unattended-upgrades==0.1
|
||||
urllib3==1.26.5
|
||||
usb-creator==0.3.7
|
||||
wadllib==1.3.6
|
||||
webencodings==0.5.1
|
||||
xdg==5
|
||||
xkit==0.0.0
|
||||
zipp==1.0.0
|
||||
openvr==2.2.0
|
||||
imgui-bundle
|
||||
# Core runtime dependencies for the current ImGui editor stack.
|
||||
Panda3D==1.10.15
|
||||
imgui-bundle
|
||||
Pillow>=9.0.1
|
||||
numpy>=1.24
|
||||
aiohttp>=3.9
|
||||
openvr==2.2.0
|
||||
|
||||
# Asset import / conversion helpers.
|
||||
pyassimp>=5.2.5
|
||||
|
||||
# Optional features:
|
||||
# playwright>=1.40 # Required by core/imgui_webview.py
|
||||
# bpy # Required only when using Blender-based conversion flows
|
||||
|
||||
328
scene/gltf_support.py
Normal file
@ -0,0 +1,328 @@
|
||||
"""Helpers for lightweight glTF metadata probing and static BAM caching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import tempfile
|
||||
|
||||
|
||||
_GLTF_METADATA_CACHE = {}
|
||||
|
||||
|
||||
def is_gltf_path(file_path: str) -> bool:
|
||||
ext = os.path.splitext(str(file_path or ""))[1].lower()
|
||||
return ext in {".gltf", ".glb"}
|
||||
|
||||
|
||||
def _to_os_specific_path(file_path: str) -> str:
|
||||
path_text = os.fspath(file_path or "")
|
||||
if not path_text:
|
||||
return path_text
|
||||
if os.path.exists(path_text):
|
||||
return os.path.normpath(path_text)
|
||||
|
||||
try:
|
||||
from panda3d.core import Filename
|
||||
|
||||
for ctor_name in ("fromOsSpecificW", "from_os_specific_w", "fromOsSpecific", "from_os_specific"):
|
||||
ctor = getattr(Filename, ctor_name, None)
|
||||
if not ctor:
|
||||
continue
|
||||
try:
|
||||
panda_filename = ctor(path_text)
|
||||
os_path = panda_filename.to_os_specific()
|
||||
if os_path and os.path.exists(os_path):
|
||||
return os.path.normpath(os_path)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
converted = Filename(path_text).to_os_specific()
|
||||
if converted and os.path.exists(converted):
|
||||
return os.path.normpath(converted)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if len(path_text) >= 3 and path_text[0] in ("/", "\\") and path_text[1].isalpha() and path_text[2] in ("/", "\\"):
|
||||
drive_path = f"{path_text[1]}:{path_text[2:]}"
|
||||
drive_path = os.path.normpath(drive_path)
|
||||
if os.path.exists(drive_path):
|
||||
return drive_path
|
||||
|
||||
return os.path.normpath(path_text)
|
||||
|
||||
|
||||
def _load_gltf_json_payload(file_path: str):
|
||||
file_path = _to_os_specific_path(file_path)
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
try:
|
||||
if ext == ".gltf":
|
||||
with open(file_path, "r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
if ext != ".glb":
|
||||
return None
|
||||
|
||||
with open(file_path, "rb") as handle:
|
||||
header = handle.read(12)
|
||||
if len(header) != 12:
|
||||
return None
|
||||
magic, _version, total_length = struct.unpack("<4sII", header)
|
||||
if magic != b"glTF":
|
||||
return None
|
||||
|
||||
while handle.tell() < total_length:
|
||||
chunk_header = handle.read(8)
|
||||
if len(chunk_header) != 8:
|
||||
break
|
||||
chunk_length, chunk_type = struct.unpack("<I4s", chunk_header)
|
||||
chunk_data = handle.read(chunk_length)
|
||||
if chunk_type == b"JSON":
|
||||
json_text = chunk_data.decode("utf-8", errors="ignore").rstrip("\x00 \t\r\n")
|
||||
return json.loads(json_text)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def probe_gltf_metadata(file_path: str) -> dict:
|
||||
file_path = _to_os_specific_path(file_path)
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return {
|
||||
"is_gltf": False,
|
||||
"has_animations": False,
|
||||
"animation_count": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
stat_info = os.stat(file_path)
|
||||
cache_key = (
|
||||
os.path.abspath(file_path),
|
||||
int(stat_info.st_mtime_ns),
|
||||
int(stat_info.st_size),
|
||||
)
|
||||
cached = _GLTF_METADATA_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return dict(cached)
|
||||
except Exception:
|
||||
cache_key = None
|
||||
|
||||
payload = _load_gltf_json_payload(file_path)
|
||||
if not payload:
|
||||
result = {
|
||||
"is_gltf": False,
|
||||
"has_animations": False,
|
||||
"animation_count": 0,
|
||||
}
|
||||
if cache_key is not None:
|
||||
_GLTF_METADATA_CACHE.clear()
|
||||
_GLTF_METADATA_CACHE[cache_key] = dict(result)
|
||||
return result
|
||||
|
||||
animations = payload.get("animations") or []
|
||||
result = {
|
||||
"is_gltf": True,
|
||||
"has_animations": bool(animations),
|
||||
"animation_count": len(animations),
|
||||
}
|
||||
if cache_key is not None:
|
||||
_GLTF_METADATA_CACHE.clear()
|
||||
_GLTF_METADATA_CACHE[cache_key] = dict(result)
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_cache_root(project_root: str = "") -> str:
|
||||
project_root = os.path.normpath(project_root or "")
|
||||
if project_root and os.path.isdir(project_root):
|
||||
try:
|
||||
from project.project_schema import ProjectLayout, ensure_project_directories
|
||||
|
||||
layout = ProjectLayout(project_root)
|
||||
ensure_project_directories(layout)
|
||||
cache_root = os.path.join(layout.imported_root, "__gltf_visual_cache__")
|
||||
os.makedirs(cache_root, exist_ok=True)
|
||||
return cache_root
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cache_root = os.path.join(tempfile.gettempdir(), "EG_gltf_visual_cache")
|
||||
os.makedirs(cache_root, exist_ok=True)
|
||||
return cache_root
|
||||
|
||||
|
||||
def _choose_visual_cache_builder(file_path: str, *, skip_animations: bool) -> str:
|
||||
meta = probe_gltf_metadata(file_path)
|
||||
if meta.get("is_gltf") and not meta.get("has_animations") and bool(skip_animations):
|
||||
return "fluent"
|
||||
return "gltf"
|
||||
|
||||
|
||||
def _build_cache_key(file_path: str, *, skip_animations: bool, flatten_nodes: bool, builder: str) -> str:
|
||||
stat_info = os.stat(file_path)
|
||||
digest = hashlib.sha1(
|
||||
"|".join(
|
||||
[
|
||||
os.path.abspath(file_path),
|
||||
str(int(stat_info.st_mtime_ns)),
|
||||
str(int(stat_info.st_size)),
|
||||
f"skip_anim={int(bool(skip_animations))}",
|
||||
f"flatten_nodes={int(bool(flatten_nodes))}",
|
||||
f"builder={builder}",
|
||||
"visual_cache_v=3",
|
||||
]
|
||||
).encode("utf-8", errors="ignore")
|
||||
).hexdigest()
|
||||
return digest
|
||||
|
||||
|
||||
def get_gltf_visual_bam_path(
|
||||
file_path: str,
|
||||
*,
|
||||
project_root: str = "",
|
||||
skip_animations: bool = True,
|
||||
flatten_nodes: bool = False,
|
||||
) -> str:
|
||||
if not is_gltf_path(file_path):
|
||||
return file_path
|
||||
file_path = _to_os_specific_path(file_path)
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return file_path
|
||||
|
||||
builder = _choose_visual_cache_builder(file_path, skip_animations=skip_animations)
|
||||
cache_key = _build_cache_key(
|
||||
file_path,
|
||||
skip_animations=skip_animations,
|
||||
flatten_nodes=flatten_nodes,
|
||||
builder=builder,
|
||||
)
|
||||
cache_root = _resolve_cache_root(project_root)
|
||||
cache_dir = os.path.join(cache_root, cache_key)
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return os.path.join(cache_dir, "visual_scene.bam")
|
||||
|
||||
|
||||
def ensure_gltf_visual_bam(
|
||||
file_path: str,
|
||||
*,
|
||||
project_root: str = "",
|
||||
skip_animations: bool = True,
|
||||
flatten_nodes: bool = False,
|
||||
) -> str:
|
||||
if not is_gltf_path(file_path):
|
||||
return file_path
|
||||
file_path = _to_os_specific_path(file_path)
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return file_path
|
||||
|
||||
cache_bam = get_gltf_visual_bam_path(
|
||||
file_path,
|
||||
project_root=project_root,
|
||||
skip_animations=skip_animations,
|
||||
flatten_nodes=flatten_nodes,
|
||||
)
|
||||
if os.path.exists(cache_bam):
|
||||
return cache_bam
|
||||
|
||||
temp_bam = f"{cache_bam}.tmp"
|
||||
model_np = None
|
||||
builder = _choose_visual_cache_builder(file_path, skip_animations=skip_animations)
|
||||
try:
|
||||
from panda3d.core import Filename
|
||||
|
||||
if builder == "fluent":
|
||||
try:
|
||||
from direct.showbase import ShowBaseGlobal
|
||||
from panda3d.core import LoaderOptions
|
||||
|
||||
base = getattr(ShowBaseGlobal, "base", None)
|
||||
if base and getattr(base, "loader", None):
|
||||
loader_options = LoaderOptions()
|
||||
if hasattr(loader_options, "LF_no_cache"):
|
||||
loader_options.setFlags(loader_options.getFlags() | loader_options.LF_no_cache)
|
||||
elif hasattr(loader_options, "LFNoCache"):
|
||||
loader_options.setFlags(loader_options.getFlags() | loader_options.LFNoCache)
|
||||
model_np = load_model_fluent(
|
||||
base.loader,
|
||||
Filename.from_os_specific(file_path),
|
||||
loader_options,
|
||||
)
|
||||
if model_np and not model_np.is_empty():
|
||||
print(f"[glTF缓存] 静态glTF使用流畅加载器构建可见BAM: {file_path}")
|
||||
if not model_np or model_np.is_empty():
|
||||
builder = "gltf"
|
||||
except Exception as exc:
|
||||
print(f"[glTF缓存] 流畅模式构建BAM失败,回退 gltf: {exc}")
|
||||
builder = "gltf"
|
||||
|
||||
if builder == "gltf":
|
||||
import gltf
|
||||
from panda3d.core import NodePath
|
||||
|
||||
settings = gltf.GltfSettings(
|
||||
skip_animations=bool(skip_animations),
|
||||
flatten_nodes=bool(flatten_nodes),
|
||||
)
|
||||
model_root = gltf.load_model(os.path.abspath(file_path), settings)
|
||||
if not model_root:
|
||||
return file_path
|
||||
|
||||
model_np = NodePath(model_root)
|
||||
if model_np.is_empty():
|
||||
return file_path
|
||||
|
||||
temp_filename = Filename.from_os_specific(temp_bam)
|
||||
if not model_np.write_bam_file(temp_filename):
|
||||
return file_path
|
||||
|
||||
os.replace(temp_bam, cache_bam)
|
||||
return cache_bam
|
||||
except Exception as exc:
|
||||
print(f"[glTF缓存] 构建可见BAM缓存失败: {exc}")
|
||||
return file_path
|
||||
finally:
|
||||
try:
|
||||
if model_np and not model_np.isEmpty():
|
||||
model_np.removeNode()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if os.path.exists(temp_bam):
|
||||
os.remove(temp_bam)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def load_model_fluent(loader, file_path, *args, **kwargs):
|
||||
"""
|
||||
加载 glTF/glb 模型时临时禁用 panda3d-gltf 插件,
|
||||
强制 Panda3D 使用下一个可用的加载器(如 Assimp 或自带轻量加载器)。
|
||||
这对于静态大场景能显著提高加载后的流畅度。
|
||||
"""
|
||||
from panda3d.core import ConfigVariableList
|
||||
|
||||
cvl = ConfigVariableList("load-file-type")
|
||||
removed_entries = []
|
||||
|
||||
# 扫描并移除所有涉及 panda3d-gltf 的注册项
|
||||
i = 0
|
||||
while i < cvl.get_num_values():
|
||||
val = cvl.get_string_value(i)
|
||||
if "panda3d-gltf" in val.lower():
|
||||
removed_entries.append(val)
|
||||
cvl.remove_value(i)
|
||||
else:
|
||||
i += 1
|
||||
|
||||
try:
|
||||
# 调用实际加载器
|
||||
print(f"[glTF智能加载] 正在以流畅模式加载 (跳过 panda3d-gltf): {file_path}")
|
||||
return loader.loadModel(file_path, *args, **kwargs)
|
||||
finally:
|
||||
# 恢复注册项,以便后续需要动画功能的模型仍能使用它
|
||||
for entry in removed_entries:
|
||||
cvl.add_value(entry)
|
||||
523
scene/scene_manager_convert_tiles_mixin.py
Normal file
@ -0,0 +1,523 @@
|
||||
"""Scene manager conversion and tileset operations."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import json
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from panda3d.core import (
|
||||
ModelPool, ModelRoot, Filename, NodePath, GeomNode, Material, Vec4, Vec3,
|
||||
MaterialAttrib, ColorAttrib, Point3, CollisionNode, CollisionSphere, CollisionBox,
|
||||
BitMask32, TransparencyAttrib, LColor, TransformState, RenderModeAttrib
|
||||
)
|
||||
from panda3d.egg import EggData, EggVertexPool
|
||||
from direct.actor.Actor import Actor
|
||||
from RenderPipelineFile.rpplugins.smaa.jitters import halton_seq
|
||||
from scene import util
|
||||
from core.editor_context import get_editor_context
|
||||
|
||||
class SceneManagerConvertTilesMixin:
|
||||
def _get_tree_widget(self):
|
||||
"""安全获取树形控件"""
|
||||
return get_editor_context(self.world).get_tree_widget()
|
||||
|
||||
def _shouldConvertToGLB(self, filepath):
|
||||
"""判断是否应该转换为GLB格式"""
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
# 需要转换的格式:FBX, OBJ, DAE等(但不转换已经是GLB/GLTF的)
|
||||
convert_formats = ['.fbx', '.obj', '.dae', '.3ds', '.blend']
|
||||
return ext in convert_formats
|
||||
|
||||
def _convertToGLBWithProgress(self, filepath):
|
||||
"""带进度显示的GLB转换"""
|
||||
class _ConsoleProgress:
|
||||
def __init__(self, label):
|
||||
self._value = 0
|
||||
self._label = label
|
||||
|
||||
def setWindowTitle(self, title):
|
||||
print(f"[GLB转换] {title}")
|
||||
|
||||
def setWindowModality(self, _):
|
||||
return None
|
||||
|
||||
def show(self):
|
||||
print(f"[GLB转换] {self._label}")
|
||||
|
||||
def hide(self):
|
||||
print("[GLB转换] 完成")
|
||||
|
||||
def setValue(self, value):
|
||||
value = int(value)
|
||||
if value != self._value:
|
||||
self._value = value
|
||||
print(f"[GLB转换] 进度: {self._value}%")
|
||||
|
||||
def setLabelText(self, text):
|
||||
if text != self._label:
|
||||
self._label = text
|
||||
print(f"[GLB转换] {self._label}")
|
||||
|
||||
progress = _ConsoleProgress("正在转换模型格式以获得更好的动画支持...")
|
||||
progress.setWindowTitle("模型格式转换")
|
||||
progress.show()
|
||||
|
||||
try:
|
||||
result = self._convertToGLB(filepath, progress)
|
||||
progress.hide()
|
||||
return result
|
||||
except Exception as e:
|
||||
progress.hide()
|
||||
print(f"转换过程出错: {e}")
|
||||
return self._convertToGLB(filepath)
|
||||
|
||||
def _convertToGLB(self, filepath, progress=None):
|
||||
"""将模型文件转换为GLB格式"""
|
||||
try:
|
||||
print(f"[GLB转换] 开始转换: {filepath}")
|
||||
|
||||
if progress:
|
||||
progress.setValue(10)
|
||||
progress.setLabelText("准备转换...")
|
||||
|
||||
# 准备输出路径
|
||||
base_name = os.path.splitext(os.path.basename(filepath))[0]
|
||||
output_dir = os.path.dirname(filepath)
|
||||
glb_path = os.path.join(output_dir, f"{base_name}_auto_converted.glb")
|
||||
|
||||
# 如果已经存在转换后的文件,直接使用
|
||||
if os.path.exists(glb_path):
|
||||
# 检查文件时间,如果原文件更新则重新转换
|
||||
original_time = os.path.getmtime(filepath)
|
||||
converted_time = os.path.getmtime(glb_path)
|
||||
if converted_time > original_time:
|
||||
print(f"[GLB转换] 使用现有转换文件: {glb_path}")
|
||||
if progress:
|
||||
progress.setValue(100)
|
||||
return glb_path
|
||||
|
||||
if progress:
|
||||
progress.setValue(20)
|
||||
progress.setLabelText("尝试 Blender 转换...")
|
||||
|
||||
# 方法1: 使用 Blender 进行转换
|
||||
if self._convertWithBlender(filepath, glb_path, progress):
|
||||
return glb_path
|
||||
|
||||
if progress:
|
||||
progress.setValue(60)
|
||||
progress.setLabelText("尝试 FBX2glTF 转换...")
|
||||
|
||||
# 方法2: 使用 FBX2glTF (如果可用)
|
||||
if self._convertWithFBX2glTF(filepath, glb_path, progress):
|
||||
return glb_path
|
||||
|
||||
if progress:
|
||||
progress.setValue(80)
|
||||
progress.setLabelText("尝试 Assimp 转换...")
|
||||
|
||||
# 方法3: 使用 Assimp
|
||||
if self._convertWithAssimp(filepath, glb_path, progress):
|
||||
return glb_path
|
||||
|
||||
#print(f"[GLB转换] 所有转换方法都失败,既然没有可以转换格式的工具和环境那么就用原始文件,不一定非要转换")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[GLB转换] 转换过程出错: {e}")
|
||||
return None
|
||||
|
||||
def _convertWithBlender(self, input_path, output_path, progress=None):
|
||||
"""使用 Blender 进行转换"""
|
||||
try:
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
print(f"[Blender转换] {input_path} → {output_path}")
|
||||
|
||||
# 创建 Blender 脚本
|
||||
script_content = f'''
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 清理默认场景
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
|
||||
print("开始导入文件...")
|
||||
|
||||
# 根据文件类型选择导入方法
|
||||
input_file = "{input_path}"
|
||||
output_file = "{output_path}"
|
||||
|
||||
try:
|
||||
ext = os.path.splitext(input_file)[1].lower()
|
||||
|
||||
if ext == '.fbx':
|
||||
bpy.ops.import_scene.fbx(filepath=input_file)
|
||||
elif ext == '.obj':
|
||||
bpy.ops.import_scene.obj(filepath=input_file)
|
||||
elif ext == '.dae':
|
||||
bpy.ops.wm.collada_import(filepath=input_file)
|
||||
elif ext == '.blend':
|
||||
bpy.ops.wm.open_mainfile(filepath=input_file)
|
||||
else:
|
||||
print(f"不支持的格式: {{ext}}")
|
||||
sys.exit(1)
|
||||
|
||||
print("导入成功,开始导出GLB...")
|
||||
|
||||
# 导出为 GLB,保留动画
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_file,
|
||||
export_format='GLB',
|
||||
export_animations=True,
|
||||
export_force_sampling=True,
|
||||
export_frame_range=True,
|
||||
export_current_frame=False,
|
||||
export_skins=True,
|
||||
export_morph=True,
|
||||
export_lights=True,
|
||||
export_cameras=False
|
||||
)
|
||||
|
||||
print("GLB导出成功!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"转换失败: {{e}}")
|
||||
sys.exit(1)
|
||||
'''
|
||||
|
||||
# 写入临时脚本文件
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as temp_file:
|
||||
temp_file.write(script_content)
|
||||
script_path = temp_file.name
|
||||
|
||||
try:
|
||||
# 执行 Blender 转换
|
||||
result = subprocess.run([
|
||||
'blender', '--background', '--python', script_path
|
||||
], capture_output=True, text=True, timeout=180)
|
||||
|
||||
# 清理临时文件
|
||||
os.unlink(script_path)
|
||||
|
||||
if result.returncode == 0 and os.path.exists(output_path):
|
||||
print(f"[Blender转换] 转换成功")
|
||||
return True
|
||||
else:
|
||||
print(f"[Blender转换] 转换失败: {result.stderr}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"[Blender转换] 转换超时")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print(f"[Blender转换] Blender 未安装")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Blender转换] 转换过程出错: {e}")
|
||||
return False
|
||||
|
||||
def _convertWithFBX2glTF(self, input_path, output_path, progress=None):
|
||||
"""使用 FBX2glTF 进行转换(仅支持FBX)"""
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
if not input_path.lower().endswith('.fbx'):
|
||||
return False
|
||||
|
||||
print(f"[FBX2glTF转换] {input_path} → {output_path}")
|
||||
|
||||
# 使用 FBX2glTF 转换
|
||||
result = subprocess.run([
|
||||
'FBX2glTF', input_path, '--output', output_path, '--binary'
|
||||
], capture_output=True, text=True, timeout=120)
|
||||
|
||||
if result.returncode == 0 and os.path.exists(output_path):
|
||||
print(f"[FBX2glTF转换] 转换成功")
|
||||
return True
|
||||
else:
|
||||
print(f"[FBX2glTF转换] 转换失败: {result.stderr}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"[FBX2glTF转换] 转换超时")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print(f"[FBX2glTF转换] FBX2glTF 未安装")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[FBX2glTF转换] 转换过程出错: {e}")
|
||||
return False
|
||||
|
||||
def _convertWithAssimp(self, input_path, output_path, progress=None):
|
||||
"""使用 PyAssimp 进行转换"""
|
||||
try:
|
||||
import pyassimp
|
||||
|
||||
print(f"[PyAssimp转换] {input_path} → {output_path}")
|
||||
|
||||
# 加载模型
|
||||
scene = pyassimp.load(input_path)
|
||||
if not scene:
|
||||
print(f"[PyAssimp转换] 加载模型失败")
|
||||
return False
|
||||
|
||||
if progress:
|
||||
progress.setValue(30)
|
||||
|
||||
# 导出为GLB格式
|
||||
pyassimp.export(scene, output_path, "glb2")
|
||||
|
||||
if progress:
|
||||
progress.setValue(80)
|
||||
|
||||
# 释放资源
|
||||
pyassimp.release(scene)
|
||||
|
||||
if os.path.exists(output_path):
|
||||
print(f"[PyAssimp转换] 转换成功")
|
||||
return True
|
||||
else:
|
||||
print(f"[PyAssimp转换] 转换失败: 输出文件未生成")
|
||||
return False
|
||||
|
||||
except ImportError:
|
||||
print(f"[PyAssimp转换] PyAssimp 未安装")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[PyAssimp转换] 转换过程出错: {e}")
|
||||
return False
|
||||
|
||||
def _check_async_task(self, panda3d_task):
|
||||
# 检查 asyncio 任务是否完成
|
||||
if hasattr(self, '_current_asyncio_task'):
|
||||
if self._current_asyncio_task.done():
|
||||
try:
|
||||
self._current_asyncio_task.result()
|
||||
except Exception as e:
|
||||
print(f"异步任务出错:{e}")
|
||||
# 返回 Panda3D 任务管理器的完成状态
|
||||
return panda3d_task.done # 注意是 done 而不是 DONE
|
||||
# 返回 Panda3D 任务管理器的继续状态
|
||||
return panda3d_task.cont # 注意是 cont 而不是 CONTINUE
|
||||
|
||||
def _parse_tileset(self,tileset_data,tileset_info):
|
||||
try:
|
||||
root = tileset_data.get('root',{})
|
||||
self._parse_tile(root,tileset_info['node'],tileset_info)
|
||||
print("✓ Tileset 解析完成")
|
||||
except Exception as e:
|
||||
print(f"✗ Tileset 解析出错: {e}")
|
||||
|
||||
def _parse_tile(self, tile_data, parent_node, tileset_info):
|
||||
try:
|
||||
# 获取tileID
|
||||
tile_id = f"tile_{len(tileset_info['tiles'])}"
|
||||
print(f"创建tile节点: {tile_id}")
|
||||
# 创建tile节点
|
||||
tile_node = parent_node.attachNewNode(tile_id)
|
||||
|
||||
tileset_info['tiles'][tile_id] = {
|
||||
'node': tile_node,
|
||||
'data': tile_data,
|
||||
'loaded': False
|
||||
}
|
||||
|
||||
# 如果有内容,创建占位几何体
|
||||
if 'content' in tile_data:
|
||||
print(f"为tile {tile_id} 创建几何体")
|
||||
self._create_tile_geometry(tile_node)
|
||||
# 递归解析子tiles
|
||||
children = tile_data.get('children', [])
|
||||
print(f"Tile {tile_id} 有 {len(children)} 个子节点")
|
||||
for child_data in children:
|
||||
self._parse_tile(child_data, tile_node, tileset_info)
|
||||
except Exception as e:
|
||||
print(f"✗ Tile 解析出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def _create_tile_geometry(self,parent_node):
|
||||
"""为 tile 创建占位几何体"""
|
||||
try:
|
||||
# 创建一个简单的立方体作为占位符
|
||||
from panda3d.core import GeomVertexFormat, GeomVertexData, GeomVertexWriter
|
||||
from panda3d.core import Geom, GeomTriangles, GeomNode
|
||||
|
||||
format = GeomVertexFormat.getV3n3c4()
|
||||
vdata = GeomVertexData('tile_cube', format, Geom.UHStatic)
|
||||
|
||||
vertex = GeomVertexWriter(vdata, 'vertex')
|
||||
normal = GeomVertexWriter(vdata, 'normal')
|
||||
color = GeomVertexWriter(vdata, 'color')
|
||||
|
||||
# 定义立方体顶点
|
||||
vertices = [
|
||||
(-0.5, -0.5, -0.5), (0.5, -0.5, -0.5), (0.5, 0.5, -0.5), (-0.5, 0.5, -0.5),
|
||||
(-0.5, -0.5, 0.5), (0.5, -0.5, 0.5), (0.5, 0.5, 0.5), (-0.5, 0.5, 0.5)
|
||||
]
|
||||
|
||||
for vert in vertices:
|
||||
vertex.addData3f(*vert)
|
||||
normal.addData3f(0, 0, 1)
|
||||
color.addData4f(0.2, 0.6, 0.8, 1.0)
|
||||
|
||||
# 创建几何体
|
||||
geom = Geom(vdata)
|
||||
|
||||
# 创建面
|
||||
prim = GeomTriangles(Geom.UHStatic)
|
||||
# 底面
|
||||
prim.addVertices(0, 1, 2)
|
||||
prim.addVertices(0, 2, 3)
|
||||
# 顶面
|
||||
prim.addVertices(4, 7, 6)
|
||||
prim.addVertices(4, 6, 5)
|
||||
# 前面
|
||||
prim.addVertices(0, 4, 5)
|
||||
prim.addVertices(0, 5, 1)
|
||||
# 后面
|
||||
prim.addVertices(2, 6, 7)
|
||||
prim.addVertices(2, 7, 3)
|
||||
# 左面
|
||||
prim.addVertices(0, 3, 7)
|
||||
prim.addVertices(0, 7, 4)
|
||||
# 右面
|
||||
prim.addVertices(1, 5, 6)
|
||||
prim.addVertices(1, 6, 2)
|
||||
|
||||
prim.closePrimitive()
|
||||
geom.addPrimitive(prim)
|
||||
|
||||
# 创建几何节点
|
||||
geom_node = GeomNode('tile_geometry')
|
||||
geom_node.addGeom(geom)
|
||||
|
||||
# 添加到场景
|
||||
cube_node = parent_node.attachNewNode(geom_node)
|
||||
cube_node.setScale(1000) # 放大以便观察
|
||||
|
||||
# 添加材质
|
||||
material = Material()
|
||||
material.setBaseColor((0.2, 0.6, 0.8, 1.0))
|
||||
material.setSpecular((0.1, 0.1, 0.1, 1.0))
|
||||
material.setShininess(10.0)
|
||||
cube_node.setMaterial(material)
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ 创建 tile 几何体出错: {e}")
|
||||
|
||||
def _create_placeholder_geometry(self, parent_node):
|
||||
"""创建一个简单的占位符几何体,让用户能看到节点"""
|
||||
try:
|
||||
from panda3d.core import GeomVertexFormat, GeomVertexData, GeomVertexWriter
|
||||
from panda3d.core import Geom, GeomTriangles, GeomNode
|
||||
|
||||
# 创建简单的立方体作为占位符
|
||||
format = GeomVertexFormat.getV3n3c4()
|
||||
vdata = GeomVertexData('placeholder_cube', format, Geom.UHStatic)
|
||||
|
||||
vertex = GeomVertexWriter(vdata, 'vertex')
|
||||
normal = GeomVertexWriter(vdata, 'normal')
|
||||
color = GeomVertexWriter(vdata, 'color')
|
||||
|
||||
# 定义立方体顶点
|
||||
size = 1.0
|
||||
vertices = [
|
||||
# 前面 (Z+)
|
||||
(-size, -size, size), (size, -size, size), (size, size, size), (-size, size, size),
|
||||
# 后面 (Z-)
|
||||
(-size, -size, -size), (-size, size, -size), (size, size, -size), (size, -size, -size),
|
||||
# 左面 (X-)
|
||||
(-size, -size, -size), (-size, -size, size), (-size, size, size), (-size, size, -size),
|
||||
# 右面 (X+)
|
||||
(size, -size, -size), (size, size, -size), (size, size, size), (size, -size, size),
|
||||
# 上面 (Y+)
|
||||
(-size, size, -size), (-size, size, size), (size, size, size), (size, size, -size),
|
||||
# 下面 (Y-)
|
||||
(-size, -size, -size), (size, -size, -size), (size, -size, size), (-size, -size, size)
|
||||
]
|
||||
|
||||
normals = [
|
||||
# 前面法线
|
||||
(0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1),
|
||||
# 后面法线
|
||||
(0, 0, -1), (0, 0, -1), (0, 0, -1), (0, 0, -1),
|
||||
# 左面法线
|
||||
(-1, 0, 0), (-1, 0, 0), (-1, 0, 0), (-1, 0, 0),
|
||||
# 右面法线
|
||||
(1, 0, 0), (1, 0, 0), (1, 0, 0), (1, 0, 0),
|
||||
# 上面法线
|
||||
(0, 1, 0), (0, 1, 0), (0, 1, 0), (0, 1, 0),
|
||||
# 下面法线
|
||||
(0, -1, 0), (0, -1, 0), (0, -1, 0), (0, -1, 0)
|
||||
]
|
||||
|
||||
# 青色
|
||||
face_colors = [
|
||||
(0.0, 1.0, 1.0, 1.0), (0.0, 1.0, 1.0, 1.0), (0.0, 1.0, 1.0, 1.0), (0.0, 1.0, 1.0, 1.0), # 前面 - 青色
|
||||
(0.0, 0.8, 0.8, 1.0), (0.0, 0.8, 0.8, 1.0), (0.0, 0.8, 0.8, 1.0), (0.0, 0.8, 0.8, 1.0), # 后面 - 稍暗青色
|
||||
(0.0, 0.9, 0.9, 1.0), (0.0, 0.9, 0.9, 1.0), (0.0, 0.9, 0.9, 1.0), (0.0, 0.9, 0.9, 1.0), # 左面 - 中等青色
|
||||
(0.0, 0.7, 0.7, 1.0), (0.0, 0.7, 0.7, 1.0), (0.0, 0.7, 0.7, 1.0), (0.0, 0.7, 0.7, 1.0), # 右面 - 稍暗青色
|
||||
(0.0, 1.0, 1.0, 1.0), (0.0, 1.0, 1.0, 1.0), (0.0, 1.0, 1.0, 1.0), (0.0, 1.0, 1.0, 1.0), # 上面 - 青色
|
||||
(0.0, 0.6, 0.6, 1.0), (0.0, 0.6, 0.6, 1.0), (0.0, 0.6, 0.6, 1.0), (0.0, 0.6, 0.6, 1.0) # 下面 - 更暗青色
|
||||
]
|
||||
|
||||
for i, vert in enumerate(vertices):
|
||||
vertex.addData3f(*vert)
|
||||
normal.addData3f(*normals[i])
|
||||
color.addData4f(*face_colors[i])
|
||||
|
||||
# 创建几何体
|
||||
geom = Geom(vdata)
|
||||
|
||||
# 创建面(每个面两个三角形)
|
||||
prim = GeomTriangles(Geom.UHStatic)
|
||||
|
||||
# 每个面4个顶点,生成2个三角形
|
||||
for face in range(6): # 6个面
|
||||
base_index = face * 4
|
||||
# 第一个三角形
|
||||
prim.addVertices(base_index, base_index + 1, base_index + 2)
|
||||
# 第二个三角形
|
||||
prim.addVertices(base_index, base_index + 2, base_index + 3)
|
||||
|
||||
prim.closePrimitive()
|
||||
geom.addPrimitive(prim)
|
||||
|
||||
# 创建几何节点
|
||||
geom_node = GeomNode('tileset_placeholder')
|
||||
geom_node.addGeom(geom)
|
||||
|
||||
# 添加到场景
|
||||
cube_node = parent_node.attachNewNode(geom_node)
|
||||
cube_node.setScale(5) # 设置合适的大小
|
||||
|
||||
# 设置双面渲染
|
||||
cube_node.setTwoSided(True)
|
||||
|
||||
# 添加材质
|
||||
material = Material()
|
||||
material.setBaseColor((0.0, 1.0, 1.0, 1.0)) # 青色
|
||||
material.setSpecular((0.5, 0.5, 0.5, 1.0))
|
||||
material.setShininess(32.0)
|
||||
cube_node.setMaterial(material)
|
||||
|
||||
# 添加标识标签
|
||||
cube_node.setTag("element_type", "cesium_placeholder")
|
||||
|
||||
print("✓ 占位符几何体创建完成")
|
||||
return cube_node
|
||||
except Exception as e:
|
||||
print(f"✗ 创建占位符几何体出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
19
scene/scene_manager_impl.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""Scene manager implementation composed from focused mixins."""
|
||||
|
||||
from scene.scene_manager_convert_tiles_mixin import SceneManagerConvertTilesMixin
|
||||
from scene.scene_manager_io_mixin import SceneManagerIOMixin
|
||||
from scene.scene_manager_light_mixin import SceneManagerLightMixin
|
||||
from scene.scene_manager_model_mixin import SceneManagerModelMixin
|
||||
from scene.scene_manager_serialization_mixin import SceneManagerSerializationMixin
|
||||
|
||||
|
||||
class SceneManagerImpl(
|
||||
SceneManagerSerializationMixin,
|
||||
SceneManagerConvertTilesMixin,
|
||||
SceneManagerLightMixin,
|
||||
SceneManagerIOMixin,
|
||||
SceneManagerModelMixin,
|
||||
):
|
||||
"""Composed scene manager implementation."""
|
||||
|
||||
pass
|
||||
1852
scene/scene_manager_io_mixin.py
Normal file
459
scene/scene_manager_light_mixin.py
Normal file
@ -0,0 +1,459 @@
|
||||
"""Scene manager light operations."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import json
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from panda3d.core import (
|
||||
ModelPool, ModelRoot, Filename, NodePath, GeomNode, Material, Vec4, Vec3,
|
||||
MaterialAttrib, ColorAttrib, Point3, CollisionNode, CollisionSphere, CollisionBox,
|
||||
BitMask32, TransparencyAttrib, LColor, TransformState, RenderModeAttrib
|
||||
)
|
||||
from panda3d.egg import EggData, EggVertexPool
|
||||
from direct.actor.Actor import Actor
|
||||
from RenderPipelineFile.rpplugins.smaa.jitters import halton_seq
|
||||
from scene import util
|
||||
|
||||
class SceneManagerLightMixin:
|
||||
def _recreateSpotLight(self, light_node):
|
||||
"""重新创建聚光灯"""
|
||||
try:
|
||||
from RenderPipelineFile.rpcore import SpotLight
|
||||
from panda3d.core import Vec3
|
||||
from core.render_pipeline_utils import get_render_pipeline
|
||||
|
||||
# 创建聚光灯对象
|
||||
light = SpotLight()
|
||||
light.direction = Vec3(0, 0, -1)
|
||||
light.fov = 70
|
||||
light.set_color_from_temperature(5 * 1000.0)
|
||||
|
||||
# 恢复保存的属性
|
||||
if light_node.hasTag("light_energy"):
|
||||
light.energy = float(light_node.getTag("light_energy"))
|
||||
else:
|
||||
light.energy = 5000
|
||||
|
||||
light.radius = 1000
|
||||
light.casts_shadows = True
|
||||
light.shadow_map_resolution = 256
|
||||
|
||||
light_pos = light_node.getPos()
|
||||
light.setPos(light_pos)
|
||||
|
||||
# 添加到渲染管线
|
||||
render_pipeline = get_render_pipeline()
|
||||
render_pipeline.add_light(light)
|
||||
|
||||
# 保存光源对象引用
|
||||
light_node.setPythonTag("rp_light_object", light)
|
||||
|
||||
# 添加到管理列表(去重)
|
||||
if light_node not in self.Spotlight:
|
||||
self.Spotlight.append(light_node)
|
||||
|
||||
# 确保灯光节点有正确的标签,以便在场景树更新时被识别
|
||||
if not light_node.hasTag("is_scene_element"):
|
||||
light_node.setTag("is_scene_element", "1")
|
||||
light_node.setTag("is_scene_element", "1")
|
||||
light_node.setTag("element_type", "spotlight")
|
||||
light_node.setTag("tree_item_type", "LIGHT_NODE")
|
||||
|
||||
if light_node.hasTag("stored_energy"):
|
||||
stored_energy = float(light_node.getTag("stored_energy"))
|
||||
if stored_energy > 0:
|
||||
light_node.setTag("stored_energy", str(stored_energy))
|
||||
|
||||
user_visible = True
|
||||
if light_node.hasTag("user_visible"):
|
||||
user_visible = light_node.getTag("user_visible").lower() == "true"
|
||||
|
||||
light_node.setPythonTag("user_visible",user_visible)
|
||||
if not user_visible:
|
||||
self.toggleLightVisibility(light_node,False)
|
||||
except Exception as e:
|
||||
print(f"重新创建聚光灯失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def _recreatePointLight(self, light_node):
|
||||
"""重新创建点光源"""
|
||||
try:
|
||||
from RenderPipelineFile.rpcore import PointLight
|
||||
from core.render_pipeline_utils import get_render_pipeline
|
||||
|
||||
# 创建点光源对象
|
||||
light = PointLight()
|
||||
|
||||
# 恢复保存的属性
|
||||
if light_node.hasTag("light_energy"):
|
||||
light.energy = float(light_node.getTag("light_energy"))
|
||||
else:
|
||||
light.energy = 5000
|
||||
|
||||
light.radius = 1000
|
||||
light.inner_radius = 0.4
|
||||
light.set_color_from_temperature(5 * 1000.0)
|
||||
light.casts_shadows = True
|
||||
light.shadow_map_resolution = 256
|
||||
|
||||
# 设置位置
|
||||
light.setPos(light_node.getPos())
|
||||
|
||||
# 添加到渲染管线
|
||||
render_pipeline = get_render_pipeline()
|
||||
render_pipeline.add_light(light)
|
||||
|
||||
# 保存光源对象引用
|
||||
light_node.setPythonTag("rp_light_object", light)
|
||||
|
||||
# 添加到管理列表(去重)
|
||||
if light_node not in self.Pointlight:
|
||||
self.Pointlight.append(light_node)
|
||||
|
||||
# 确保灯光节点有正确的标签,以便在场景树更新时被识别
|
||||
if not light_node.hasTag("is_scene_element"):
|
||||
light_node.setTag("is_scene_element", "1")
|
||||
|
||||
light_node.setTag("is_scene_element", "1")
|
||||
light_node.setTag("element_type", "pointlight")
|
||||
light_node.setTag("tree_item_type", "LIGHT_NODE")
|
||||
|
||||
if light_node.hasTag("stored_energy"):
|
||||
stored_energy = float(light_node.getTag("stored_energy"))
|
||||
if stored_energy > 0:
|
||||
light_node.setTag("stored_energy", str(stored_energy))
|
||||
|
||||
user_visible = True
|
||||
if light_node.hasTag("user_visible"):
|
||||
user_visible = light_node.getTag("user_visible").lower()=="true"
|
||||
|
||||
light_node.setPythonTag("user_visible",user_visible)
|
||||
|
||||
if not user_visible:
|
||||
self.toggleLightVisibility(light_node,False)
|
||||
except Exception as e:
|
||||
print(f"重新创建点光源失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def isLightObject(self, nodePath):
|
||||
"""检查是否为灯光对象"""
|
||||
try:
|
||||
if not nodePath:
|
||||
return False
|
||||
|
||||
|
||||
# 方法1: 检查PythonTag
|
||||
if nodePath.hasPythonTag("rp_light_object"):
|
||||
rp_light = nodePath.getPythonTag("rp_light_object")
|
||||
if rp_light is not None:
|
||||
return True
|
||||
|
||||
# 方法2: 检查element_type标签
|
||||
if nodePath.hasTag("element_type"):
|
||||
element_type = nodePath.getTag("element_type")
|
||||
if element_type in ["spotlight", "pointlight"]:
|
||||
return True
|
||||
|
||||
# 方法3: 检查tree_item_type标签
|
||||
if nodePath.hasTag("tree_item_type"):
|
||||
tree_item_type = nodePath.getTag("tree_item_type")
|
||||
if tree_item_type == "LIGHT_NODE":
|
||||
return True
|
||||
|
||||
# 方法4: 通过名称模式匹配(作为后备方案)
|
||||
node_name = nodePath.getName().lower()
|
||||
if "spotlight" in node_name or "pointlight" in node_name:
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"检查灯光对象时出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def toggleLightVisibility(self, light_node, visible):
|
||||
"""切换灯光可见性"""
|
||||
try:
|
||||
print(f"切换灯光可见性: {light_node.getName()}, 可见={visible}")
|
||||
|
||||
# 保存用户可见性状态到该特定节点
|
||||
light_node.setPythonTag("user_visible", visible)
|
||||
|
||||
# 获取该特定灯光对象
|
||||
rp_light_object = light_node.getPythonTag("rp_light_object")
|
||||
if not rp_light_object:
|
||||
print(f"错误: {light_node.getName()} 未找到RP灯光对象引用")
|
||||
return
|
||||
|
||||
# 获取RenderPipeline实例
|
||||
from core.render_pipeline_utils import get_render_pipeline
|
||||
render_pipeline = get_render_pipeline()
|
||||
|
||||
if not render_pipeline:
|
||||
print("错误: 无法获取RenderPipeline实例")
|
||||
return
|
||||
|
||||
try:
|
||||
if visible:
|
||||
if light_node.hasTag("stored_energy"):
|
||||
stored_energy = float(light_node.getTag("stored_energy"))
|
||||
rp_light_object.energy=stored_energy
|
||||
print(f"已恢复灯光强度: {light_node.getName()}, 能量={stored_energy}")
|
||||
# 启用特定灯光
|
||||
# render_pipeline.add_light(rp_light_object)
|
||||
# print(f"已添加灯光到渲染管线: {light_node.getName()}")
|
||||
else:
|
||||
# 禁用特定灯光
|
||||
current_energy = rp_light_object.energy
|
||||
if current_energy != 0.0:
|
||||
light_node.setTag("stored_energy", str(current_energy))
|
||||
elif light_node.hasTag("stored_energy"):
|
||||
stored_energy = float(light_node.getTag("stored_energy"))
|
||||
current_energy = stored_energy
|
||||
else:
|
||||
current_energy = 0.0
|
||||
rp_light_object.energy = 0.0
|
||||
print(f"已禁用灯光: {light_node.getName()}, 保存的能量={current_energy}")
|
||||
# render_pipeline.remove_light(rp_light_object)
|
||||
# print(f"已从渲染管线移除灯光: {light_node.getName()}")
|
||||
except Exception as e:
|
||||
print(f"操作RenderPipeline灯光时出错: {e}")
|
||||
|
||||
# 控制节点显示状态(可选,主要是视觉上的)
|
||||
if visible:
|
||||
light_node.show()
|
||||
else:
|
||||
light_node.hide()
|
||||
|
||||
print(f"灯光可见性设置完成: {visible}")
|
||||
except Exception as e:
|
||||
print(f"切换灯光可见性失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def _recreateLightFromData(self, node_data, parent_node, name):
|
||||
"""根据数据重建光源"""
|
||||
try:
|
||||
light_type = node_data.get('tags', {}).get('light_type', 'spot_light')
|
||||
|
||||
# 创建光源
|
||||
if light_type == 'spot_light':
|
||||
light_node = self.createSpotLight(pos=node_data.get('pos', (0, 0, 0)))
|
||||
else: # point_light
|
||||
light_node = self.createPointLight(pos=node_data.get('pos', (0, 0, 0)))
|
||||
|
||||
if light_node:
|
||||
# 设置名称
|
||||
light_node.setName(name)
|
||||
|
||||
# 恢复其他属性
|
||||
light_data = node_data.get('light_data', {})
|
||||
rp_light = light_node.getPythonTag("rp_light_object")
|
||||
if rp_light and light_data:
|
||||
if 'energy' in light_data:
|
||||
rp_light.energy = light_data['energy']
|
||||
if 'radius' in light_data:
|
||||
rp_light.radius = light_data['radius']
|
||||
if 'fov' in light_data and hasattr(rp_light, 'fov'):
|
||||
rp_light.fov = light_data['fov']
|
||||
if 'inner_radius' in light_data and hasattr(rp_light, 'inner_radius'):
|
||||
rp_light.inner_radius = light_data['inner_radius']
|
||||
if 'casts_shadows' in light_data and hasattr(rp_light, 'casts_shadows'):
|
||||
rp_light.casts_shadows = light_data['casts_shadows']
|
||||
if 'shadow_map_resolution' in light_data and hasattr(rp_light, 'shadow_map_resolution'):
|
||||
rp_light.shadow_map_resolution = light_data['shadow_map_resolution']
|
||||
|
||||
# 恢复其他标签
|
||||
for tag_key, tag_value in node_data.get('tags', {}).items():
|
||||
if tag_key not in ['name', 'light_type']:
|
||||
light_node.setTag(tag_key, str(tag_value))
|
||||
|
||||
return light_node
|
||||
|
||||
except Exception as e:
|
||||
print(f"重建光源失败: {e}")
|
||||
return None
|
||||
|
||||
def createSpotLight(self, pos=(0, 0, 5)):
|
||||
"""创建聚光灯
|
||||
|
||||
Args:
|
||||
pos (tuple): 光源位置 (x, y, z)
|
||||
|
||||
Returns:
|
||||
NodePath: 创建的聚光灯节点
|
||||
"""
|
||||
try:
|
||||
# 检查是否使用RenderPipeline
|
||||
if hasattr(self.world, 'render_pipeline') and self.world.render_pipeline:
|
||||
from RenderPipelineFile.rpcore import SpotLight
|
||||
from core.render_pipeline_utils import get_render_pipeline
|
||||
|
||||
# 创建RenderPipeline聚光灯
|
||||
from panda3d.core import Vec3
|
||||
spotlight = SpotLight()
|
||||
spotlight.direction = Vec3(0, 0, -1) # 向下照射
|
||||
spotlight.fov = 70 # 聚光角度
|
||||
spotlight.set_color_from_temperature(6500) # 日光色温
|
||||
spotlight.energy = 5000 # 光照强度
|
||||
spotlight.radius = 1000 # 光照范围
|
||||
spotlight.casts_shadows = True # 启用阴影
|
||||
spotlight.shadow_map_resolution = 256 # 阴影分辨率
|
||||
spotlight.setPos(pos)
|
||||
|
||||
# 添加到RenderPipeline
|
||||
render_pipeline = get_render_pipeline()
|
||||
if render_pipeline:
|
||||
render_pipeline.add_light(spotlight)
|
||||
print(f"✓ RenderPipeline聚光灯创建成功,位置: {pos}")
|
||||
|
||||
# 创建包装节点用于场景树显示
|
||||
light_name = f"Spotlight_{len(self.Spotlight)}"
|
||||
spotlight_node = self.world.render.attachNewNode(light_name)
|
||||
spotlight_node.setPos(*pos)
|
||||
spotlight_node.setTag("light_type", "spot_light")
|
||||
spotlight_node.setTag("is_scene_element", "1")
|
||||
spotlight_node.setTag("tree_item_type", "LIGHT_NODE")
|
||||
spotlight_node.setTag("light_energy", str(getattr(spotlight, "energy", 5000)))
|
||||
spotlight_node.setTag("created_by_user", "1")
|
||||
spotlight_node.setTag("element_type", "spotlight")
|
||||
spotlight_node.setPythonTag("rp_light_object", spotlight)
|
||||
spotlight_node.setPythonTag("engine_light_registered", True)
|
||||
self.Spotlight.append(spotlight_node)
|
||||
return spotlight_node
|
||||
else:
|
||||
print("✗ 无法获取RenderPipeline实例")
|
||||
return None
|
||||
else:
|
||||
# 使用标准Panda3D光源
|
||||
from panda3d.core import Spotlight, PerspectiveLens
|
||||
|
||||
# 创建聚光灯
|
||||
spotlight = Spotlight('spotlight')
|
||||
spotlight.setColor((1, 1, 1, 1)) # 白色光
|
||||
spotlight.setLens(PerspectiveLens())
|
||||
|
||||
# 创建光源节点
|
||||
spotlight_node = self.world.render.attachNewNode(spotlight)
|
||||
spotlight_node.setPos(pos)
|
||||
spotlight_node.setTag("light_type", "spot_light")
|
||||
spotlight_node.setTag("is_scene_element", "1")
|
||||
spotlight_node.setTag("tree_item_type", "LIGHT_NODE")
|
||||
spotlight_node.setTag("created_by_user", "1")
|
||||
spotlight_node.setTag("element_type", "spotlight")
|
||||
spotlight_node.setPythonTag("engine_light_registered", True)
|
||||
|
||||
# 设置聚光灯方向(向下照射)
|
||||
spotlight_node.lookAt(pos[0], pos[1], pos[2] - 5) # 向下看5个单位
|
||||
|
||||
# 设置聚光灯范围
|
||||
spotlight.setExponent(1.0) # 聚光指数
|
||||
spotlight.setAttenuation((1, 0.1, 0.01)) # 衰减
|
||||
|
||||
# 添加到光源列表
|
||||
self.Spotlight.append(spotlight_node)
|
||||
|
||||
# 启用光源
|
||||
self.world.render.setLight(spotlight_node)
|
||||
|
||||
# 启用阴影
|
||||
if hasattr(spotlight, 'setShadowCaster'):
|
||||
spotlight.setShadowCaster(True)
|
||||
|
||||
print(f"✓ 标准聚光灯创建成功,位置: {pos}")
|
||||
return spotlight_node
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ 创建聚光灯失败: {e}")
|
||||
return None
|
||||
|
||||
def createPointLight(self, pos=(0, 0, 5)):
|
||||
"""创建点光源
|
||||
|
||||
Args:
|
||||
pos (tuple): 光源位置 (x, y, z)
|
||||
|
||||
Returns:
|
||||
NodePath: 创建的点光源节点
|
||||
"""
|
||||
try:
|
||||
# 检查是否使用RenderPipeline
|
||||
if hasattr(self.world, 'render_pipeline') and self.world.render_pipeline:
|
||||
from RenderPipelineFile.rpcore import PointLight
|
||||
from core.render_pipeline_utils import get_render_pipeline
|
||||
|
||||
# 创建RenderPipeline点光源
|
||||
pointlight = PointLight()
|
||||
pointlight.set_color_from_temperature(6500) # 日光色温
|
||||
pointlight.energy = 3000 # 光照强度
|
||||
pointlight.radius = 1000 # 光照范围
|
||||
pointlight.casts_shadows = True # 启用阴影
|
||||
pointlight.shadow_map_resolution = 256 # 阴影分辨率
|
||||
pointlight.setPos(pos)
|
||||
|
||||
# 添加到RenderPipeline
|
||||
render_pipeline = get_render_pipeline()
|
||||
if render_pipeline:
|
||||
render_pipeline.add_light(pointlight)
|
||||
print(f"✓ RenderPipeline点光源创建成功,位置: {pos}")
|
||||
|
||||
# 创建包装节点用于场景树显示
|
||||
light_name = f"Pointlight_{len(self.Pointlight)}"
|
||||
pointlight_node = self.world.render.attachNewNode(light_name)
|
||||
pointlight_node.setPos(*pos)
|
||||
pointlight_node.setTag("light_type", "point_light")
|
||||
pointlight_node.setTag("is_scene_element", "1")
|
||||
pointlight_node.setTag("tree_item_type", "LIGHT_NODE")
|
||||
pointlight_node.setTag("light_energy", str(getattr(pointlight, "energy", 3000)))
|
||||
pointlight_node.setTag("created_by_user", "1")
|
||||
pointlight_node.setTag("element_type", "pointlight")
|
||||
pointlight_node.setPythonTag("rp_light_object", pointlight)
|
||||
pointlight_node.setPythonTag("engine_light_registered", True)
|
||||
self.Pointlight.append(pointlight_node)
|
||||
return pointlight_node
|
||||
else:
|
||||
print("✗ 无法获取RenderPipeline实例")
|
||||
return None
|
||||
else:
|
||||
# 使用标准Panda3D光源
|
||||
from panda3d.core import PointLight
|
||||
|
||||
# 创建点光源
|
||||
pointlight = PointLight('pointlight')
|
||||
pointlight.setColor((1, 1, 1, 1)) # 白色光
|
||||
pointlight.setAttenuation((1, 0.1, 0.01)) # 衰减设置
|
||||
|
||||
# 创建光源节点
|
||||
pointlight_node = self.world.render.attachNewNode(pointlight)
|
||||
pointlight_node.setPos(pos)
|
||||
pointlight_node.setTag("light_type", "point_light")
|
||||
pointlight_node.setTag("is_scene_element", "1")
|
||||
pointlight_node.setTag("tree_item_type", "LIGHT_NODE")
|
||||
pointlight_node.setTag("created_by_user", "1")
|
||||
pointlight_node.setTag("element_type", "pointlight")
|
||||
pointlight_node.setPythonTag("engine_light_registered", True)
|
||||
|
||||
# 添加到光源列表
|
||||
self.Pointlight.append(pointlight_node)
|
||||
|
||||
# 启用光源
|
||||
self.world.render.setLight(pointlight_node)
|
||||
|
||||
# 启用阴影
|
||||
if hasattr(pointlight, 'setShadowCaster'):
|
||||
pointlight.setShadowCaster(True)
|
||||
|
||||
print(f"✓ 标准点光源创建成功,位置: {pos}")
|
||||
return pointlight_node
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ 创建点光源失败: {e}")
|
||||
return None
|
||||
1186
scene/scene_manager_model_mixin.py
Normal file
873
scene/scene_manager_serialization_mixin.py
Normal file
@ -0,0 +1,873 @@
|
||||
"""Scene manager node serialization operations."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import json
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from panda3d.core import (
|
||||
ModelPool, ModelRoot, Filename, NodePath, GeomNode, Material, Vec4, Vec3,
|
||||
MaterialAttrib, ColorAttrib, Point3, CollisionNode, CollisionSphere, CollisionBox,
|
||||
BitMask32, TransparencyAttrib, LColor, TransformState, RenderModeAttrib
|
||||
)
|
||||
from panda3d.egg import EggData, EggVertexPool
|
||||
from direct.actor.Actor import Actor
|
||||
from RenderPipelineFile.rpplugins.smaa.jitters import halton_seq
|
||||
from scene import util
|
||||
from core.editor_context import get_editor_context
|
||||
|
||||
class SceneManagerSerializationMixin:
|
||||
def _get_editor_context(self):
|
||||
return get_editor_context(self.world)
|
||||
|
||||
def _get_tree_widget(self):
|
||||
return self._get_editor_context().get_tree_widget()
|
||||
|
||||
def _get_gui_manager(self):
|
||||
return self._get_editor_context().get_gui_manager()
|
||||
|
||||
def serializeNode(self, node):
|
||||
"""序列化节点为字典数据"""
|
||||
try:
|
||||
node_data = {
|
||||
'name': node.getName(),
|
||||
'type': type(node.node()).__name__,
|
||||
'pos': (node.getX(), node.getY(), node.getZ()),
|
||||
'hpr': (node.getH(), node.getP(), node.getR()),
|
||||
'scale': (node.getSx(), node.getSy(), node.getSz()),
|
||||
'tags': {},
|
||||
'children': []
|
||||
}
|
||||
|
||||
# 保存所有标签
|
||||
for tag_key in node.getTagKeys():
|
||||
node_data['tags'][tag_key] = node.getTag(tag_key)
|
||||
|
||||
# 特殊处理不同类型的节点
|
||||
if hasattr(node.node(), 'getClassType'):
|
||||
node_class = node.node().getClassType().getName()
|
||||
node_data['node_class'] = node_class
|
||||
|
||||
# 递归序列化子节点
|
||||
for child in node.getChildren():
|
||||
# 跳过辅助节点
|
||||
if not child.getName().startswith(('gizmo', 'selectionBox', 'grid')):
|
||||
child_data = self.serializeNode(child)
|
||||
if child_data:
|
||||
node_data['children'].append(child_data)
|
||||
|
||||
return node_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"序列化节点 {node.getName()} 失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def deserializeNode(self, node_data, parent_node):
|
||||
"""从字典数据反序列化节点"""
|
||||
try:
|
||||
# 创建新节点
|
||||
node_name = node_data.get('name', 'node')
|
||||
new_node = parent_node.attachNewNode(node_name)
|
||||
|
||||
# 设置变换
|
||||
pos = node_data.get('pos', (0, 0, 0))
|
||||
hpr = node_data.get('hpr', (0, 0, 0))
|
||||
scale = node_data.get('scale', (1, 1, 1))
|
||||
|
||||
new_node.setPos(*pos)
|
||||
new_node.setHpr(*hpr)
|
||||
new_node.setScale(*scale)
|
||||
|
||||
# 恢复标签
|
||||
for tag_key, tag_value in node_data.get('tags', {}).items():
|
||||
new_node.setTag(tag_key, tag_value)
|
||||
|
||||
# 根据节点类型进行特殊处理
|
||||
node_type = node_data.get('type', '')
|
||||
node_class = node_data.get('node_class', '')
|
||||
|
||||
# 特殊处理光源节点
|
||||
if 'light_type' in node_data.get('tags', {}):
|
||||
light_type = node_data['tags']['light_type']
|
||||
if light_type == 'spot_light':
|
||||
self._recreateSpotLight(new_node)
|
||||
elif light_type == 'point_light':
|
||||
self._recreatePointLight(new_node)
|
||||
|
||||
# 递归创建子节点
|
||||
for child_data in node_data.get('children', []):
|
||||
self.deserializeNode(child_data, new_node)
|
||||
|
||||
return new_node
|
||||
|
||||
except Exception as e:
|
||||
print(f"反序列化节点 {node_data.get('name', 'unknown')} 失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def serializeNodeForCopy(self, node):
|
||||
"""序列化节点用于复制操作,完整保存视觉属性"""
|
||||
try:
|
||||
if not node or node.isEmpty():
|
||||
return None
|
||||
|
||||
node_data = {
|
||||
'name': node.getName(),
|
||||
'type': type(node.node()).__name__,
|
||||
'pos': (node.getX(), node.getY(), node.getZ()),
|
||||
'hpr': (node.getH(), node.getP(), node.getR()),
|
||||
'scale': (node.getSx(), node.getSy(), node.getSz()),
|
||||
'tags': {},
|
||||
'children': []
|
||||
}
|
||||
|
||||
# 保存所有标签
|
||||
try:
|
||||
if hasattr(node, 'getTagKeys'):
|
||||
for tag_key in node.getTagKeys():
|
||||
node_data['tags'][tag_key] = node.getTag(tag_key)
|
||||
except Exception as e:
|
||||
print(f"获取标签时出错: {e}")
|
||||
|
||||
# 保存视觉属性
|
||||
try:
|
||||
# 保存颜色属性
|
||||
if hasattr(node, 'getColor'):
|
||||
color = node.getColor()
|
||||
node_data['color'] = (color.getX(), color.getY(), color.getZ(), color.getW())
|
||||
|
||||
# 保存材质属性
|
||||
if hasattr(node, 'getMaterial'):
|
||||
material = node.getMaterial()
|
||||
if material:
|
||||
material_data = {}
|
||||
material_data['base_color'] = (
|
||||
material.getBaseColor().getX(),
|
||||
material.getBaseColor().getY(),
|
||||
material.getBaseColor().getZ(),
|
||||
material.getBaseColor().getW()
|
||||
)
|
||||
material_data['ambient'] = (
|
||||
material.getAmbient().getX(),
|
||||
material.getAmbient().getY(),
|
||||
material.getAmbient().getZ(),
|
||||
material.getAmbient().getW()
|
||||
)
|
||||
material_data['diffuse'] = (
|
||||
material.getDiffuse().getX(),
|
||||
material.getDiffuse().getY(),
|
||||
material.getDiffuse().getZ(),
|
||||
material.getDiffuse().getW()
|
||||
)
|
||||
material_data['specular'] = (
|
||||
material.getSpecular().getX(),
|
||||
material.getSpecular().getY(),
|
||||
material.getSpecular().getZ(),
|
||||
material.getSpecular().getW()
|
||||
)
|
||||
material_data['shininess'] = material.getShininess()
|
||||
node_data['material'] = material_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"保存视觉属性时出错: {e}")
|
||||
|
||||
# 根据节点类型保存特定信息
|
||||
if node.hasTag("tree_item_type"):
|
||||
node_type = node.getTag("tree_item_type")
|
||||
node_data['node_type'] = node_type
|
||||
|
||||
# 保存特定类型节点的额外信息
|
||||
if node_type in ["LIGHT_NODE", "SPOT_LIGHT_NODE", "POINT_LIGHT_NODE"]:
|
||||
# 保存光源特定信息
|
||||
rp_light = node.getPythonTag("rp_light_object")
|
||||
if rp_light:
|
||||
node_data['light_data'] = {
|
||||
'energy': getattr(rp_light, 'energy', 5000),
|
||||
'radius': getattr(rp_light, 'radius', 1000),
|
||||
'fov': getattr(rp_light, 'fov', 70) if hasattr(rp_light, 'fov') else None,
|
||||
'inner_radius': getattr(rp_light, 'inner_radius', 0.4) if hasattr(rp_light,
|
||||
'inner_radius') else None,
|
||||
'casts_shadows': getattr(rp_light, 'casts_shadows', True) if hasattr(rp_light,
|
||||
'casts_shadows') else True,
|
||||
'shadow_map_resolution': getattr(rp_light, 'shadow_map_resolution', 256) if hasattr(
|
||||
rp_light, 'shadow_map_resolution') else 256
|
||||
}
|
||||
elif node_type in ["GUI_BUTTON", "GUI_LABEL", "GUI_ENTRY", "GUI_IMAGE",
|
||||
"GUI_3D_TEXT", "GUI_3D_IMAGE", "GUI_VIRTUAL_SCREEN"]:
|
||||
# 保存GUI元素特定信息
|
||||
node_data['gui_data'] = self._serializeGUIData(node)
|
||||
elif node_type == "IMPORTED_MODEL_NODE":
|
||||
# 保存模型特定信息
|
||||
node_data['model_data'] = self._serializeModelData(node)
|
||||
|
||||
return node_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"序列化节点失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def _serializeGUIData(self, node):
|
||||
"""序列化GUI元素数据"""
|
||||
try:
|
||||
gui_data = {}
|
||||
|
||||
# 保存GUI相关的通用属性
|
||||
if node.hasTag("gui_type"):
|
||||
gui_data['gui_type'] = node.getTag("gui_type")
|
||||
|
||||
# 保存文本内容(如果有的话)
|
||||
if node.hasTag("text"):
|
||||
gui_data['text'] = node.getTag("text")
|
||||
|
||||
# 保存其他GUI相关标签
|
||||
gui_tags = ['font', 'font_size', 'text_color', 'bg_color', 'size']
|
||||
for tag in gui_tags:
|
||||
if node.hasTag(tag):
|
||||
gui_data[tag] = node.getTag(tag)
|
||||
|
||||
return gui_data
|
||||
except Exception as e:
|
||||
print(f"序列化GUI数据失败: {e}")
|
||||
return {}
|
||||
|
||||
def _serializeModelTextures(self, node):
|
||||
"""序列化模型纹理信息"""
|
||||
try:
|
||||
texture_data = {}
|
||||
|
||||
# 获取节点的所有纹理阶段
|
||||
from panda3d.core import TextureStage
|
||||
texture_stages = node.findAllTextureStages()
|
||||
|
||||
if texture_stages.getNumTextureStages() > 0:
|
||||
texture_data['textures'] = {}
|
||||
|
||||
# 遍历所有纹理阶段
|
||||
for i in range(texture_stages.getNumTextureStages()):
|
||||
stage = texture_stages.getTextureStage(i)
|
||||
stage_name = stage.getName()
|
||||
|
||||
# 获取该阶段的纹理
|
||||
texture = node.getTexture(stage)
|
||||
if texture:
|
||||
# 保存纹理信息
|
||||
texture_info = {
|
||||
'stage_name': stage_name,
|
||||
'stage_mode': stage.getMode(),
|
||||
'stage_sort': stage.getSort(), # 保存纹理阶段排序
|
||||
'texture_path': texture.getFullpath().toOsSpecific() if texture.hasFullpath() else '',
|
||||
'texture_name': texture.getName(),
|
||||
'wrap_u': texture.getWrapU(),
|
||||
'wrap_v': texture.getWrapV(),
|
||||
'minfilter': texture.getMinfilter(),
|
||||
'magfilter': texture.getMagfilter(),
|
||||
'anisotropic_degree': texture.getAnisotropicDegree()
|
||||
}
|
||||
|
||||
# 保存颜色比例和偏移(使用安全的方法)
|
||||
try:
|
||||
texture_info['color_scale'] = tuple(node.getTextureScale(stage))
|
||||
except:
|
||||
texture_info['color_scale'] = (1.0, 1.0, 1.0, 1.0)
|
||||
|
||||
try:
|
||||
texture_info['color_offset'] = tuple(node.getTextureOffset(stage))
|
||||
except:
|
||||
texture_info['color_offset'] = (0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
texture_data['textures'][stage_name] = texture_info
|
||||
|
||||
return texture_data
|
||||
except Exception as e:
|
||||
print(f"序列化模型纹理时出错: {e}")
|
||||
return {}
|
||||
|
||||
def _serializeModelData(self, node):
|
||||
"""序列化模型数据,包括材质和纹理信息"""
|
||||
try:
|
||||
model_data = {}
|
||||
|
||||
# 保存模型相关的标签
|
||||
model_tags = ['model_path', 'file', 'element_type']
|
||||
for tag in model_tags:
|
||||
if node.hasTag(tag):
|
||||
model_data[tag] = node.getTag(tag)
|
||||
|
||||
# 保存材质信息
|
||||
try:
|
||||
# 获取模型的材质信息
|
||||
if hasattr(node, 'getState'):
|
||||
state = node.getState()
|
||||
if state:
|
||||
# 保存基础颜色信息(使用正确的方法)
|
||||
from panda3d.core import ColorAttrib
|
||||
color_attrib = state.getAttrib(ColorAttrib)
|
||||
if color_attrib and not color_attrib.isOff():
|
||||
color = color_attrib.getColor()
|
||||
model_data['base_color'] = (
|
||||
color.getX(),
|
||||
color.getY(),
|
||||
color.getZ(),
|
||||
color.getW()
|
||||
)
|
||||
|
||||
# 保存其他材质属性
|
||||
from panda3d.core import MaterialAttrib
|
||||
material_attrib = state.getAttrib(MaterialAttrib.getClassType())
|
||||
if material_attrib:
|
||||
material = material_attrib.getMaterial()
|
||||
if material:
|
||||
# 保存基础颜色
|
||||
base_color = material.getBaseColor()
|
||||
model_data['material_base_color'] = (
|
||||
base_color.getX(), base_color.getY(), base_color.getZ(), base_color.getW()
|
||||
)
|
||||
|
||||
# 保存环境光颜色
|
||||
ambient_color = material.getAmbient()
|
||||
model_data['material_ambient_color'] = (
|
||||
ambient_color.getX(), ambient_color.getY(), ambient_color.getZ(),
|
||||
ambient_color.getW()
|
||||
)
|
||||
|
||||
# 保存漫反射颜色
|
||||
diffuse_color = material.getDiffuse()
|
||||
model_data['material_diffuse_color'] = (
|
||||
diffuse_color.getX(), diffuse_color.getY(), diffuse_color.getZ(),
|
||||
diffuse_color.getW()
|
||||
)
|
||||
|
||||
# 保存高光颜色
|
||||
specular_color = material.getSpecular()
|
||||
model_data['material_specular_color'] = (
|
||||
specular_color.getX(), specular_color.getY(), specular_color.getZ(),
|
||||
specular_color.getW()
|
||||
)
|
||||
|
||||
# 保存粗糙度和金属度等参数
|
||||
model_data['material_roughness'] = material.getRoughness()
|
||||
model_data['material_metallic'] = material.getMetallic()
|
||||
|
||||
# 保存自发光颜色
|
||||
emission_color = material.getEmission()
|
||||
model_data['material_emission_color'] = (
|
||||
emission_color.getX(), emission_color.getY(), emission_color.getZ(),
|
||||
emission_color.getW()
|
||||
)
|
||||
|
||||
# 保存光泽度
|
||||
model_data['material_shininess'] = material.getShininess()
|
||||
|
||||
# 保存透明度信息
|
||||
from panda3d.core import TransparencyAttrib
|
||||
transparency_attrib = state.getAttrib(TransparencyAttrib.getClassType())
|
||||
if transparency_attrib:
|
||||
model_data['transparency_mode'] = transparency_attrib.get_mode()
|
||||
|
||||
except Exception as e:
|
||||
print(f"保存材质信息时出错: {e}")
|
||||
|
||||
# 保存纹理信息
|
||||
try:
|
||||
texture_data = self._serializeModelTextures(node)
|
||||
if texture_data:
|
||||
model_data['texture_data'] = texture_data
|
||||
except Exception as e:
|
||||
print(f"保存纹理信息时出错: {e}")
|
||||
|
||||
return model_data
|
||||
except Exception as e:
|
||||
print(f"序列化模型数据失败: {e}")
|
||||
return {}
|
||||
|
||||
def recreateNodeFromData(self, node_data, parent_node):
|
||||
"""根据数据重建节点,并确保在场景树中显示"""
|
||||
try:
|
||||
if not node_data or not parent_node or parent_node.isEmpty():
|
||||
return None
|
||||
|
||||
print(f"正在重建节点 {node_data}")
|
||||
node_type = node_data.get('node_type', '')
|
||||
original_name = node_data.get('name', 'node')
|
||||
|
||||
# 生成唯一名称
|
||||
unique_name = self._generateUniqueName(original_name, parent_node)
|
||||
|
||||
# 根据节点类型调用相应的重建方法
|
||||
new_node = None
|
||||
if node_type in ["LIGHT_NODE", "SPOT_LIGHT_NODE", "POINT_LIGHT_NODE"]:
|
||||
new_node = self._recreateLightFromData(node_data, parent_node, unique_name)
|
||||
elif node_type == "CESIUM_TILESET_NODE":
|
||||
new_node = self._recreateTilesetFromData(node_data, parent_node, unique_name)
|
||||
elif node_type in ["GUI_BUTTON", "GUI_LABEL", "GUI_ENTRY", "GUI_IMAGE",
|
||||
"GUI_3DTEXT", "GUI_3DIMAGE", "GUI_VIDEO_SCREEN","GUI_2D_VIDEO_SCREEN"]:
|
||||
new_node = self._recreateGUIFromData(node_data, parent_node, unique_name)
|
||||
elif node_type == "IMPORTED_MODEL_NODE":
|
||||
new_node = self._recreateModelFromData(node_data, parent_node, unique_name)
|
||||
else:
|
||||
# 创建普通节点
|
||||
new_node = self._createBasicNodeFromData(node_data, parent_node, unique_name)
|
||||
|
||||
# 如果成功创建节点,确保它在场景树中显示
|
||||
if new_node:
|
||||
# 尝试更新场景树以显示新节点
|
||||
try:
|
||||
tree_widget = self._get_tree_widget()
|
||||
if tree_widget:
|
||||
# 查找父节点在场景树中的对应项
|
||||
parent_item = self._findTreeItemForNode(parent_node)
|
||||
if parent_item:
|
||||
# 添加新节点到场景树
|
||||
tree_widget.add_node_to_tree_widget(new_node, parent_item, node_type or "NODE")
|
||||
except Exception as e:
|
||||
print(f"添加节点到场景树时出错: {e}")
|
||||
|
||||
return new_node
|
||||
|
||||
except Exception as e:
|
||||
print(f"重建节点失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def _findTreeItemForNode(self, node):
|
||||
"""根据节点查找对应的场景树项"""
|
||||
try:
|
||||
tree_widget = self._get_tree_widget()
|
||||
if tree_widget:
|
||||
# 遍历场景树查找匹配的节点项
|
||||
for i in range(tree_widget.topLevelItemCount()):
|
||||
item = tree_widget.topLevelItem(i)
|
||||
result = self._findTreeItemForNodeRecursive(item, node)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"查找场景树项时出错: {e}")
|
||||
return None
|
||||
|
||||
def _findTreeItemForNodeRecursive(self, item, target_node):
|
||||
"""递归查找场景树项"""
|
||||
try:
|
||||
# 检查当前项是否匹配
|
||||
item_node = getattr(item, 'node_path', None)
|
||||
if not item_node:
|
||||
item_node = getattr(item, 'node', None)
|
||||
|
||||
if item_node and item_node == target_node:
|
||||
return item
|
||||
|
||||
# 递归检查子项
|
||||
for i in range(item.childCount()):
|
||||
child_item = item.child(i)
|
||||
result = self._findTreeItemForNodeRecursive(child_item, target_node)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"递归查找场景树项时出错: {e}")
|
||||
return None
|
||||
|
||||
def _recreateTilesetFromData(self, node_data, parent_node, name):
|
||||
"""根据数据重建Tileset"""
|
||||
try:
|
||||
tileset_url = node_data.get('tileset_url', '')
|
||||
if not tileset_url:
|
||||
return None
|
||||
|
||||
# 使用现有方法加载tileset
|
||||
position = node_data.get('pos', (0, 0, 0))
|
||||
tileset_node = self.load_cesium_tileset(tileset_url, position)
|
||||
|
||||
if tileset_node:
|
||||
# 设置名称
|
||||
tileset_node.setName(name)
|
||||
|
||||
# 恢复其他标签
|
||||
for tag_key, tag_value in node_data.get('tags', {}).items():
|
||||
if tag_key not in ['name']:
|
||||
tileset_node.setTag(tag_key, str(tag_value))
|
||||
|
||||
return tileset_node
|
||||
|
||||
except Exception as e:
|
||||
print(f"重建Tileset失败: {e}")
|
||||
return None
|
||||
|
||||
def _recreateGUIFromData(self, node_data, parent_node, name):
|
||||
"""根据数据重建GUI元素"""
|
||||
try:
|
||||
gui_data = node_data.get('gui_data', {})
|
||||
#gui_type = gui_data.get('gui_type', '')
|
||||
gui_type = node_data.get("tags").get("gui_type", "")
|
||||
|
||||
print(f"正在重建GUI元素: {gui_type}")
|
||||
print(f"正在重建GUI元素: {node_data}")
|
||||
|
||||
# 根据GUI类型调用相应的创建方法
|
||||
new_gui_element = None
|
||||
gui_manager = self._get_gui_manager()
|
||||
|
||||
if gui_type == "button" and hasattr(self.world, 'createGUIButton'):
|
||||
pos = node_data.get('pos', (0, 0, 0))
|
||||
text = node_data.get('tags').get('gui_text', '')
|
||||
size = node_data.get('scale', 1)
|
||||
print(pos,text,size)
|
||||
new_gui_element = self.world.createGUIButton(pos,text,size)
|
||||
elif gui_type == "label" and hasattr(self.world, 'createGUILabel'):
|
||||
pos = node_data.get('pos', (0, 0, 0))
|
||||
text = node_data.get('tags').get('gui_text', '')
|
||||
size = node_data.get('scale', 1)
|
||||
new_gui_element = self.world.createGUILabel(pos,text,size)
|
||||
elif gui_type == "entry" and hasattr(self.world, 'createGUIEntry'):
|
||||
pos = node_data.get('pos', (0, 0, 0))
|
||||
text = node_data.get('tags').get('gui_text', '')
|
||||
size = node_data.get('scale', 1)
|
||||
new_gui_element = self.world.createGUIEntry(pos,text,size)
|
||||
elif gui_type == "2d_image" and hasattr(self.world, 'createGUI2DImage'):
|
||||
pos = node_data.get('pos', (0, 0, 0))
|
||||
image_path = node_data.get('tags').get('image_path', '')
|
||||
size = node_data.get('size', 1)
|
||||
new_gui_element = self.world.createGUI2DImage(pos, image_path, size)
|
||||
elif gui_type == "3d_text" and hasattr(self.world, 'createGUI3DText'):
|
||||
print("正在创建3D文本!!!")
|
||||
pos = node_data.get('pos', (0, 0, 0))
|
||||
text = node_data.get('tags', {}).get('gui_text', '')
|
||||
scale = node_data.get('scale', 1)
|
||||
if isinstance(scale, (list, tuple)):
|
||||
scale = scale[0] if len(scale) > 0 else 1
|
||||
print(f"正在创建3D文本: 位置={pos}, 文本={text}, 大小={scale}")
|
||||
new_gui_element = self.world.createGUI3DText(pos, text, scale)
|
||||
elif gui_type == "3d_image" and hasattr(self.world, 'createGUI3DImage'):
|
||||
pos = node_data.get('pos', (0, 0, 0))
|
||||
image_path = node_data.get('tags').get('gui_image_path', '')
|
||||
scale = node_data.get('scale', (1, 1))
|
||||
if isinstance(scale, (int, float)):
|
||||
scale = (scale, scale)
|
||||
elif isinstance(scale, (list, tuple)) and len(scale) >= 2:
|
||||
scale = (scale[0], scale[1])
|
||||
else:
|
||||
scale = (1, 1)
|
||||
print(f"正在创建3D图片: 位置={pos}, 路径={image_path}, 大小={scale}")
|
||||
if gui_manager and hasattr(gui_manager, 'createGUI3DImage'):
|
||||
new_gui_element = gui_manager.createGUI3DImage(pos, image_path, scale)
|
||||
elif gui_type == "video_screen" and gui_manager and hasattr(gui_manager, 'createVideoScreen'):
|
||||
pos = node_data.get('pos', (0, 0, 0))
|
||||
video_path = node_data.get('tags').get('video_path', '')
|
||||
scale = node_data.get('scale', (1, 1,1))
|
||||
new_gui_element = gui_manager.createVideoScreen(pos,scale,video_path)
|
||||
elif gui_type == "2d_video_screen" and gui_manager and hasattr(gui_manager, 'createGUI2DVideoScreen'):
|
||||
pos = node_data.get('pos', (0, 0, 0))
|
||||
video_path = node_data.get('tags').get('video_path', '')
|
||||
scale = node_data.get('scale', (1, 1, 1))
|
||||
new_gui_element = gui_manager.createGUI2DVideoScreen(pos,scale,video_path)
|
||||
|
||||
if new_gui_element:
|
||||
# 设置名称和变换
|
||||
if hasattr(new_gui_element, 'setName'):
|
||||
new_gui_element.setName(name)
|
||||
|
||||
# 设置位置、旋转、缩放
|
||||
pos = node_data.get('pos', (0, 0, 0))
|
||||
hpr = node_data.get('hpr', (0, 0, 0))
|
||||
scale = node_data.get('scale', (1, 1, 1))
|
||||
|
||||
if hasattr(new_gui_element, 'setPos'):
|
||||
new_gui_element.setPos(*pos)
|
||||
if hasattr(new_gui_element, 'setHpr'):
|
||||
new_gui_element.setHpr(*hpr)
|
||||
if hasattr(new_gui_element, 'setScale'):
|
||||
new_gui_element.setScale(*scale)
|
||||
|
||||
# 恢复文本内容
|
||||
if 'text' in gui_data and hasattr(new_gui_element, 'setText'):
|
||||
new_gui_element.setText(gui_data['text'])
|
||||
|
||||
# 恢复其他标签
|
||||
for tag_key, tag_value in node_data.get('tags', {}).items():
|
||||
if hasattr(new_gui_element, 'setTag') and tag_key not in ['name']:
|
||||
new_gui_element.setTag(tag_key, str(tag_value))
|
||||
|
||||
print(f"GUI元素重建成功: {name}")
|
||||
|
||||
return new_gui_element
|
||||
|
||||
except Exception as e:
|
||||
print(f"重建GUI元素失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def _recreateModelFromData(self, node_data, parent_node, name):
|
||||
"""根据数据重建模型,保持材质"""
|
||||
try:
|
||||
model_data = node_data.get('model_data', {})
|
||||
model_path = model_data.get('model_path', model_data.get('file', ''))
|
||||
|
||||
if not model_path or not os.path.exists(model_path):
|
||||
# 如果原始模型文件不存在,创建一个基本节点
|
||||
return self._createBasicNodeFromData(node_data, parent_node, name)
|
||||
|
||||
# 导入模型,保持原有参数
|
||||
model = self.importModel(
|
||||
model_path,
|
||||
apply_unit_conversion=False, # 已经处理过的模型不需要再次转换
|
||||
normalize_scales=False, # 保持原有缩放
|
||||
auto_convert_to_glb=False # 已经处理过的模型不需要再次转换
|
||||
)
|
||||
|
||||
if model:
|
||||
# 设置名称
|
||||
model.setName(name)
|
||||
|
||||
# 设置变换
|
||||
pos = node_data.get('pos', (0, 0, 0))
|
||||
hpr = node_data.get('hpr', (0, 0, 0))
|
||||
scale = node_data.get('scale', (1, 1, 1))
|
||||
|
||||
model.setPos(*pos)
|
||||
model.setHpr(*hpr)
|
||||
model.setScale(*scale)
|
||||
|
||||
# 恢复材质和纹理信息
|
||||
try:
|
||||
self._restoreModelMaterial(model, model_data)
|
||||
except Exception as e:
|
||||
print(f"恢复模型材质时出错: {e}")
|
||||
|
||||
# 恢复标签
|
||||
for tag_key, tag_value in node_data.get('tags', {}).items():
|
||||
if tag_key not in ['name']:
|
||||
model.setTag(tag_key, str(tag_value))
|
||||
|
||||
# 添加到模型列表
|
||||
if model not in self.models:
|
||||
self.models.append(model)
|
||||
|
||||
return model
|
||||
|
||||
except Exception as e:
|
||||
print(f"重建模型失败: {e}")
|
||||
# 出错时创建基本节点
|
||||
return self._createBasicNodeFromData(node_data, parent_node, name)
|
||||
|
||||
def _restoreModelTextures(self, model, texture_data):
|
||||
"""恢复模型纹理"""
|
||||
try:
|
||||
if not texture_data or 'textures' not in texture_data:
|
||||
return
|
||||
|
||||
from panda3d.core import TextureStage, SamplerState
|
||||
|
||||
textures_info = texture_data['textures']
|
||||
|
||||
# 为每个纹理阶段恢复纹理
|
||||
for stage_name, texture_info in textures_info.items():
|
||||
# 创建纹理阶段
|
||||
stage = TextureStage(stage_name)
|
||||
stage.setMode(texture_info.get('stage_mode', TextureStage.M_modulate))
|
||||
# 恢复纹理阶段排序
|
||||
stage.setSort(texture_info.get('stage_sort', 0)) # 默认为0(p3d_Texture0)
|
||||
|
||||
# 加载纹理
|
||||
texture_path = texture_info['texture_path']
|
||||
if texture_path and os.path.exists(texture_path):
|
||||
texture = self.world.loader.loadTexture(texture_path)
|
||||
if texture:
|
||||
# 设置纹理属性
|
||||
texture.setWrapU(texture_info.get('wrap_u', SamplerState.WM_repeat))
|
||||
texture.setWrapV(texture_info.get('wrap_v', SamplerState.WM_repeat))
|
||||
texture.setMinfilter(texture_info.get('minfilter', SamplerState.FT_linear))
|
||||
texture.setMagfilter(texture_info.get('magfilter', SamplerState.FT_linear))
|
||||
texture.setAnisotropicDegree(texture_info.get('anisotropic_degree', 1))
|
||||
|
||||
# 应用纹理到模型
|
||||
model.setTexture(stage, texture, 1) # 1 表示强制应用
|
||||
|
||||
# 恢复颜色比例和偏移(使用安全的方法)
|
||||
if 'color_scale' in texture_info:
|
||||
try:
|
||||
model.setTextureScale(stage, *texture_info['color_scale'])
|
||||
except Exception as e:
|
||||
print(f"恢复纹理比例失败: {e}")
|
||||
|
||||
if 'color_offset' in texture_info:
|
||||
try:
|
||||
model.setTextureOffset(stage, *texture_info['color_offset'])
|
||||
except Exception as e:
|
||||
print(f"恢复纹理偏移失败: {e}")
|
||||
|
||||
print(f"恢复纹理: {stage_name} <- {texture_path}")
|
||||
else:
|
||||
print(f"纹理文件不存在或路径为空: {texture_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"恢复模型纹理时出错: {e}")
|
||||
|
||||
def _restoreModelMaterial(self, model, model_data):
|
||||
"""恢复模型材质和纹理"""
|
||||
try:
|
||||
# 恢复基础颜色
|
||||
if 'base_color' in model_data:
|
||||
from panda3d.core import ColorAttrib
|
||||
base_color = model_data['base_color']
|
||||
color = (base_color[0], base_color[1], base_color[2], base_color[3])
|
||||
model.setColor(color)
|
||||
|
||||
# 恢复复杂材质属性
|
||||
if any(key.startswith('material_') for key in model_data.keys()):
|
||||
from panda3d.core import Material
|
||||
|
||||
# 创建新材质或获取现有材质
|
||||
material = Material()
|
||||
|
||||
# 恢复基础颜色
|
||||
if 'material_base_color' in model_data:
|
||||
base_color = model_data['material_base_color']
|
||||
material.setBaseColor((base_color[0], base_color[1], base_color[2], base_color[3]))
|
||||
|
||||
# 恢复环境光颜色
|
||||
if 'material_ambient_color' in model_data:
|
||||
ambient_color = model_data['material_ambient_color']
|
||||
material.setAmbient((ambient_color[0], ambient_color[1], ambient_color[2], ambient_color[3]))
|
||||
|
||||
# 恢复漫反射颜色
|
||||
if 'material_diffuse_color' in model_data:
|
||||
diffuse_color = model_data['material_diffuse_color']
|
||||
material.setDiffuse((diffuse_color[0], diffuse_color[1], diffuse_color[2], diffuse_color[3]))
|
||||
|
||||
# 恢复高光颜色
|
||||
if 'material_specular_color' in model_data:
|
||||
specular_color = model_data['material_specular_color']
|
||||
material.setSpecular((specular_color[0], specular_color[1], specular_color[2], specular_color[3]))
|
||||
|
||||
# 恢复自发光颜色
|
||||
if 'material_emission_color' in model_data:
|
||||
emission_color = model_data['material_emission_color']
|
||||
material.setEmission((emission_color[0], emission_color[1], emission_color[2], emission_color[3]))
|
||||
|
||||
# 恢复粗糙度和金属度
|
||||
if 'material_roughness' in model_data:
|
||||
material.setRoughness(model_data['material_roughness'])
|
||||
|
||||
if 'material_metallic' in model_data:
|
||||
material.setMetallic(model_data['material_metallic'])
|
||||
|
||||
# 恢复光泽度
|
||||
if 'material_shininess' in model_data:
|
||||
material.setShininess(model_data['material_shininess'])
|
||||
|
||||
# 应用材质到模型
|
||||
model.setMaterial(material)
|
||||
|
||||
# 恢复透明度设置
|
||||
if 'transparency_mode' in model_data:
|
||||
from panda3d.core import TransparencyAttrib
|
||||
transparency_mode = model_data['transparency_mode']
|
||||
model.setTransparency(transparency_mode)
|
||||
|
||||
# 恢复纹理信息
|
||||
if 'texture_data' in model_data:
|
||||
self._restoreModelTextures(model, model_data['texture_data'])
|
||||
|
||||
except Exception as e:
|
||||
print(f"恢复材质失败: {e}")
|
||||
|
||||
def _createBasicNodeFromData(self, node_data, parent_node, name):
|
||||
"""创建基本节点,保持视觉属性"""
|
||||
try:
|
||||
new_node = parent_node.attachNewNode(name)
|
||||
|
||||
# 设置变换
|
||||
pos = node_data.get('pos', (0, 0, 0))
|
||||
hpr = node_data.get('hpr', (0, 0, 0))
|
||||
scale = node_data.get('scale', (1, 1, 1))
|
||||
|
||||
new_node.setPos(*pos)
|
||||
new_node.setHpr(*hpr)
|
||||
new_node.setScale(*scale)
|
||||
|
||||
# 恢复视觉属性
|
||||
try:
|
||||
# 恢复颜色
|
||||
if 'color' in node_data:
|
||||
color_data = node_data['color']
|
||||
new_node.setColor(color_data[0], color_data[1], color_data[2], color_data[3])
|
||||
|
||||
# 恢复材质
|
||||
if 'material' in node_data:
|
||||
from panda3d.core import Material
|
||||
material_data = node_data['material']
|
||||
material = Material()
|
||||
|
||||
if 'base_color' in material_data:
|
||||
bc = material_data['base_color']
|
||||
material.setBaseColor((bc[0], bc[1], bc[2], bc[3]))
|
||||
|
||||
if 'ambient' in material_data:
|
||||
ac = material_data['ambient']
|
||||
material.setAmbient((ac[0], ac[1], ac[2], ac[3]))
|
||||
|
||||
if 'diffuse' in material_data:
|
||||
dc = material_data['diffuse']
|
||||
material.setDiffuse((dc[0], dc[1], dc[2], dc[3]))
|
||||
|
||||
if 'specular' in material_data:
|
||||
sc = material_data['specular']
|
||||
material.setSpecular((sc[0], sc[1], sc[2], sc[3]))
|
||||
|
||||
if 'shininess' in material_data:
|
||||
material.setShininess(material_data['shininess'])
|
||||
|
||||
new_node.setMaterial(material)
|
||||
|
||||
except Exception as e:
|
||||
print(f"恢复视觉属性时出错: {e}")
|
||||
|
||||
# 恢复标签
|
||||
for tag_key, tag_value in node_data.get('tags', {}).items():
|
||||
if tag_key not in ['name']:
|
||||
new_node.setTag(tag_key, str(tag_value))
|
||||
|
||||
return new_node
|
||||
|
||||
except Exception as e:
|
||||
print(f"创建基本节点失败: {e}")
|
||||
return None
|
||||
|
||||
def _generateUniqueName(self, base_name, parent_node):
|
||||
"""生成唯一节点名称"""
|
||||
try:
|
||||
# 移除可能的数字后缀
|
||||
import re
|
||||
import time
|
||||
name_base = re.sub(r'_\d+$', '', base_name)
|
||||
|
||||
# 查找现有同名节点
|
||||
counter = 1
|
||||
unique_name = base_name
|
||||
while True:
|
||||
# 检查父节点下是否已存在同名子节点
|
||||
existing_node = parent_node.find(unique_name)
|
||||
if existing_node.isEmpty():
|
||||
break
|
||||
unique_name = f"{name_base}_{counter}"
|
||||
counter += 1
|
||||
if counter > 1000: # 防止无限循环
|
||||
break
|
||||
|
||||
return unique_name
|
||||
|
||||
except Exception as e:
|
||||
print(f"生成唯一名称时出错: {e}")
|
||||
return f"{base_name}_{int(time.time())}"
|
||||
5
scene/tree_roles.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Shared tree data-role constants."""
|
||||
|
||||
# Keep this value stable for legacy tree-item data payloads.
|
||||
TREE_USER_ROLE = 256
|
||||
|
||||
100
scene/util.py
@ -22,16 +22,17 @@ class CrossPlatformPathHandler:
|
||||
|
||||
print(f"路径处理器初始化 - 系统: {self.system}")
|
||||
|
||||
def normalize_model_path(self, filepath):
|
||||
"""标准化模型文件路径"""
|
||||
try:
|
||||
def normalize_model_path(self, filepath):
|
||||
"""标准化模型文件路径"""
|
||||
try:
|
||||
#print(f"\n=== 路径标准化处理 ===")
|
||||
#print(f"原始路径: {filepath}")
|
||||
#print(f"当前系统: {self.system}")
|
||||
|
||||
# 步骤1: 检查原始路径是否存在
|
||||
if self._check_file_exists(filepath):
|
||||
return self._panda3d_normalize(filepath)
|
||||
# 步骤1: 检查原始路径是否存在
|
||||
if self._check_file_exists(filepath):
|
||||
existing_path = self._to_os_specific_existing_path(filepath) or filepath
|
||||
return self._panda3d_normalize(existing_path)
|
||||
|
||||
# 步骤2: 路径修复尝试
|
||||
fixed_path = self._attempt_path_fixes(filepath)
|
||||
@ -51,21 +52,78 @@ class CrossPlatformPathHandler:
|
||||
print(f"❌ 路径标准化失败: {e}")
|
||||
return filepath
|
||||
|
||||
def _check_file_exists(self, filepath):
|
||||
"""检查文件是否存在"""
|
||||
exists = os.path.exists(filepath)
|
||||
return exists
|
||||
def _check_file_exists(self, filepath):
|
||||
"""检查文件是否存在"""
|
||||
try:
|
||||
if filepath and os.path.exists(filepath):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
os_path = self._to_os_specific_existing_path(filepath)
|
||||
if os_path and os.path.exists(os_path):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _to_os_specific_existing_path(self, filepath):
|
||||
"""将 Panda 风格路径转换为当前系统下真实存在的路径。"""
|
||||
path_text = os.fspath(filepath or "")
|
||||
if not path_text:
|
||||
return ""
|
||||
if os.path.exists(path_text):
|
||||
return os.path.normpath(path_text)
|
||||
|
||||
try:
|
||||
for ctor_name in ("fromOsSpecificW", "from_os_specific_w", "fromOsSpecific", "from_os_specific"):
|
||||
ctor = getattr(Filename, ctor_name, None)
|
||||
if not ctor:
|
||||
continue
|
||||
try:
|
||||
candidate = ctor(path_text).to_os_specific()
|
||||
if candidate and os.path.exists(candidate):
|
||||
return os.path.normpath(candidate)
|
||||
except Exception:
|
||||
continue
|
||||
candidate = Filename(path_text).to_os_specific()
|
||||
if candidate and os.path.exists(candidate):
|
||||
return os.path.normpath(candidate)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if len(path_text) >= 3 and path_text[0] in ("/", "\\") and path_text[1].isalpha() and path_text[2] in ("/", "\\"):
|
||||
drive_path = f"{path_text[1].upper()}:{path_text[2:]}"
|
||||
drive_path = os.path.normpath(drive_path)
|
||||
if os.path.exists(drive_path):
|
||||
return drive_path
|
||||
|
||||
return ""
|
||||
|
||||
def _panda3d_normalize(self, filepath):
|
||||
"""使用Panda3D标准化路径"""
|
||||
try:
|
||||
panda_filename = Filename.from_os_specific(filepath)
|
||||
normalized_path = panda_filename.get_fullpath()
|
||||
print(f"✓ Panda3D标准化: {normalized_path}")
|
||||
return normalized_path
|
||||
except Exception as e:
|
||||
print(f"⚠️ Panda3D标准化失败: {e}")
|
||||
return filepath
|
||||
def _panda3d_normalize(self, filepath):
|
||||
"""使用Panda3D标准化路径"""
|
||||
for ctor_name in ("fromOsSpecificW", "from_os_specific_w", "fromOsSpecific", "from_os_specific"):
|
||||
ctor = getattr(Filename, ctor_name, None)
|
||||
if not ctor:
|
||||
continue
|
||||
try:
|
||||
panda_filename = ctor(filepath)
|
||||
normalized_path = panda_filename.get_fullpath()
|
||||
if normalized_path:
|
||||
print(f"✓ Panda3D标准化: {normalized_path}")
|
||||
return normalized_path
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
panda_filename = Filename(filepath)
|
||||
normalized_path = panda_filename.get_fullpath()
|
||||
if normalized_path:
|
||||
print(f"✓ Panda3D标准化: {normalized_path}")
|
||||
return normalized_path
|
||||
except Exception as e:
|
||||
print(f"⚠️ Panda3D标准化失败: {e}")
|
||||
return filepath
|
||||
|
||||
def _attempt_path_fixes(self, filepath):
|
||||
"""尝试各种路径修复方法"""
|
||||
@ -293,4 +351,4 @@ def normalize_model_path(filepath):
|
||||
|
||||
def suggest_file_placement(filename):
|
||||
"""便捷函数:建议文件放置位置"""
|
||||
return path_handler.suggest_file_placement(filename)
|
||||
return path_handler.suggest_file_placement(filename)
|
||||
|
||||
2
ssbo_component/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""SSBO runtime helpers and editor components."""
|
||||
|
||||
410
ssbo_component/runtime_importer.py
Normal file
@ -0,0 +1,410 @@
|
||||
import os
|
||||
|
||||
from panda3d.core import Filename, GeomNode, Material, MaterialAttrib, NodePath
|
||||
|
||||
from .ssbo_controller import ObjectController
|
||||
|
||||
|
||||
class RuntimeSSBOSceneImporter:
|
||||
"""Minimal runtime-only SSBO scene importer without editor UI dependencies."""
|
||||
|
||||
def __init__(self, base_app):
|
||||
self.base = base_app
|
||||
self.controller = None
|
||||
self.model = None
|
||||
self.source_model = None
|
||||
self.source_model_root = None
|
||||
self.static_scene_root = None
|
||||
self.interactive_scene_root = None
|
||||
self.interactive_nodes = {}
|
||||
|
||||
def load_scene(self, scene_path, interactive_root_names=None):
|
||||
source_model = self._load_source_model_from_path(scene_path)
|
||||
self._clear_runtime_state()
|
||||
|
||||
source_root = self._ensure_source_model_root()
|
||||
imported_root = source_model.copyTo(source_root)
|
||||
self._set_node_name(imported_root, os.path.basename(scene_path) or "scene.bam")
|
||||
self.source_model = imported_root
|
||||
interactive_root_names = {str(name).strip() for name in (interactive_root_names or []) if str(name).strip()}
|
||||
|
||||
top_level_children = self._get_scene_top_level_children(imported_root)
|
||||
if interactive_root_names and top_level_children:
|
||||
self._build_release_split_scene(top_level_children, interactive_root_names)
|
||||
return self.interactive_scene_root or self.static_scene_root
|
||||
|
||||
working_holder = NodePath("ssbo_source_scene_work")
|
||||
working_root = source_root.copyTo(working_holder)
|
||||
|
||||
self.controller = ObjectController()
|
||||
self.controller.bake_ids_and_collect(working_root)
|
||||
self.model = self.controller.model
|
||||
self.model.reparentTo(self.base.render)
|
||||
|
||||
try:
|
||||
if not working_holder.isEmpty():
|
||||
working_holder.removeNode()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return self.model
|
||||
|
||||
def load_split_scene(self, *, static_scene_path="", interactive_scene_path="", interactive_root_names=None):
|
||||
self._clear_runtime_state()
|
||||
interactive_root_names = {str(name).strip() for name in (interactive_root_names or []) if str(name).strip()}
|
||||
|
||||
runtime_root = NodePath("runtime_split_scene")
|
||||
loaded_any = False
|
||||
|
||||
if static_scene_path and os.path.exists(static_scene_path):
|
||||
static_source = self._load_source_model_from_path(static_scene_path)
|
||||
static_holder = NodePath("ssbo_static_scene_work")
|
||||
static_root = static_source.copyTo(static_holder)
|
||||
|
||||
self.controller = ObjectController()
|
||||
self.controller.bake_ids_and_collect(static_root)
|
||||
self.model = self.controller.model
|
||||
if self.model and not self.model.isEmpty():
|
||||
self.model.reparentTo(runtime_root)
|
||||
self.static_scene_root = self.model
|
||||
loaded_any = True
|
||||
|
||||
try:
|
||||
if not static_holder.isEmpty():
|
||||
static_holder.removeNode()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if interactive_scene_path and os.path.exists(interactive_scene_path):
|
||||
interactive_source = self._load_source_model_from_path(interactive_scene_path)
|
||||
interactive_root = interactive_source.copyTo(runtime_root)
|
||||
self.interactive_scene_root = interactive_root
|
||||
loaded_any = True
|
||||
for child in self._get_scene_top_level_children(interactive_root):
|
||||
child_name = self._get_node_name(child, "")
|
||||
if not child_name:
|
||||
continue
|
||||
if interactive_root_names and child_name not in interactive_root_names:
|
||||
continue
|
||||
self.interactive_nodes[child_name] = child
|
||||
|
||||
if not loaded_any:
|
||||
try:
|
||||
if not runtime_root.isEmpty():
|
||||
runtime_root.removeNode()
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError("Failed to load split runtime scene")
|
||||
|
||||
runtime_root.reparentTo(self.base.render)
|
||||
print(
|
||||
f"[RuntimeSSBO] 已加载分层 cooked 场景: 静态={'yes' if self.static_scene_root else 'no'}, "
|
||||
f"交互对象={len(self.interactive_nodes)}"
|
||||
)
|
||||
return runtime_root
|
||||
|
||||
def _clear_runtime_state(self):
|
||||
for node in (self.model, self.static_scene_root, self.interactive_scene_root, self.source_model_root):
|
||||
try:
|
||||
if node and not node.isEmpty():
|
||||
node.removeNode()
|
||||
except Exception:
|
||||
continue
|
||||
self.controller = None
|
||||
self.model = None
|
||||
self.source_model = None
|
||||
self.source_model_root = None
|
||||
self.static_scene_root = None
|
||||
self.interactive_scene_root = None
|
||||
self.interactive_nodes = {}
|
||||
|
||||
def get_runtime_node_for_name(self, node_name):
|
||||
return self.interactive_nodes.get(str(node_name or "").strip())
|
||||
|
||||
def _get_scene_top_level_children(self, imported_root):
|
||||
children = []
|
||||
try:
|
||||
for child in imported_root.getChildren():
|
||||
if child and not child.isEmpty():
|
||||
children.append(child)
|
||||
except Exception:
|
||||
children = []
|
||||
|
||||
if children:
|
||||
return children
|
||||
return [imported_root] if imported_root and not imported_root.isEmpty() else []
|
||||
|
||||
def _build_release_split_scene(self, top_level_children, interactive_root_names):
|
||||
static_holder = NodePath("runtime_static_scene")
|
||||
interactive_holder = NodePath("runtime_interactive_scene")
|
||||
interactive_count = 0
|
||||
static_count = 0
|
||||
|
||||
for child in top_level_children:
|
||||
child_name = self._get_node_name(child, "")
|
||||
if child_name in interactive_root_names:
|
||||
runtime_child = child.copyTo(interactive_holder)
|
||||
self._set_node_name(runtime_child, child_name)
|
||||
self.interactive_nodes[child_name] = runtime_child
|
||||
interactive_count += 1
|
||||
else:
|
||||
child.copyTo(static_holder)
|
||||
static_count += 1
|
||||
|
||||
if static_count:
|
||||
self.static_scene_root = static_holder
|
||||
self.static_scene_root.reparentTo(self.base.render)
|
||||
try:
|
||||
self.static_scene_root.flattenStrong()
|
||||
except Exception as e:
|
||||
print(f"静态场景合批失败,继续使用未合批结果: {e}")
|
||||
else:
|
||||
try:
|
||||
if not static_holder.isEmpty():
|
||||
static_holder.removeNode()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if interactive_count:
|
||||
self.interactive_scene_root = interactive_holder
|
||||
self.interactive_scene_root.reparentTo(self.base.render)
|
||||
else:
|
||||
try:
|
||||
if not interactive_holder.isEmpty():
|
||||
interactive_holder.removeNode()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[RuntimeSSBO] 运行时分层导入完成: 交互对象 {interactive_count} 个, 静态合批对象 {static_count} 个")
|
||||
|
||||
def _load_source_model_from_path(self, model_path):
|
||||
source_model = None
|
||||
last_error = None
|
||||
for fn in self._build_filename_candidates(model_path):
|
||||
try:
|
||||
source_model = self.base.loader.loadModel(fn)
|
||||
if source_model and not source_model.isEmpty():
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
source_model = None
|
||||
|
||||
if not source_model or source_model.isEmpty():
|
||||
if last_error:
|
||||
raise RuntimeError(f"Failed to load model '{model_path}': {last_error}")
|
||||
raise RuntimeError(f"Failed to load model '{model_path}'")
|
||||
|
||||
self._fix_black_materials(source_model)
|
||||
self._repair_missing_textures(source_model, model_path)
|
||||
return source_model
|
||||
|
||||
def _build_filename_candidates(self, path_text):
|
||||
candidates = []
|
||||
seen = set()
|
||||
for ctor_name in ("fromOsSpecificW", "from_os_specific_w", "fromOsSpecific", "from_os_specific"):
|
||||
ctor = getattr(Filename, ctor_name, None)
|
||||
if not ctor:
|
||||
continue
|
||||
try:
|
||||
fn = ctor(path_text)
|
||||
key = fn.getFullpath() if hasattr(fn, "getFullpath") else fn.get_fullpath()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
candidates.append(fn)
|
||||
except Exception:
|
||||
continue
|
||||
if not candidates:
|
||||
try:
|
||||
candidates.append(Filename(path_text))
|
||||
except Exception:
|
||||
pass
|
||||
return candidates
|
||||
|
||||
def _ensure_source_model_root(self):
|
||||
root = self.source_model_root
|
||||
if root and not root.isEmpty():
|
||||
return root
|
||||
self.source_model_root = NodePath("ssbo_source_scene_root")
|
||||
return self.source_model_root
|
||||
|
||||
def _set_node_name(self, node, name):
|
||||
if not node:
|
||||
return
|
||||
try:
|
||||
node.set_name(name)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
node.setName(name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get_node_name(self, node, default_name=None):
|
||||
if not node:
|
||||
return default_name
|
||||
try:
|
||||
return node.get_name()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return node.getName()
|
||||
except Exception:
|
||||
return default_name
|
||||
|
||||
def _fix_black_materials(self, model):
|
||||
try:
|
||||
for geom_path in model.findAllMatches("**/+GeomNode"):
|
||||
geom_node = geom_path.node()
|
||||
node_state = geom_path.getState()
|
||||
if node_state.hasAttrib(MaterialAttrib.getClassType()):
|
||||
mat_attrib = node_state.getAttrib(MaterialAttrib.getClassType())
|
||||
mat = mat_attrib.getMaterial()
|
||||
if mat and self._is_dark_material(mat):
|
||||
new_mat = Material(mat)
|
||||
new_mat.setBaseColor((0.8, 0.8, 0.8, 1.0))
|
||||
new_mat.setDiffuse((0.8, 0.8, 0.8, 1.0))
|
||||
geom_path.setState(node_state.setAttrib(MaterialAttrib.make(new_mat)))
|
||||
|
||||
for i in range(geom_node.getNumGeoms()):
|
||||
geom_state = geom_node.getGeomState(i)
|
||||
if geom_state.hasAttrib(MaterialAttrib.getClassType()):
|
||||
mat_attrib = geom_state.getAttrib(MaterialAttrib.getClassType())
|
||||
mat = mat_attrib.getMaterial()
|
||||
if mat and self._is_dark_material(mat):
|
||||
new_mat = Material(mat)
|
||||
new_mat.setBaseColor((0.8, 0.8, 0.8, 1.0))
|
||||
new_mat.setDiffuse((0.8, 0.8, 0.8, 1.0))
|
||||
geom_node.setGeomState(i, geom_state.setAttrib(MaterialAttrib.make(new_mat)))
|
||||
else:
|
||||
new_mat = Material()
|
||||
new_mat.setBaseColor((0.8, 0.8, 0.8, 1.0))
|
||||
new_mat.setDiffuse((0.8, 0.8, 0.8, 1.0))
|
||||
new_mat.setSpecular((0.2, 0.2, 0.2, 1.0))
|
||||
new_mat.setRoughness(0.8)
|
||||
geom_node.setGeomState(i, geom_state.addAttrib(MaterialAttrib.make(new_mat)))
|
||||
model.clearColor()
|
||||
except Exception as e:
|
||||
print(f"修复黑色模型材质时出错: {e}")
|
||||
|
||||
def _is_dark_material(self, material):
|
||||
try:
|
||||
if material.hasBaseColor():
|
||||
c = material.getBaseColor()
|
||||
elif material.hasDiffuse():
|
||||
c = material.getDiffuse()
|
||||
else:
|
||||
return True
|
||||
return c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _repair_missing_textures(self, model_np, model_path):
|
||||
if not model_np or model_np.isEmpty():
|
||||
return
|
||||
|
||||
search_dirs = self._build_texture_search_dirs(model_path)
|
||||
texture_index = self._index_texture_files(search_dirs)
|
||||
|
||||
for node in [model_np] + list(model_np.findAllMatches("**")):
|
||||
if not node or node.isEmpty():
|
||||
continue
|
||||
try:
|
||||
stages = node.findAllTextureStages()
|
||||
stage_count = stages.getNumTextureStages()
|
||||
stage_at = stages.getTextureStage
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for i in range(stage_count):
|
||||
stage = stage_at(i)
|
||||
if not stage:
|
||||
continue
|
||||
try:
|
||||
tex = node.getTexture(stage)
|
||||
except Exception:
|
||||
tex = None
|
||||
if not tex or self._texture_is_valid(tex):
|
||||
continue
|
||||
|
||||
basename = self._extract_texture_basename(tex)
|
||||
if not basename:
|
||||
continue
|
||||
|
||||
replacement = texture_index.get(basename.lower())
|
||||
if replacement:
|
||||
new_tex = self._load_texture_from_path(replacement)
|
||||
if new_tex:
|
||||
try:
|
||||
node.setTexture(stage, new_tex, 1)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
node.clearTexture(stage)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _build_texture_search_dirs(self, model_path):
|
||||
dirs = []
|
||||
model_dir = os.path.dirname(os.path.abspath(model_path))
|
||||
project_root = getattr(self.base, "project_path", "") or os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def add_dir(path):
|
||||
if path and os.path.isdir(path):
|
||||
normalized = os.path.normpath(path)
|
||||
if normalized not in dirs:
|
||||
dirs.append(normalized)
|
||||
|
||||
add_dir(model_dir)
|
||||
for sub in ("textures", "texture", "tex", "assets", "materials"):
|
||||
add_dir(os.path.join(model_dir, sub))
|
||||
add_dir(os.path.join(project_root, "resources", sub))
|
||||
add_dir(os.path.join(project_root, "resources"))
|
||||
return dirs
|
||||
|
||||
def _index_texture_files(self, dirs, limit=30000):
|
||||
texture_exts = {".png", ".jpg", ".jpeg", ".tga", ".bmp", ".dds", ".ktx", ".ktx2", ".webp"}
|
||||
index = {}
|
||||
scanned = 0
|
||||
for root_dir in dirs:
|
||||
try:
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for filename in files:
|
||||
if os.path.splitext(filename)[1].lower() not in texture_exts:
|
||||
continue
|
||||
index.setdefault(filename.lower(), os.path.join(root, filename))
|
||||
scanned += 1
|
||||
if scanned >= limit:
|
||||
return index
|
||||
except Exception:
|
||||
continue
|
||||
return index
|
||||
|
||||
def _load_texture_from_path(self, texture_path):
|
||||
for fn in self._build_filename_candidates(texture_path):
|
||||
try:
|
||||
tex = self.base.loader.loadTexture(fn)
|
||||
if tex:
|
||||
return tex
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _texture_is_valid(self, texture):
|
||||
try:
|
||||
x_size = texture.getXSize()
|
||||
y_size = texture.getYSize()
|
||||
return bool(x_size and y_size)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _extract_texture_basename(self, texture):
|
||||
try:
|
||||
path = texture.getFilename().toOsSpecific()
|
||||
except Exception:
|
||||
path = ""
|
||||
if not path:
|
||||
return ""
|
||||
return os.path.basename(path)
|
||||