feat(3d): FBX/GLTF/OBJ 加载器与骨骼动画支持 (#315)

* feat(3d): FBX/GLTF/OBJ 加载器与骨骼动画支持

* chore: 更新 pnpm-lock.yaml

* fix: 移除未使用的变量和方法

* fix: 修复 mesh-3d-editor tsconfig 引用路径

* fix: 修复正则表达式 ReDoS 漏洞
This commit is contained in:
YHH
2025-12-23 15:34:01 +08:00
committed by GitHub
parent 49dd6a91c6
commit 828ff969e1
69 changed files with 16370 additions and 56 deletions

View File

@@ -32,6 +32,8 @@
"@esengine/engine-core": "workspace:*",
"@esengine/material-editor": "workspace:*",
"@esengine/material-system": "workspace:*",
"@esengine/mesh-3d": "workspace:*",
"@esengine/mesh-3d-editor": "workspace:*",
"@esengine/particle": "workspace:*",
"@esengine/particle-editor": "workspace:*",
"@esengine/physics-rapier2d": "workspace:*",

View File

@@ -75,12 +75,30 @@ export class TauriAPI {
}
/**
* 读取文件内容
* 读取文件内容(文本)
*/
static async readFileContent(path: string): Promise<string> {
return await invoke<string>('read_file_content', { path });
}
/**
* 读取文件内容(二进制)
* Read file content as binary ArrayBuffer
*/
static async readFileBinary(path: string): Promise<ArrayBuffer> {
// Use Tauri read_file_as_base64 command which returns base64 encoded data
// 使用 Tauri 的 read_file_as_base64 命令,返回 base64 编码的数据
const base64: string = await invoke<string>('read_file_as_base64', { filePath: path });
// Decode base64 to ArrayBuffer
// 将 base64 解码为 ArrayBuffer
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
/**
* 列出目录内容
*/

View File

@@ -27,6 +27,7 @@ import { BlueprintPlugin } from '@esengine/blueprint-editor';
import { MaterialPlugin } from '@esengine/material-editor';
import { SpritePlugin } from '@esengine/sprite-editor';
import { ShaderEditorPlugin } from '@esengine/shader-editor';
import { Mesh3DPlugin } from '@esengine/mesh-3d-editor';
// 纯运行时插件 | Runtime-only plugins
import { CameraPlugin } from '@esengine/camera';
@@ -70,6 +71,7 @@ export class PluginInstaller {
{ name: 'BlueprintPlugin', plugin: BlueprintPlugin },
{ name: 'MaterialPlugin', plugin: MaterialPlugin },
{ name: 'ShaderEditorPlugin', plugin: ShaderEditorPlugin },
{ name: 'Mesh3DPlugin', plugin: Mesh3DPlugin },
];
for (const { name, plugin } of modulePlugins) {

View File

@@ -41,10 +41,17 @@ import {
AlertTriangle,
X,
FolderPlus,
Inbox
Inbox,
Box,
Bone,
Film,
Palette,
Loader2
} from 'lucide-react';
import { Core, Entity, HierarchySystem, PrefabSerializer } from '@esengine/ecs-framework';
import { MessageHub, FileActionRegistry, AssetRegistryService, MANAGED_ASSET_DIRECTORIES, type FileCreationTemplate, EntityStoreService, SceneManagerService } from '@esengine/editor-core';
import type { IGLTFAsset, IMeshData, IGLTFMaterial, IGLTFAnimationClip, IAssetContent, IAssetParseContext } from '@esengine/asset-system';
import { FBXLoader, GLTFLoader } from '@esengine/asset-system';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { SettingsService } from '../services/SettingsService';
import { ContextMenu, ContextMenuItem } from './ContextMenu';
@@ -54,10 +61,25 @@ import '../styles/ContentBrowser.css';
interface AssetItem {
name: string;
path: string;
type: 'file' | 'folder';
type: 'file' | 'folder' | 'sub-asset';
extension?: string;
size?: number;
modified?: number;
// Sub-asset specific fields
// 子资产特定字段
parentPath?: string; // Path to parent model file | 父模型文件路径
subAssetType?: 'mesh' | 'material' | 'animation' | 'skeleton';
subAssetIndex?: number; // Index within parent asset | 在父资产中的索引
}
/**
* Check if file extension is an expandable 3D model
* 检查文件扩展名是否是可展开的3D模型
*/
function isExpandableModel(extension: string | undefined): boolean {
if (!extension) return false;
const ext = extension.toLowerCase();
return ['fbx', 'gltf', 'glb', 'obj'].includes(ext);
}
interface FolderNode {
@@ -159,6 +181,17 @@ function highlightSearchText(text: string, query: string): React.ReactNode {
function getAssetTypeName(asset: AssetItem): string {
if (asset.type === 'folder') return 'Folder';
// Handle sub-assets | 处理子资产
if (asset.type === 'sub-asset') {
switch (asset.subAssetType) {
case 'mesh': return 'Mesh';
case 'material': return 'Material';
case 'animation': return 'Animation';
case 'skeleton': return 'Skeleton';
default: return 'Sub-Asset';
}
}
// Check for compound extensions first
const name = asset.name.toLowerCase();
if (name.endsWith('.tilemap.json') || name.endsWith('.tilemap')) return 'Tilemap';
@@ -180,6 +213,10 @@ function getAssetTypeName(asset: AssetItem): string {
case 'prefab': return 'Prefab';
case 'mat': return 'Material';
case 'anim': return 'Animation';
case 'fbx':
case 'gltf':
case 'glb':
case 'obj': return '3D Model';
default: return ext?.toUpperCase() || 'File';
}
}
@@ -251,6 +288,12 @@ export function ContentBrowser({
// Drag and drop state for file moving
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
// Expanded model assets (for viewing sub-assets)
// 展开的模型资产(用于查看子资产)
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
const [modelSubAssets, setModelSubAssets] = useState<Map<string, AssetItem[]>>(new Map());
const [loadingModels, setLoadingModels] = useState<Set<string>>(new Set());
// 初始化和监听插件安装事件以更新模板列表
// Initialize and listen for plugin installation events to update template list
useEffect(() => {
@@ -637,6 +680,172 @@ export class ${className} {
}
}, [currentPath, projectPath, loadAssets, buildFolderTree]);
/**
* Load sub-assets from a 3D model file
* 从3D模型文件加载子资产
*/
const loadModelSubAssets = useCallback(async (modelPath: string): Promise<AssetItem[]> => {
try {
const modelName = modelPath.split(/[\\/]/).pop() || 'Model';
const ext = modelName.split('.').pop()?.toLowerCase() || '';
// Read file binary content
// 读取文件二进制内容
const binaryData = await TauriAPI.readFileBinary(modelPath);
if (!binaryData || binaryData.byteLength === 0) {
console.warn('[ContentBrowser] Cannot read file:', modelPath);
return [];
}
// Create minimal parse context (loaders don't need full metadata for basic parsing)
// 创建最小解析上下文(加载器只需要基本解析的路径信息)
const parseContext = {
metadata: {
path: modelPath,
name: modelName,
type: ext === 'fbx' ? 'model/fbx' : 'model/gltf',
guid: '',
size: binaryData.byteLength,
hash: '',
dependencies: [],
lastModified: Date.now(),
importerVersion: '1.0.0',
labels: [],
tags: [],
version: 1
},
loadDependency: async () => null
} as unknown as IAssetParseContext;
// Create content object
// 创建内容对象
const content: IAssetContent = {
type: 'binary',
binary: binaryData
};
// Select appropriate loader and parse
// 选择合适的加载器并解析
let asset: IGLTFAsset;
if (ext === 'fbx') {
const loader = new FBXLoader();
asset = await loader.parse(content, parseContext);
} else if (ext === 'gltf' || ext === 'glb') {
const loader = new GLTFLoader();
asset = await loader.parse(content, parseContext);
} else {
console.warn('[ContentBrowser] Unsupported model format:', ext);
return [];
}
const subAssets: AssetItem[] = [];
// Add meshes
// 添加网格
if (asset.meshes && asset.meshes.length > 0) {
asset.meshes.forEach((mesh: IMeshData, index: number) => {
subAssets.push({
name: mesh.name || `Mesh_${index}`,
path: `${modelPath}#mesh:${index}`,
type: 'sub-asset',
parentPath: modelPath,
subAssetType: 'mesh',
subAssetIndex: index
});
});
}
// Add materials
// 添加材质
if (asset.materials && asset.materials.length > 0) {
asset.materials.forEach((material: IGLTFMaterial, index: number) => {
subAssets.push({
name: material.name || `Material_${index}`,
path: `${modelPath}#material:${index}`,
type: 'sub-asset',
parentPath: modelPath,
subAssetType: 'material',
subAssetIndex: index
});
});
}
// Add animations
// 添加动画
if (asset.animations && asset.animations.length > 0) {
asset.animations.forEach((anim: IGLTFAnimationClip, index: number) => {
subAssets.push({
name: anim.name || `Animation_${index}`,
path: `${modelPath}#animation:${index}`,
type: 'sub-asset',
parentPath: modelPath,
subAssetType: 'animation',
subAssetIndex: index
});
});
}
// Add skeleton if present
// 添加骨骼(如果存在)
if (asset.skeleton) {
subAssets.push({
name: `Skeleton (${asset.skeleton.joints.length} joints)`,
path: `${modelPath}#skeleton:0`,
type: 'sub-asset',
parentPath: modelPath,
subAssetType: 'skeleton',
subAssetIndex: 0
});
}
console.log(`[ContentBrowser] Loaded sub-assets for ${modelName}:`);
console.log(` - Meshes: ${asset.meshes?.length ?? 0}`);
console.log(` - Materials: ${asset.materials?.length ?? 0}`);
console.log(` - Animations: ${asset.animations?.length ?? 0}`);
console.log(` - Skeleton: ${asset.skeleton ? 'yes' : 'no'}`);
console.log(` - Sub-assets total: ${subAssets.length}`);
return subAssets;
} catch (error) {
console.error('[ContentBrowser] Failed to load model sub-assets:', error);
return [];
}
}, []);
/**
* Toggle model expansion
* 切换模型展开状态
*/
const toggleModelExpand = useCallback(async (modelPath: string, e: React.MouseEvent) => {
e.stopPropagation();
const isExpanded = expandedModels.has(modelPath);
if (isExpanded) {
// Collapse
// 折叠
setExpandedModels(prev => {
const next = new Set(prev);
next.delete(modelPath);
return next;
});
} else {
// Expand - load sub-assets if not already loaded
// 展开 - 如果尚未加载则加载子资产
if (!modelSubAssets.has(modelPath)) {
setLoadingModels(prev => new Set(prev).add(modelPath));
const subAssets = await loadModelSubAssets(modelPath);
setModelSubAssets(prev => new Map(prev).set(modelPath, subAssets));
setLoadingModels(prev => {
const next = new Set(prev);
next.delete(modelPath);
return next;
});
}
setExpandedModels(prev => new Set(prev).add(modelPath));
}
}, [expandedModels, modelSubAssets, loadModelSubAssets]);
// 点击外部关闭过滤器下拉菜单 | Close filter dropdown when clicking outside
useEffect(() => {
if (!showFilterDropdown) return;
@@ -1031,6 +1240,21 @@ export class ${className} {
setCurrentPath(asset.path);
loadAssets(asset.path);
setExpandedFolders(prev => new Set([...prev, asset.path]));
} else if (asset.type === 'sub-asset') {
// Handle sub-asset double click
// 处理子资产双击
if (asset.subAssetType === 'animation' && asset.parentPath) {
// Open animation preview panel
// 打开动画预览面板
messageHub?.publish('animation:preview', {
filePath: asset.parentPath,
animationIndex: asset.subAssetIndex ?? 0
});
console.log('[ContentBrowser] Opening animation preview:', asset.parentPath, 'index:', asset.subAssetIndex);
}
// Other sub-asset types can be handled here
// 其他子资产类型可以在这里处理
return;
} else {
const ext = asset.extension?.toLowerCase();
console.log('[ContentBrowser] File ext:', ext, 'onOpenScene:', !!onOpenScene);
@@ -1088,7 +1312,7 @@ export class ${className} {
console.error('Failed to open file:', error);
}
}
}, [loadAssets, onOpenScene, fileActionRegistry, projectPath]);
}, [loadAssets, onOpenScene, fileActionRegistry, projectPath, messageHub]);
// Handle context menu
const handleContextMenu = useCallback((e: React.MouseEvent, asset?: AssetItem) => {
@@ -1194,6 +1418,23 @@ export class ${className} {
return <Folder size={size} className="asset-thumbnail-icon folder" />;
}
// Handle sub-assets
// 处理子资产
if (asset.type === 'sub-asset') {
switch (asset.subAssetType) {
case 'mesh':
return <Box size={size} className="asset-thumbnail-icon sub-asset mesh" />;
case 'material':
return <Palette size={size} className="asset-thumbnail-icon sub-asset material" />;
case 'animation':
return <Film size={size} className="asset-thumbnail-icon sub-asset animation" />;
case 'skeleton':
return <Bone size={size} className="asset-thumbnail-icon sub-asset skeleton" />;
default:
return <File size={size} className="asset-thumbnail-icon sub-asset" />;
}
}
const ext = asset.extension?.toLowerCase();
switch (ext) {
case 'ecs':
@@ -1213,6 +1454,13 @@ export class ${className} {
case 'gif':
case 'webp':
return <FileImage size={size} className="asset-thumbnail-icon image" />;
// 3D Model files | 3D 模型文件
case 'fbx':
case 'obj':
case 'gltf':
case 'glb':
case 'dae':
return <Box size={size} className="asset-thumbnail-icon model3d" />;
default:
return <File size={size} className="asset-thumbnail-icon" />;
}
@@ -1698,8 +1946,8 @@ export class ${className} {
});
}, []);
// Filter assets by search and hidden extensions
// 按搜索词和隐藏扩展名过滤资产
// Filter assets by search and hidden extensions, and inject sub-assets for expanded models
// 按搜索词和隐藏扩展名过滤资产,并为展开的模型注入子资产
const filteredAssets = useMemo(() => {
let result = assets;
@@ -1717,8 +1965,24 @@ export class ${className} {
result = result.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()));
}
// Inject sub-assets for expanded models
// 为展开的模型注入子资产
if (expandedModels.size > 0) {
const resultWithSubAssets: AssetItem[] = [];
for (const asset of result) {
resultWithSubAssets.push(asset);
// If this is an expanded model, add its sub-assets after it
// 如果这是一个展开的模型,在其后添加子资产
if (asset.type === 'file' && isExpandableModel(asset.extension) && expandedModels.has(asset.path)) {
const subAssets = modelSubAssets.get(asset.path) || [];
resultWithSubAssets.push(...subAssets);
}
}
result = resultWithSubAssets;
}
return result;
}, [assets, hiddenExtensions, searchQuery]);
}, [assets, hiddenExtensions, searchQuery, expandedModels, modelSubAssets]);
const breadcrumbs = getBreadcrumbs();
@@ -1994,18 +2258,26 @@ export class ${className} {
) : (
filteredAssets.map(asset => {
const isDragOverAsset = asset.type === 'folder' && dragOverFolder === asset.path;
const isSubAsset = asset.type === 'sub-asset';
const isExpandableFile = asset.type === 'file' && isExpandableModel(asset.extension);
const isModelExpanded = isExpandableFile && expandedModels.has(asset.path);
const isModelLoading = isExpandableFile && loadingModels.has(asset.path);
return (
<div
key={asset.path}
className={`cb-asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''} ${isDragOverAsset ? 'drag-over' : ''}`}
className={`cb-asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''} ${isDragOverAsset ? 'drag-over' : ''} ${isSubAsset ? 'sub-asset' : ''} ${isModelExpanded ? 'expanded' : ''}`}
onClick={(e) => handleAssetClick(asset, e)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
onContextMenu={(e) => {
e.stopPropagation();
handleContextMenu(e, asset);
}}
draggable
draggable={!isSubAsset}
onDragStart={(e) => {
if (isSubAsset) {
e.preventDefault();
return;
}
e.dataTransfer.setData('asset-path', asset.path);
e.dataTransfer.setData('text/plain', asset.path);
// Add GUID for files
@@ -2038,6 +2310,22 @@ export class ${className} {
}
}}
>
{/* Expand button for 3D models | 3D模型的展开按钮 */}
{isExpandableFile && (
<button
className={`cb-asset-expand-btn ${isModelExpanded ? 'expanded' : ''}`}
onClick={(e) => toggleModelExpand(asset.path, e)}
title={isModelExpanded ? 'Collapse' : 'Expand'}
>
{isModelLoading ? (
<Loader2 size={12} className="spinning" />
) : isModelExpanded ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
</button>
)}
<div className="cb-asset-thumbnail">
{getFileIcon(asset)}
</div>

View File

@@ -23,6 +23,7 @@ import { QRCodeDialog } from './QRCodeDialog';
import { collectAssetReferences } from '@esengine/asset-system';
import { RuntimeSceneManager, type IRuntimeSceneManager } from '@esengine/runtime-core';
import { ParticleSystemComponent } from '@esengine/particle';
import { MeshComponent } from '@esengine/mesh-3d';
import type { ModuleManifest } from '../services/RuntimeResolver';
@@ -314,6 +315,12 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const orbitCameraRef = useRef(orbitCamera);
const isOrbitingRef = useRef(false);
const isPanningRef = useRef(false);
const isZoomingRef = useRef(false);
// Fly mode (right-click + WASD) | 飞行模式(右键 + WASD
const isFlyModeRef = useRef(false);
const flyKeysRef = useRef({ w: false, a: false, s: false, d: false, q: false, e: false });
const flySpeedRef = useRef(10); // units per second | 每秒单位数
const altKeyRef = useRef(false);
const selectedEntityRef = useRef<Entity | null>(null);
const messageHubRef = useRef<MessageHub | null>(null);
const commandManagerRef = useRef<CommandManager | null>(null);
@@ -490,23 +497,43 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
return;
}
// 3D mode: orbit camera controls
// 3D 模式:轨道相机控制
// 3D mode: Scene view camera controls
// 3D 模式:场景视图相机控制
// - Alt + Left: Orbit | Alt + 左键:轨道旋转
// - Alt + Middle / Middle: Pan | Alt + 中键 / 中键:平移
// - Alt + Right: Zoom | Alt + 右键:缩放
// - Right: Fly mode (+ WASD) | 右键:飞行模式(+ WASD
if (renderModeRef.current === '3D') {
if (e.button === 0) {
// Left button: orbit (rotate around target)
// 左键:轨道旋转(围绕目标旋转)
const isAlt = e.altKey;
if (e.button === 0 && isAlt) {
// Alt + Left button: orbit (rotate around target)
// Alt + 左键:轨道旋转(围绕目标旋转)
isOrbitingRef.current = true;
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
canvas.style.cursor = 'grabbing';
e.preventDefault();
} else if (e.button === 1 || e.button === 2) {
// Middle/Right button: pan
// 中键/右键:平移
} else if (e.button === 1 || (e.button === 1 && isAlt)) {
// Middle button (with or without Alt): pan
// 中键(有无 Alt:平移
isPanningRef.current = true;
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
canvas.style.cursor = 'move';
e.preventDefault();
} else if (e.button === 2 && isAlt) {
// Alt + Right button: zoom
// Alt + 右键:缩放
isZoomingRef.current = true;
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
canvas.style.cursor = 'ns-resize';
e.preventDefault();
} else if (e.button === 2 && !isAlt) {
// Right button (without Alt): fly mode
// 右键(无 Alt飞行模式
isFlyModeRef.current = true;
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
canvas.style.cursor = 'crosshair';
e.preventDefault();
}
return;
}
@@ -608,8 +635,8 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const deltaX = e.clientX - lastMousePosRef.current.x;
const deltaY = e.clientY - lastMousePosRef.current.y;
// 3D mode: orbit camera controls
// 3D 模式:轨道相机控制
// 3D mode: Scene view camera controls
// 3D 模式:场景视图相机控制
if (renderModeRef.current === '3D') {
if (isOrbitingRef.current) {
// Orbit: rotate around target
@@ -619,31 +646,76 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const newYaw = prev.yaw + deltaX * orbitSensitivity;
const newPitch = Math.max(-89, Math.min(89, prev.pitch - deltaY * orbitSensitivity));
const newOrbit = { ...prev, yaw: newYaw, pitch: newPitch };
// Sync to engine in next tick
// 在下一帧同步到引擎
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
return newOrbit;
});
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
} else if (isPanningRef.current) {
// Pan: move target point
// 平移:移动目标点
// Pan: move target point (drag to move scene in same direction)
// 平移:移动目标点(拖拽方向与场景移动方向相同)
const panSensitivity = 0.01;
const orbit = orbitCameraRef.current;
const yawRad = (orbit.yaw * Math.PI) / 180;
const pitchRad = (orbit.pitch * Math.PI) / 180;
// Calculate pan direction based on camera orientation
// 根据相机朝向计算平移方向
// Calculate camera right and up vectors
// 计算相机的右向量和上向量
const rightX = Math.cos(yawRad);
const rightZ = -Math.sin(yawRad);
// Up vector in world space (simplified)
const upY = Math.cos(pitchRad);
setOrbitCamera((prev) => {
const panScale = prev.distance * panSensitivity;
// 左右反转,上下保持原样
// Invert horizontal, keep vertical as is
const newOrbit = {
...prev,
targetX: prev.targetX - deltaX * rightX * panScale,
targetY: prev.targetY + deltaY * panScale,
targetZ: prev.targetZ - deltaX * rightZ * panScale
targetX: prev.targetX + deltaX * rightX * panScale,
targetY: prev.targetY + deltaY * upY * panScale,
targetZ: prev.targetZ + deltaX * rightZ * panScale
};
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
return newOrbit;
});
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
} else if (isZoomingRef.current) {
// Alt + Right: Zoom by moving distance
// Alt + 右键:通过改变距离来缩放
const zoomSensitivity = 0.02;
setOrbitCamera((prev) => {
const newDistance = Math.max(0.5, Math.min(1000, prev.distance * (1 + deltaY * zoomSensitivity)));
const newOrbit = { ...prev, distance: newDistance };
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
return newOrbit;
});
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
} else if (isFlyModeRef.current) {
// Fly mode: first-person camera look (like FPS)
// 飞行模式:第一人称相机视角(类似 FPS
const lookSensitivity = 0.2;
setOrbitCamera((prev) => {
const newYaw = prev.yaw + deltaX * lookSensitivity;
const newPitch = Math.max(-89, Math.min(89, prev.pitch - deltaY * lookSensitivity));
// In fly mode, target moves with camera to maintain forward direction
// 在飞行模式下,目标点随相机移动以保持前进方向
const pos = calculateOrbitCameraPosition({ ...prev, yaw: newYaw, pitch: newPitch });
// Calculate new target based on camera looking forward
// 根据相机前方计算新目标点
const pitchRad = (newPitch * Math.PI) / 180;
const yawRad = (newYaw * Math.PI) / 180;
const forwardX = -Math.cos(pitchRad) * Math.sin(yawRad);
const forwardY = Math.sin(pitchRad);
const forwardZ = -Math.cos(pitchRad) * Math.cos(yawRad);
const newOrbit = {
...prev,
yaw: newYaw,
pitch: newPitch,
targetX: pos.x + forwardX * prev.distance,
targetY: pos.y + forwardY * prev.distance,
targetZ: pos.z + forwardZ * prev.distance
};
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
return newOrbit;
@@ -749,8 +821,8 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
};
const handleMouseUp = () => {
// 3D mode: reset orbit/pan flags
// 3D 模式:重置轨道/平移标志
// 3D mode: reset all camera control flags
// 3D 模式:重置所有相机控制标志
if (isOrbitingRef.current) {
isOrbitingRef.current = false;
canvas.style.cursor = 'grab';
@@ -759,6 +831,16 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
isPanningRef.current = false;
canvas.style.cursor = 'grab';
}
if (isZoomingRef.current) {
isZoomingRef.current = false;
canvas.style.cursor = 'grab';
}
if (isFlyModeRef.current) {
isFlyModeRef.current = false;
canvas.style.cursor = 'grab';
// Reset fly keys | 重置飞行按键
flyKeysRef.current = { w: false, a: false, s: false, d: false, q: false, e: false };
}
// 2D mode: original mouse up handling
// 2D 模式:原始鼠标抬起处理
@@ -857,16 +939,146 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
setCamera2DZoom((prev) => Math.max(0.01, Math.min(100, prev * zoomFactor)));
};
// Keyboard event handlers for fly mode and focus
// 飞行模式和聚焦的键盘事件处理
const handleKeyDown = (e: KeyboardEvent) => {
if (renderModeRef.current !== '3D') return;
if (playStateRef.current === 'playing') return;
// WASD + QE for fly mode movement
// WASD + QE 飞行模式移动
const key = e.key.toLowerCase();
if (key === 'w' || key === 'a' || key === 's' || key === 'd' || key === 'q' || key === 'e') {
flyKeysRef.current[key as keyof typeof flyKeysRef.current] = true;
}
// F key: Focus on selected entity
// F 键:聚焦到选中实体
if (key === 'f' && selectedEntityRef.current) {
const entity = selectedEntityRef.current;
const transform = entity.getComponent(TransformComponent);
if (transform) {
// Focus camera on entity position
// 聚焦相机到实体位置
setOrbitCamera((prev) => {
const newOrbit = {
...prev,
targetX: transform.position.x,
targetY: transform.position.y,
targetZ: transform.position.z,
distance: Math.max(5, prev.distance) // Ensure minimum distance
};
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
return newOrbit;
});
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
const key = e.key.toLowerCase();
if (key === 'w' || key === 'a' || key === 's' || key === 'd' || key === 'q' || key === 'e') {
flyKeysRef.current[key as keyof typeof flyKeysRef.current] = false;
}
};
// Fly mode animation loop
// 飞行模式动画循环
let flyAnimationId: number | null = null;
let lastFlyTime = 0;
const updateFlyMode = (timestamp: number) => {
if (!isFlyModeRef.current) {
flyAnimationId = null;
return;
}
const deltaTime = lastFlyTime > 0 ? (timestamp - lastFlyTime) / 1000 : 0.016;
lastFlyTime = timestamp;
const keys = flyKeysRef.current;
const speed = flySpeedRef.current * deltaTime;
// Check if any movement key is pressed
// 检查是否有移动键被按下
if (keys.w || keys.a || keys.s || keys.d || keys.q || keys.e) {
setOrbitCamera((prev) => {
const pitchRad = (prev.pitch * Math.PI) / 180;
const yawRad = (prev.yaw * Math.PI) / 180;
// Calculate camera directions
// 计算相机方向
const forwardX = -Math.cos(pitchRad) * Math.sin(yawRad);
const forwardY = Math.sin(pitchRad);
const forwardZ = -Math.cos(pitchRad) * Math.cos(yawRad);
const rightX = Math.cos(yawRad);
const rightZ = -Math.sin(yawRad);
let moveX = 0, moveY = 0, moveZ = 0;
// WASD movement (reversed A/D for intuitive scene navigation)
// WASD 移动(反转 A/D 以符合直觉的场景导航)
if (keys.w) { moveX += forwardX; moveY += forwardY; moveZ += forwardZ; }
if (keys.s) { moveX -= forwardX; moveY -= forwardY; moveZ -= forwardZ; }
if (keys.a) { moveX += rightX; moveZ += rightZ; } // Reversed: move scene right
if (keys.d) { moveX -= rightX; moveZ -= rightZ; } // Reversed: move scene left
// QE for up/down
if (keys.e) { moveY += 1; }
if (keys.q) { moveY -= 1; }
// Calculate current camera position
const pos = calculateOrbitCameraPosition(prev);
// Move both camera and target
// 同时移动相机和目标点
const newOrbit = {
...prev,
targetX: prev.targetX + moveX * speed,
targetY: prev.targetY + moveY * speed,
targetZ: prev.targetZ + moveZ * speed
};
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
return newOrbit;
});
}
flyAnimationId = requestAnimationFrame(updateFlyMode);
};
// Start fly mode loop when entering fly mode
// 进入飞行模式时启动飞行循环
const startFlyLoop = () => {
if (flyAnimationId === null && isFlyModeRef.current) {
lastFlyTime = 0;
flyAnimationId = requestAnimationFrame(updateFlyMode);
}
};
// Check periodically if fly mode is active
// 定期检查飞行模式是否激活
const flyCheckInterval = setInterval(() => {
if (isFlyModeRef.current && flyAnimationId === null) {
startFlyLoop();
}
}, 100);
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('wheel', handleWheel, { passive: false });
canvas.addEventListener('contextmenu', handleContextMenu);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
if (flyAnimationId !== null) {
cancelAnimationFrame(flyAnimationId);
}
clearInterval(flyCheckInterval);
window.removeEventListener('resize', resizeCanvas);
resizeObserver.disconnect();
canvas.removeEventListener('mousedown', handleMouseDown);
@@ -874,6 +1086,8 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
canvas.removeEventListener('contextmenu', handleContextMenu);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keyup', handleKeyUp);
};
}, []);
@@ -1884,8 +2098,10 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
// Check for supported asset types | 检查支持的资产类型
const isPrefab = lowerPath.endsWith('.prefab');
const isFui = lowerPath.endsWith('.fui');
const is3DModel = lowerPath.endsWith('.gltf') || lowerPath.endsWith('.glb') ||
lowerPath.endsWith('.obj') || lowerPath.endsWith('.fbx');
if (!isPrefab && !isFui) {
if (!isPrefab && !isFui && !is3DModel) {
return;
}
@@ -1976,6 +2192,39 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
entityStore.selectEntity(entity);
console.log(`[Viewport] FGUI entity created at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${finalName}`);
} else if (is3DModel) {
// 处理 3D 模型文件 | Handle 3D model file
const filename = assetPath.split(/[/\\]/).pop() || '3D Mesh';
const entityName = filename.replace(/\.(gltf|glb|obj|fbx)$/i, '');
// 生成唯一名称 | Generate unique name
const existingCount = entityStore.getAllEntities()
.filter((ent: Entity) => ent.name.startsWith(entityName)).length;
const finalName = existingCount > 0 ? `${entityName} ${existingCount + 1}` : entityName;
// 创建实体 | Create entity
const entity = scene.createEntity(finalName);
// 添加 TransformComponent | Add TransformComponent
const transform = new TransformComponent();
transform.position.x = worldPos.x;
transform.position.y = worldPos.y;
entity.addComponent(transform);
// 添加 MeshComponent | Add MeshComponent
const meshComponent = new MeshComponent();
// 优先使用 GUID如果没有则使用路径
// Prefer GUID, fallback to path
meshComponent.modelGuid = assetGuid || assetPath;
entity.addComponent(meshComponent);
// 注册并选中实体 | Register and select entity
entityStore.addEntity(entity);
messageHub.publish('entity:added', { entity });
messageHub.publish('scene:modified', {});
entityStore.selectEntity(entity);
console.log(`[Viewport] 3D Mesh entity created at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${finalName}`);
}
} catch (error) {
console.error('[Viewport] Failed to handle drop:', error);

View File

@@ -9,22 +9,28 @@ interface AxisInputProps {
axis: 'x' | 'y' | 'z';
value: number;
onChange: (value: number) => void;
onChangeCommit?: (value: number) => void; // 拖拽结束时调用 | Called when drag ends
suffix?: string;
}
function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
function AxisInput({ axis, value, onChange, onChangeCommit, suffix }: AxisInputProps) {
const [isDragging, setIsDragging] = useState(false);
const [inputValue, setInputValue] = useState(String(value ?? 0));
const dragStartRef = useRef({ x: 0, value: 0 });
const currentValueRef = useRef(value ?? 0); // 跟踪当前值 | Track current value
useEffect(() => {
setInputValue(String(value ?? 0));
}, [value]);
if (!isDragging) {
setInputValue(String(value ?? 0));
currentValueRef.current = value ?? 0;
}
}, [value, isDragging]);
const handleBarMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
dragStartRef.current = { x: e.clientX, value: value ?? 0 };
currentValueRef.current = value ?? 0;
};
useEffect(() => {
@@ -35,11 +41,14 @@ function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
const sensitivity = e.shiftKey ? 0.01 : e.ctrlKey ? 1 : 0.1;
const newValue = dragStartRef.current.value + delta * sensitivity;
const rounded = Math.round(newValue * 1000) / 1000;
currentValueRef.current = rounded;
setInputValue(String(rounded));
onChange(rounded);
};
const handleMouseUp = () => {
setIsDragging(false);
onChangeCommit?.(currentValueRef.current); // 拖拽结束时通知 | Notify when drag ends
};
document.addEventListener('mousemove', handleMouseMove);
@@ -49,7 +58,7 @@ function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, onChange]);
}, [isDragging, onChange, onChangeCommit]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
@@ -108,6 +117,7 @@ interface TransformRowProps {
isLocked?: boolean;
onLockChange?: (locked: boolean) => void;
onChange: (value: { x: number; y: number; z: number }) => void;
onChangeCommit?: () => void; // 拖拽结束时调用 | Called when drag ends
onReset?: () => void;
suffix?: string;
showDivider?: boolean;
@@ -120,26 +130,54 @@ function TransformRow({
isLocked = false,
onLockChange,
onChange,
onChangeCommit,
onReset,
suffix,
showDivider = true
}: TransformRowProps) {
// 使用 ref 来跟踪当前值,避免在拖拽过程中因重新渲染而丢失
// Use ref to track current value, avoiding loss during drag re-renders
const currentValueRef = useRef({ x: value?.x ?? 0, y: value?.y ?? 0, z: value?.z ?? 0 });
useEffect(() => {
currentValueRef.current = { x: value?.x ?? 0, y: value?.y ?? 0, z: value?.z ?? 0 };
}, [value?.x, value?.y, value?.z]);
const handleAxisChange = (axis: 'x' | 'y' | 'z', newValue: number) => {
// 使用 ref 中的当前值,确保即使在快速拖拽时也能正确读取
// Use current value from ref to ensure correct reading during fast dragging
const currentX = currentValueRef.current.x;
const currentY = currentValueRef.current.y;
const currentZ = currentValueRef.current.z;
let newVector: { x: number; y: number; z: number };
if (isLocked && showLock) {
const oldVal = value[axis];
const oldVal = axis === 'x' ? currentX : axis === 'y' ? currentY : currentZ;
if (oldVal !== 0) {
const ratio = newValue / oldVal;
onChange({
x: axis === 'x' ? newValue : value.x * ratio,
y: axis === 'y' ? newValue : value.y * ratio,
z: axis === 'z' ? newValue : value.z * ratio
});
newVector = {
x: axis === 'x' ? newValue : currentX * ratio,
y: axis === 'y' ? newValue : currentY * ratio,
z: axis === 'z' ? newValue : currentZ * ratio
};
} else {
onChange({ ...value, [axis]: newValue });
newVector = {
x: axis === 'x' ? newValue : currentX,
y: axis === 'y' ? newValue : currentY,
z: axis === 'z' ? newValue : currentZ
};
}
} else {
onChange({ ...value, [axis]: newValue });
newVector = {
x: axis === 'x' ? newValue : currentX,
y: axis === 'y' ? newValue : currentY,
z: axis === 'z' ? newValue : currentZ
};
}
currentValueRef.current = newVector;
onChange(newVector);
};
return (
@@ -154,18 +192,21 @@ function TransformRow({
axis="x"
value={value?.x ?? 0}
onChange={(v) => handleAxisChange('x', v)}
onChangeCommit={onChangeCommit}
suffix={suffix}
/>
<AxisInput
axis="y"
value={value?.y ?? 0}
onChange={(v) => handleAxisChange('y', v)}
onChangeCommit={onChangeCommit}
suffix={suffix}
/>
<AxisInput
axis="z"
value={value?.z ?? 0}
onChange={(v) => handleAxisChange('z', v)}
onChangeCommit={onChangeCommit}
suffix={suffix}
/>
</div>
@@ -230,21 +271,54 @@ function TransformInspectorContent({ context }: { context: ComponentInspectorCon
const [mobility, setMobility] = useState<'static' | 'stationary' | 'movable'>('static');
const [, forceUpdate] = useState({});
// 拖拽过程中只更新 transform 值,不触发 UI 刷新
// During dragging, only update transform value, don't trigger UI refresh
const handlePositionChange = (value: { x: number; y: number; z: number }) => {
transform.position = value;
context.onChange?.('position', value);
forceUpdate({});
};
const handleRotationChange = (value: { x: number; y: number; z: number }) => {
transform.rotation = value;
context.onChange?.('rotation', value);
forceUpdate({});
};
const handleScaleChange = (value: { x: number; y: number; z: number }) => {
transform.scale = value;
context.onChange?.('scale', value);
};
// 拖拽结束时通知外部并刷新 UI
// Notify external and refresh UI when drag ends
const handlePositionCommit = () => {
context.onChange?.('position', transform.position);
forceUpdate({});
};
const handleRotationCommit = () => {
context.onChange?.('rotation', transform.rotation);
forceUpdate({});
};
const handleScaleCommit = () => {
context.onChange?.('scale', transform.scale);
forceUpdate({});
};
// Reset 操作立即生效
// Reset operations take effect immediately
const handlePositionReset = () => {
transform.position = { x: 0, y: 0, z: 0 };
context.onChange?.('position', transform.position);
forceUpdate({});
};
const handleRotationReset = () => {
transform.rotation = { x: 0, y: 0, z: 0 };
context.onChange?.('rotation', transform.rotation);
forceUpdate({});
};
const handleScaleReset = () => {
transform.scale = { x: 1, y: 1, z: 1 };
context.onChange?.('scale', transform.scale);
forceUpdate({});
};
@@ -254,13 +328,15 @@ function TransformInspectorContent({ context }: { context: ComponentInspectorCon
label="Location"
value={transform.position}
onChange={handlePositionChange}
onReset={() => handlePositionChange({ x: 0, y: 0, z: 0 })}
onChangeCommit={handlePositionCommit}
onReset={handlePositionReset}
/>
<TransformRow
label="Rotation"
value={transform.rotation}
onChange={handleRotationChange}
onReset={() => handleRotationChange({ x: 0, y: 0, z: 0 })}
onChangeCommit={handleRotationCommit}
onReset={handleRotationReset}
suffix="°"
/>
<TransformRow
@@ -270,7 +346,8 @@ function TransformInspectorContent({ context }: { context: ComponentInspectorCon
isLocked={isScaleLocked}
onLockChange={setIsScaleLocked}
onChange={handleScaleChange}
onReset={() => handleScaleChange({ x: 1, y: 1, z: 1 })}
onChangeCommit={handleScaleCommit}
onReset={handleScaleReset}
showDivider={false}
/>
<div className="tf-divider" />

View File

@@ -15,7 +15,8 @@ import {
TextureServiceToken,
DynamicAtlasServiceToken,
CoordinateServiceToken,
RenderConfigServiceToken
RenderConfigServiceToken,
EngineBridgeToken
} from '@esengine/ecs-engine-bindgen';
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
@@ -259,6 +260,9 @@ export class EngineService {
// 创建服务注册表并注册核心服务
// Create service registry and register core services
const services = new PluginServiceRegistry();
// 注册 EngineBridge供 MeshRenderSystem 等系统使用)
// Register EngineBridge (for systems like MeshRenderSystem)
services.register(EngineBridgeToken, this._runtime.bridge);
// 使用单一职责接口注册 EngineBridge | Register EngineBridge with single-responsibility interfaces
services.register(TextureServiceToken, this._runtime.bridge);
services.register(DynamicAtlasServiceToken, this._runtime.bridge);

View File

@@ -736,6 +736,10 @@
color: #ec407a;
}
.asset-thumbnail-icon.model3d {
color: #26a69a;
}
/* ==================== Status Bar ==================== */
.cb-status-bar {
display: flex;
@@ -878,3 +882,126 @@
.cb-asset-grid::-webkit-scrollbar-thumb:hover {
background: #4e4e4e;
}
/* ==================== 3D Model Sub-Asset Expansion ==================== */
/* Expand button for expandable models */
.cb-asset-expand-btn {
position: absolute;
left: 4px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
background: transparent;
border: none;
border-radius: 3px;
color: #888;
cursor: pointer;
z-index: 1;
}
.cb-asset-expand-btn:hover {
background: #3c3c3c;
color: #fff;
}
.cb-asset-expand-btn.expanded {
color: #3b82f6;
}
/* Spinning animation for loading */
.cb-asset-expand-btn .spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Grid view adjustments for expandable items */
.cb-asset-grid.grid .cb-asset-item {
position: relative;
}
.cb-asset-grid.grid .cb-asset-item .cb-asset-expand-btn {
left: 2px;
top: 2px;
transform: none;
}
/* Sub-asset items in grid view */
.cb-asset-grid.grid .cb-asset-item.sub-asset {
background: #252530;
border-left: 2px solid #3b82f6;
padding-left: 16px;
}
.cb-asset-grid.grid .cb-asset-item.sub-asset:hover {
background: #2d2d38;
}
.cb-asset-grid.grid .cb-asset-item.sub-asset.selected {
background: #0a4780;
}
.cb-asset-grid.grid .cb-asset-item.sub-asset .cb-asset-thumbnail {
width: 48px;
height: 48px;
background: #202028;
}
/* List view adjustments */
.cb-asset-grid.list .cb-asset-item {
position: relative;
}
.cb-asset-grid.list .cb-asset-item .cb-asset-expand-btn {
position: relative;
left: auto;
top: auto;
transform: none;
flex-shrink: 0;
margin-right: 4px;
}
/* Sub-asset items in list view */
.cb-asset-grid.list .cb-asset-item.sub-asset {
padding-left: 32px;
background: #252530;
border-left: 2px solid #3b82f6;
}
.cb-asset-grid.list .cb-asset-item.sub-asset:hover {
background: #2d2d38;
}
.cb-asset-grid.list .cb-asset-item.sub-asset.selected {
background: #0a4780;
}
/* Sub-asset icon colors */
.asset-thumbnail-icon.sub-asset.mesh {
color: #f59e0b;
}
.asset-thumbnail-icon.sub-asset.material {
color: #8b5cf6;
}
.asset-thumbnail-icon.sub-asset.animation {
color: #10b981;
}
.asset-thumbnail-icon.sub-asset.skeleton {
color: #ef4444;
}
/* Expanded model highlight */
.cb-asset-item.expanded {
border-bottom: 1px solid #3b82f6;
}