refactor: reorganize package structure and decouple framework packages (#338)

* refactor: reorganize package structure and decouple framework packages

## Package Structure Reorganization
- Reorganized 55 packages into categorized subdirectories:
  - packages/framework/ - Generic framework (Laya/Cocos compatible)
  - packages/engine/ - ESEngine core modules
  - packages/rendering/ - Rendering modules (WASM dependent)
  - packages/physics/ - Physics modules
  - packages/streaming/ - World streaming
  - packages/network-ext/ - Network extensions
  - packages/editor/ - Editor framework and plugins
  - packages/rust/ - Rust WASM engine
  - packages/tools/ - Build tools and SDK

## Framework Package Decoupling
- Decoupled behavior-tree and blueprint packages from ESEngine dependencies
- Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent)
- ESEngine-specific code moved to esengine/ subpath exports
- Framework packages now usable with Cocos/Laya without ESEngine

## CI Configuration
- Updated CI to only type-check and lint framework packages
- Added type-check:framework and lint:framework scripts

## Breaking Changes
- Package import paths changed due to directory reorganization
- ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine')

* fix: update es-engine file path after directory reorganization

* docs: update README to focus on framework over engine

* ci: only build framework packages, remove Rust/WASM dependencies

* fix: remove esengine subpath from behavior-tree and blueprint builds

ESEngine integration code will only be available in full engine builds.
Framework packages are now purely engine-agnostic.

* fix: move network-protocols to framework, build both in CI

* fix: update workflow paths from packages/core to packages/framework/core

* fix: exclude esengine folder from type-check in behavior-tree and blueprint

* fix: update network tsconfig references to new paths

* fix: add test:ci:framework to only test framework packages in CI

* fix: only build core and math npm packages in CI

* fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
YHH
2025-12-26 14:50:35 +08:00
committed by GitHub
parent a84ff902e4
commit 155411e743
1936 changed files with 4147 additions and 11578 deletions

View File

@@ -0,0 +1,15 @@
{
"id": "mesh-3d-editor",
"name": "@esengine/mesh-3d-editor",
"displayName": "3D Mesh Editor",
"description": "Editor support for 3D mesh system | 3D 网格编辑器支持",
"version": "1.0.0",
"category": "Editor",
"icon": "Box",
"isEditorPlugin": true,
"runtimeModule": "@esengine/mesh-3d",
"exports": {
"inspectors": ["MeshComponentInspector", "Animation3DInspector"],
"importers": ["GLTFImporter", "FBXImporter", "OBJImporter"]
}
}

View File

@@ -0,0 +1,54 @@
{
"name": "@esengine/mesh-3d-editor",
"version": "1.0.0",
"description": "Editor components for 3D mesh system",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"build:watch": "tsup --watch",
"type-check": "tsc --noEmit",
"clean": "rimraf dist"
},
"dependencies": {
"@esengine/mesh-3d": "workspace:*"
},
"peerDependencies": {
"@esengine/editor-core": "workspace:*"
},
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/editor-core": "workspace:*",
"@esengine/asset-system": "workspace:*",
"@esengine/build-config": "workspace:*",
"@tauri-apps/api": "^2.5.0",
"react": "^18.3.1",
"@types/react": "^18.2.0",
"lucide-react": "^0.453.0",
"rimraf": "^5.0.5",
"tsup": "^8.0.0",
"typescript": "^5.3.3"
},
"keywords": [
"ecs",
"mesh",
"3d",
"editor",
"gltf"
],
"author": "yhh",
"license": "MIT",
"private": true
}

View File

@@ -0,0 +1,124 @@
/**
* Mesh Component Inspector Styles.
* 网格组件检查器样式。
*/
.mesh-component-inspector {
margin-top: 8px;
}
/* Mesh Info Section */
.mesh-info-section {
background: var(--panel-bg, #1e1e1e);
border: 1px solid var(--border-color, #3c3c3c);
border-radius: 4px;
overflow: hidden;
}
.mesh-info-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
background: var(--header-bg, #252526);
cursor: pointer;
user-select: none;
}
.mesh-info-header:hover {
background: var(--header-hover-bg, #2a2a2a);
}
.mesh-info-expand {
display: flex;
align-items: center;
color: var(--text-secondary, #888);
}
.mesh-info-title {
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #ccc);
}
.mesh-info-content {
padding: 8px 10px;
}
.mesh-info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-size: 11px;
}
.mesh-info-row label {
color: var(--text-secondary, #888);
}
.mesh-info-value {
color: var(--text-primary, #ccc);
font-family: monospace;
}
.mesh-info-vec3 {
font-size: 10px;
}
.mesh-info-divider {
height: 1px;
background: var(--border-color, #3c3c3c);
margin: 8px 0;
}
.mesh-info-subtitle {
font-size: 11px;
font-weight: 500;
color: var(--text-primary, #ccc);
margin-bottom: 6px;
}
/* Materials list */
.mesh-info-materials {
display: flex;
flex-direction: column;
gap: 4px;
}
.mesh-info-material {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
background: var(--item-bg, #2d2d2d);
border-radius: 3px;
font-size: 11px;
}
.mesh-info-material-index {
min-width: 20px;
padding: 2px 4px;
background: var(--badge-bg, #3c3c3c);
border-radius: 2px;
text-align: center;
color: var(--text-secondary, #888);
}
.mesh-info-material-name {
color: var(--text-primary, #ccc);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Empty state */
.mesh-info-empty {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
color: var(--text-secondary, #888);
font-size: 11px;
font-style: italic;
}

View File

@@ -0,0 +1,202 @@
/**
* Mesh Component Inspector.
* 网格组件检查器。
*
* Provides custom inspector UI for MeshComponent.
* 为 MeshComponent 提供自定义检查器 UI。
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework';
import { IComponentInspector, ComponentInspectorContext, MessageHub } from '@esengine/editor-core';
import { MeshComponent } from '@esengine/mesh-3d';
import { ChevronDown, ChevronRight, Box, Info } from 'lucide-react';
import './MeshComponentInspector.css';
/**
* Mesh info display props.
* 网格信息显示属性。
*/
interface MeshInfoProps {
mesh: MeshComponent;
}
/**
* Mesh info component.
* 网格信息组件。
*
* Displays detailed mesh information when a model is loaded.
* 当模型加载后显示详细的网格信息。
*/
function MeshInfo({ mesh }: MeshInfoProps) {
const [isExpanded, setIsExpanded] = useState(true);
if (!mesh.meshAsset) {
return (
<div className="mesh-info-empty">
<Info size={14} />
<span>No model loaded</span>
</div>
);
}
const asset = mesh.meshAsset;
const currentMesh = mesh.currentMesh;
const totalMeshes = asset.meshes?.length ?? 0;
return (
<div className="mesh-info-section">
<div
className="mesh-info-header"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="mesh-info-expand">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<Box size={14} />
<span className="mesh-info-title">Mesh Info</span>
</div>
{isExpanded && (
<div className="mesh-info-content">
{/* Model name */}
<div className="mesh-info-row">
<label>Name</label>
<span className="mesh-info-value">{asset.name || 'Unnamed'}</span>
</div>
{/* Total meshes */}
<div className="mesh-info-row">
<label>Meshes</label>
<span className="mesh-info-value">{totalMeshes}</span>
</div>
{/* Current mesh details */}
{currentMesh && (
<>
<div className="mesh-info-divider" />
<div className="mesh-info-subtitle">Current Mesh ({mesh.meshIndex})</div>
<div className="mesh-info-row">
<label>Mesh Name</label>
<span className="mesh-info-value">{currentMesh.name || `Mesh ${mesh.meshIndex}`}</span>
</div>
{currentMesh.vertices && (
<div className="mesh-info-row">
<label>Vertices</label>
<span className="mesh-info-value">{Math.floor(currentMesh.vertices.length / 3).toLocaleString()}</span>
</div>
)}
{currentMesh.indices && (
<div className="mesh-info-row">
<label>Triangles</label>
<span className="mesh-info-value">{Math.floor(currentMesh.indices.length / 3).toLocaleString()}</span>
</div>
)}
</>
)}
{/* Materials */}
{asset.materials && asset.materials.length > 0 && (
<>
<div className="mesh-info-divider" />
<div className="mesh-info-subtitle">Materials ({asset.materials.length})</div>
<div className="mesh-info-materials">
{asset.materials.map((mat, i) => (
<div key={i} className="mesh-info-material">
<span className="mesh-info-material-index">{i}</span>
<span className="mesh-info-material-name">{mat.name || `Material ${i}`}</span>
</div>
))}
</div>
</>
)}
{/* Bounds */}
{asset.bounds && (
<>
<div className="mesh-info-divider" />
<div className="mesh-info-subtitle">Bounds</div>
<div className="mesh-info-row">
<label>Min</label>
<span className="mesh-info-value mesh-info-vec3">
({asset.bounds.min[0].toFixed(2)}, {asset.bounds.min[1].toFixed(2)}, {asset.bounds.min[2].toFixed(2)})
</span>
</div>
<div className="mesh-info-row">
<label>Max</label>
<span className="mesh-info-value mesh-info-vec3">
({asset.bounds.max[0].toFixed(2)}, {asset.bounds.max[1].toFixed(2)}, {asset.bounds.max[2].toFixed(2)})
</span>
</div>
</>
)}
</div>
)}
</div>
);
}
/**
* Mesh inspector content component.
* 网格检查器内容组件。
*/
function MeshInspectorContent({ context }: { context: ComponentInspectorContext }) {
const mesh = context.component as MeshComponent;
const [, forceUpdate] = useState({});
// Force update when mesh index changes
// 当网格索引变化时强制更新
useEffect(() => {
forceUpdate({});
}, [mesh.meshIndex, mesh.modelGuid]);
const handleChange = useCallback((propertyName: string, value: unknown) => {
(mesh as unknown as Record<string, unknown>)[propertyName] = value;
context.onChange?.(propertyName, value);
forceUpdate({});
// Publish scene:modified
// 发布 scene:modified
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('scene:modified', {});
}
}, [mesh, context]);
return (
<div className="mesh-component-inspector">
{/* Mesh info display */}
<MeshInfo mesh={mesh} />
</div>
);
}
/**
* Mesh component inspector implementation.
* 网格组件检查器实现。
*
* Uses 'append' mode to show mesh info after the default PropertyInspector.
* 使用 'append' 模式在默认 PropertyInspector 后显示网格信息。
*/
export class MeshComponentInspector implements IComponentInspector<MeshComponent> {
readonly id = 'mesh-component-inspector';
readonly name = 'Mesh Component Inspector';
readonly priority = 100;
readonly targetComponents = ['Mesh', 'MeshComponent'];
readonly renderMode = 'append' as const;
canHandle(component: Component): component is MeshComponent {
const typeName = getComponentInstanceTypeName(component);
return typeName === 'Mesh' || typeName === 'MeshComponent';
}
render(context: ComponentInspectorContext): React.ReactElement {
return React.createElement(MeshInspectorContent, {
context,
key: `mesh-${context.version}`
});
}
}

View File

@@ -0,0 +1,498 @@
/**
* Animation Preview Panel
* 动画预览面板
*
* Displays 3D model preview with animation playback controls.
* 显示 3D 模型预览和动画播放控制。
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import type { IAssetContent, IAssetParseContext, IGLTFAsset, IGLTFAnimationClip } from '@esengine/asset-system';
import { FBXLoader, GLTFLoader } from '@esengine/asset-system';
import {
Play, Pause, Square, SkipBack, SkipForward,
RefreshCw, Clock, Layers, Activity, ChevronDown, RotateCcw
} from 'lucide-react';
import { ModelPreview3D } from './ModelPreview3D';
import '../styles/AnimationPreviewPanel.css';
/**
* 格式化时间为 MM:SS.ms 格式
* Format time to MM:SS.ms format
*/
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 100);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`;
}
/**
* 读取二进制文件Tauri 环境)
* Read binary file (Tauri environment)
*/
async function readFileBinary(path: string): Promise<ArrayBuffer | null> {
try {
const { invoke } = await import('@tauri-apps/api/core');
const base64: string = await invoke<string>('read_file_as_base64', { filePath: path });
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;
} catch (error) {
console.error('[AnimationPreview] Failed to read file:', error);
return null;
}
}
interface AnimationPreviewState {
asset: IGLTFAsset | null;
assetPath: string | null;
selectedAnimationIndex: number;
isPlaying: boolean;
currentTime: number;
speed: number;
loop: boolean;
isLoading: boolean;
}
const initialState: AnimationPreviewState = {
asset: null,
assetPath: null,
selectedAnimationIndex: 0,
isPlaying: false,
currentTime: 0,
speed: 1.0,
loop: true,
isLoading: false,
};
export function AnimationPreviewPanel() {
const [state, setState] = useState<AnimationPreviewState>(initialState);
const animationFrameRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const {
asset,
assetPath,
selectedAnimationIndex,
isPlaying,
currentTime,
speed,
loop,
isLoading,
} = state;
const currentClip = asset?.animations?.[selectedAnimationIndex] ?? null;
// Animation loop | 动画循环
useEffect(() => {
if (!isPlaying || !asset) return;
const clip = asset.animations?.[selectedAnimationIndex];
if (!clip || clip.duration <= 0) return;
const animate = (time: number) => {
if (lastTimeRef.current === 0) {
lastTimeRef.current = time;
}
const deltaTime = (time - lastTimeRef.current) / 1000;
lastTimeRef.current = time;
setState(prev => {
if (!prev.isPlaying) return prev;
const clip = prev.asset?.animations?.[prev.selectedAnimationIndex];
if (!clip || clip.duration <= 0) return prev;
let newTime = prev.currentTime + deltaTime * prev.speed;
if (newTime >= clip.duration) {
if (prev.loop) {
newTime = newTime % clip.duration;
} else {
return { ...prev, currentTime: clip.duration, isPlaying: false };
}
}
return { ...prev, currentTime: newTime };
});
animationFrameRef.current = requestAnimationFrame(animate);
};
lastTimeRef.current = 0;
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [isPlaying, asset, selectedAnimationIndex, speed, loop]);
// Load asset | 加载资产
const loadAsset = useCallback(async (filePath: string) => {
setState(prev => ({ ...prev, isLoading: true }));
try {
const fileName = filePath.split(/[\\/]/).pop() || 'Model';
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const binaryData = await readFileBinary(filePath);
if (!binaryData || binaryData.byteLength === 0) {
console.warn('[AnimationPreview] Cannot read file:', filePath);
setState(prev => ({ ...prev, isLoading: false }));
return;
}
const parseContext = {
metadata: {
path: filePath,
name: fileName,
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;
const content: IAssetContent = {
type: 'binary',
binary: binaryData
};
let parsedAsset: IGLTFAsset;
if (ext === 'fbx') {
const loader = new FBXLoader();
parsedAsset = await loader.parse(content, parseContext);
} else if (ext === 'gltf' || ext === 'glb') {
const loader = new GLTFLoader();
parsedAsset = await loader.parse(content, parseContext);
} else {
console.warn('[AnimationPreview] Unsupported format:', ext);
setState(prev => ({ ...prev, isLoading: false }));
return;
}
console.log(`[AnimationPreview] Loaded: ${parsedAsset.meshes?.length ?? 0} meshes, ${parsedAsset.animations?.length ?? 0} animations`);
setState({
asset: parsedAsset,
assetPath: filePath,
selectedAnimationIndex: 0,
currentTime: 0,
isPlaying: false,
speed: 1.0,
loop: true,
isLoading: false,
});
} catch (error) {
console.error('[AnimationPreview] Failed to load asset:', error);
setState(prev => ({ ...prev, isLoading: false }));
}
}, []);
// Listen for animation preview requests | 监听动画预览请求
useEffect(() => {
const messageHub = Core.services.tryResolve(MessageHub);
if (!messageHub) return;
const unsubscribe = messageHub.subscribe('animation:preview', (data: { filePath: string; animationIndex?: number }) => {
loadAsset(data.filePath);
if (data.animationIndex !== undefined) {
setState(prev => ({
...prev,
selectedAnimationIndex: data.animationIndex!,
currentTime: 0,
isPlaying: false,
}));
}
});
return () => unsubscribe?.();
}, [loadAsset]);
// Action handlers | 操作处理器
const selectAnimation = useCallback((index: number) => {
setState(prev => ({
...prev,
selectedAnimationIndex: index,
currentTime: 0,
isPlaying: false,
}));
}, []);
const play = useCallback(() => {
setState(prev => ({ ...prev, isPlaying: true }));
}, []);
const pause = useCallback(() => {
setState(prev => ({ ...prev, isPlaying: false }));
}, []);
const stop = useCallback(() => {
setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 }));
}, []);
const setTime = useCallback((time: number) => {
setState(prev => {
const clip = prev.asset?.animations?.[prev.selectedAnimationIndex];
if (clip) {
return { ...prev, currentTime: Math.max(0, Math.min(time, clip.duration)) };
}
return prev;
});
}, []);
const setSpeed = useCallback((newSpeed: number) => {
setState(prev => ({ ...prev, speed: Math.max(0.1, Math.min(newSpeed, 5)) }));
}, []);
const setLoop = useCallback((newLoop: boolean) => {
setState(prev => ({ ...prev, loop: newLoop }));
}, []);
const handleTimelineChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value);
setTime(value);
}, [setTime]);
const handleSpeedChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
setSpeed(parseFloat(e.target.value));
}, [setSpeed]);
// Render loading state | 渲染加载状态
if (isLoading) {
return (
<div className="animation-preview-panel loading">
<RefreshCw className="spin" size={24} />
<span>Loading...</span>
</div>
);
}
// Render empty state | 渲染空状态
if (!asset) {
return (
<div className="animation-preview-panel empty">
<Activity size={48} strokeWidth={1} />
<p>No model loaded</p>
<p className="hint">Double-click a model or animation in Content Browser</p>
</div>
);
}
const animations = asset.animations ?? [];
const hasAnimations = animations.length > 0;
const hasMeshes = (asset.meshes?.length ?? 0) > 0;
const duration = currentClip?.duration ?? 0;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className="animation-preview-panel">
{/* Header | 头部 */}
<div className="panel-header">
<span className="asset-name" title={assetPath ?? ''}>
{assetPath?.split(/[\\/]/).pop() ?? 'Unknown'}
</span>
<button
className="icon-button"
onClick={() => setState(initialState)}
title="Clear"
>
<RotateCcw size={14} />
</button>
</div>
{/* 3D Preview | 3D 预览 */}
{hasMeshes && (
<div className="preview-viewport">
<ModelPreview3D
asset={asset}
animationClip={currentClip}
currentTime={currentTime}
width={280}
height={180}
/>
</div>
)}
{/* No mesh message | 无网格消息 */}
{!hasMeshes && (
<div className="no-mesh-message">
<p>No mesh data in this file</p>
</div>
)}
{/* Animation selector | 动画选择器 */}
{hasAnimations && (
<div className="animation-selector">
<label>Animation:</label>
<div className="select-wrapper">
<select
value={selectedAnimationIndex}
onChange={(e) => selectAnimation(parseInt(e.target.value))}
>
{animations.map((anim: IGLTFAnimationClip, index: number) => (
<option key={index} value={index}>
{anim.name || `Animation ${index}`}
</option>
))}
</select>
<ChevronDown size={14} />
</div>
</div>
)}
{/* Animation info | 动画信息 */}
{currentClip && (
<div className="animation-info">
<div className="info-row">
<Clock size={14} />
<span>Duration: {formatTime(currentClip.duration)}</span>
</div>
<div className="info-row">
<Layers size={14} />
<span>Channels: {currentClip.channels?.length ?? 0}</span>
</div>
</div>
)}
{/* Timeline | 时间轴 */}
{hasAnimations && (
<div className="timeline-section">
<div className="time-display">
<span className="current-time">{formatTime(currentTime)}</span>
<span className="separator">/</span>
<span className="total-time">{formatTime(duration)}</span>
</div>
<div className="timeline-track">
<input
type="range"
min={0}
max={duration}
step={0.01}
value={currentTime}
onChange={handleTimelineChange}
className="timeline-slider"
/>
<div
className="timeline-progress"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{/* Playback controls | 播放控制 */}
{hasAnimations && (
<div className="playback-controls">
<button
className="control-button"
onClick={() => setTime(0)}
title="Go to start"
>
<SkipBack size={16} />
</button>
{isPlaying ? (
<button
className="control-button primary"
onClick={pause}
title="Pause"
>
<Pause size={20} />
</button>
) : (
<button
className="control-button primary"
onClick={play}
title="Play"
>
<Play size={20} />
</button>
)}
<button
className="control-button"
onClick={stop}
title="Stop"
>
<Square size={16} />
</button>
<button
className="control-button"
onClick={() => setTime(duration)}
title="Go to end"
>
<SkipForward size={16} />
</button>
</div>
)}
{/* Options | 选项 */}
{hasAnimations && (
<div className="playback-options">
<div className="option-row">
<label>Speed:</label>
<select value={speed} onChange={handleSpeedChange}>
<option value={0.25}>0.25x</option>
<option value={0.5}>0.5x</option>
<option value={1}>1x</option>
<option value={1.5}>1.5x</option>
<option value={2}>2x</option>
</select>
</div>
<div className="option-row">
<label>
<input
type="checkbox"
checked={loop}
onChange={(e) => setLoop(e.target.checked)}
/>
Loop
</label>
</div>
</div>
)}
{/* No animations message | 无动画消息 */}
{hasMeshes && !hasAnimations && (
<div className="no-animations">
<p>This model has no animations</p>
</div>
)}
{/* Model info | 模型信息 */}
<div className="model-info">
<div className="section-title">Model Info</div>
<div className="info-row">
<span>Meshes: {asset.meshes?.length ?? 0}</span>
</div>
<div className="info-row">
<span>Materials: {asset.materials?.length ?? 0}</span>
</div>
{asset.skeleton && (
<div className="info-row">
<span>Joints: {asset.skeleton.joints?.length ?? 0}</span>
</div>
)}
</div>
</div>
);
}
export default AnimationPreviewPanel;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,221 @@
/**
* @esengine/mesh-3d-editor
*
* Editor support for @esengine/mesh-3d - inspectors and entity templates
* 3D 网格编辑器支持 - 检视器和实体模板
*/
import React from 'react';
import type { Entity, ServiceContainer } from '@esengine/ecs-framework';
import { Core } from '@esengine/ecs-framework';
import type {
IEditorModuleLoader,
EntityCreationTemplate,
IEditorPlugin,
ModuleManifest,
PanelDescriptor
} from '@esengine/editor-core';
import {
EntityStoreService,
MessageHub,
EditorComponentRegistry,
ComponentInspectorRegistry,
PanelPosition
} from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core';
// Runtime imports from @esengine/mesh-3d
import {
MeshComponent,
Animation3DComponent,
SkeletonComponent,
Mesh3DRuntimeModule
} from '@esengine/mesh-3d';
// Inspector
import { MeshComponentInspector } from './MeshComponentInspector';
// Panel
import { AnimationPreviewPanel } from './components/AnimationPreviewPanel';
// Export inspector and panel
export { MeshComponentInspector } from './MeshComponentInspector';
export { AnimationPreviewPanel } from './components/AnimationPreviewPanel';
/**
* 3D 网格编辑器模块
* Mesh 3D Editor Module
*/
export class Mesh3DEditorModule implements IEditorModuleLoader {
async install(services: ServiceContainer): Promise<void> {
// 注册组件检查器 | Register component inspectors
const componentInspectorRegistry = services.tryResolve(ComponentInspectorRegistry);
if (componentInspectorRegistry) {
componentInspectorRegistry.register(new MeshComponentInspector());
}
// 注册 Mesh 组件到编辑器组件注册表 | Register Mesh components to editor component registry
const componentRegistry = services.resolve(EditorComponentRegistry);
if (componentRegistry) {
const meshComponents = [
{
name: 'Mesh',
type: MeshComponent,
category: 'components.category.rendering',
description: '3D mesh rendering component',
icon: 'Box'
}
];
for (const comp of meshComponents) {
componentRegistry.register({
name: comp.name,
type: comp.type,
category: comp.category,
description: comp.description,
icon: comp.icon
});
}
// Register animation components
// 注册动画组件
componentRegistry.register({
name: 'Animation3D',
type: Animation3DComponent,
category: 'components.category.animation',
description: '3D animation playback component',
icon: 'Play'
});
componentRegistry.register({
name: 'Skeleton',
type: SkeletonComponent,
category: 'components.category.animation',
description: 'Skeleton component for skinned meshes',
icon: 'GitBranch'
});
}
}
async uninstall(): Promise<void> {
// Nothing to cleanup
}
/**
* 获取面板描述符
* Get panel descriptors
*/
getPanels(): PanelDescriptor[] {
return [
{
id: 'animation-preview',
title: 'Animation Preview',
titleKey: 'panel.animationPreview',
icon: 'Play',
position: PanelPosition.Right,
component: AnimationPreviewPanel,
defaultSize: 300,
resizable: true,
closable: true,
order: 150
}
];
}
getEntityCreationTemplates(): EntityCreationTemplate[] {
return [
// 3D Mesh Entity
{
id: 'create-mesh-3d',
label: '3D Mesh',
icon: 'Box',
category: 'rendering',
order: 200,
create: (): number => {
return this.createMeshEntity('Mesh3D');
}
}
];
}
/**
* 创建 Mesh 实体的辅助方法
* Helper method to create Mesh entity
*/
private createMeshEntity(baseName: string, configure?: (entity: Entity) => void): number {
const scene = Core.scene;
if (!scene) {
throw new Error('Scene not available');
}
const entityStore = Core.services.resolve(EntityStoreService);
const messageHub = Core.services.resolve(MessageHub);
if (!entityStore || !messageHub) {
throw new Error('EntityStoreService or MessageHub not available');
}
const existingCount = entityStore.getAllEntities()
.filter((e: Entity) => e.name.startsWith(baseName)).length;
const entityName = existingCount > 0 ? `${baseName} ${existingCount + 1}` : baseName;
const entity = scene.createEntity(entityName);
// Add Transform component
const transform = new TransformComponent();
entity.addComponent(transform);
// Add Mesh component
const mesh = new MeshComponent();
entity.addComponent(mesh);
if (configure) {
configure(entity);
}
entityStore.addEntity(entity);
messageHub.publish('entity:added', { entity });
messageHub.publish('scene:modified', {});
entityStore.selectEntity(entity);
return entity.id;
}
}
export const mesh3DEditorModule = new Mesh3DEditorModule();
/**
* Mesh3D 插件清单
* Mesh3D Plugin Manifest
*/
const manifest: ModuleManifest = {
id: '@esengine/mesh-3d',
name: '@esengine/mesh-3d',
displayName: 'Mesh 3D',
version: '1.0.0',
description: '3D mesh rendering with GLTF/GLB/OBJ/FBX support',
category: 'Rendering',
icon: 'Box',
isCore: false,
defaultEnabled: true,
isEngineModule: true,
canContainContent: true,
dependencies: ['core', 'math', 'asset-system'],
exports: {
components: ['MeshComponent', 'Animation3DComponent', 'SkeletonComponent'],
systems: ['MeshRenderSystem', 'Animation3DSystem', 'SkeletonBakingSystem']
},
requiresWasm: true
};
/**
* 完整的 Mesh3D 插件(运行时 + 编辑器)
* Complete Mesh3D Plugin (runtime + editor)
*/
export const Mesh3DPlugin: IEditorPlugin = {
manifest,
runtimeModule: new Mesh3DRuntimeModule(),
editorModule: mesh3DEditorModule
};
export default mesh3DEditorModule;

View File

@@ -0,0 +1,377 @@
/**
* Animation Preview Panel Styles
* 动画预览面板样式
*/
.animation-preview-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--panel-background, #1e1e1e);
color: var(--text-color, #cccccc);
font-size: 12px;
overflow-y: auto;
}
.animation-preview-panel.loading,
.animation-preview-panel.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-muted, #888888);
}
.animation-preview-panel.loading .spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animation-preview-panel.empty p {
margin: 0;
text-align: center;
}
.animation-preview-panel.empty .hint {
font-size: 11px;
color: var(--text-muted, #666666);
}
/* Header | 头部 */
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--header-background, #252526);
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.asset-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.icon-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: none;
border-radius: 3px;
color: var(--text-muted, #888888);
cursor: pointer;
}
.icon-button:hover {
background: var(--button-hover, #3c3c3c);
color: var(--text-color, #cccccc);
}
/* 3D Preview Viewport | 3D 预览视口 */
.preview-viewport {
padding: 8px;
background: #1a1a1e;
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.preview-viewport canvas {
display: block;
border-radius: 4px;
}
.model-preview-3d {
position: relative;
}
/* No mesh message | 无网格消息 */
.no-mesh-message {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--section-background, #2d2d2d);
border-bottom: 1px solid var(--border-color, #3c3c3c);
color: var(--text-muted, #666666);
}
/* Animation selector | 动画选择器 */
.animation-selector {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.animation-selector label {
flex-shrink: 0;
color: var(--text-muted, #888888);
}
.select-wrapper {
flex: 1;
position: relative;
}
.select-wrapper select {
width: 100%;
padding: 4px 24px 4px 8px;
background: var(--input-background, #3c3c3c);
border: 1px solid var(--border-color, #5c5c5c);
border-radius: 3px;
color: var(--text-color, #cccccc);
font-size: 12px;
appearance: none;
cursor: pointer;
}
.select-wrapper select:hover {
border-color: var(--border-hover, #007acc);
}
.select-wrapper svg {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--text-muted, #888888);
}
/* Animation info | 动画信息 */
.animation-info {
padding: 8px 12px;
background: var(--section-background, #2d2d2d);
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.info-row {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
color: var(--text-muted, #888888);
}
.info-row svg {
flex-shrink: 0;
}
/* Timeline | 时间轴 */
.timeline-section {
padding: 12px;
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.time-display {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-bottom: 8px;
font-family: monospace;
font-size: 14px;
}
.current-time {
color: var(--accent-color, #007acc);
}
.separator {
color: var(--text-muted, #666666);
}
.total-time {
color: var(--text-muted, #888888);
}
.timeline-track {
position: relative;
height: 20px;
background: var(--track-background, #3c3c3c);
border-radius: 3px;
overflow: hidden;
}
.timeline-slider {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background: transparent;
cursor: pointer;
z-index: 2;
-webkit-appearance: none;
appearance: none;
}
.timeline-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 20px;
background: var(--accent-color, #007acc);
border-radius: 2px;
cursor: grab;
}
.timeline-slider::-webkit-slider-thumb:active {
cursor: grabbing;
}
.timeline-slider::-moz-range-thumb {
width: 12px;
height: 20px;
background: var(--accent-color, #007acc);
border: none;
border-radius: 2px;
cursor: grab;
}
.timeline-progress {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--progress-color, rgba(0, 122, 204, 0.3));
pointer-events: none;
z-index: 1;
}
/* Playback controls | 播放控制 */
.playback-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.control-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--button-background, #3c3c3c);
border: 1px solid var(--border-color, #5c5c5c);
border-radius: 4px;
color: var(--text-color, #cccccc);
cursor: pointer;
transition: all 0.15s ease;
}
.control-button:hover {
background: var(--button-hover, #4c4c4c);
border-color: var(--border-hover, #007acc);
}
.control-button:active {
background: var(--button-active, #2c2c2c);
}
.control-button.primary {
width: 40px;
height: 40px;
background: var(--accent-color, #007acc);
border-color: var(--accent-color, #007acc);
color: white;
}
.control-button.primary:hover {
background: var(--accent-hover, #1e8ad2);
border-color: var(--accent-hover, #1e8ad2);
}
/* Playback options | 播放选项 */
.playback-options {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.option-row {
display: flex;
align-items: center;
gap: 6px;
}
.option-row label {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-muted, #888888);
cursor: pointer;
font-size: 11px;
}
.option-row select {
padding: 2px 6px;
background: var(--input-background, #3c3c3c);
border: 1px solid var(--border-color, #5c5c5c);
border-radius: 3px;
color: var(--text-color, #cccccc);
font-size: 11px;
}
.option-row input[type="checkbox"] {
cursor: pointer;
margin: 0;
}
/* No animations | 无动画 */
.no-animations {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
color: var(--text-muted, #666666);
text-align: center;
font-style: italic;
}
/* Model info | 模型信息 */
.model-info {
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.section-title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted, #888888);
margin-bottom: 6px;
}
.model-info .info-row {
color: var(--text-muted, #888888);
font-size: 11px;
}
/* Skeleton info | 骨骼信息 */
.skeleton-info {
padding: 12px;
border-top: 1px solid var(--border-color, #3c3c3c);
}
.skeleton-info .info-row {
color: var(--text-muted, #888888);
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
],
"references": [
{
"path": "../../../framework/core"
},
{
"path": "../../../engine/engine-core"
},
{
"path": "../../../editor/editor-core"
},
{
"path": "../../../rendering/mesh-3d"
}
]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsup';
import { editorOnlyPreset } from '../../../tools/build-config/src/presets/plugin-tsup';
export default defineConfig({
...editorOnlyPreset(),
tsconfig: 'tsconfig.build.json'
});