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:
@@ -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;
|
||||
}
|
||||
@@ -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}`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
221
packages/editor/plugins/mesh-3d-editor/src/index.ts
Normal file
221
packages/editor/plugins/mesh-3d-editor/src/index.ts
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user