@@ -27,7 +27,7 @@ import { coreSceneCommands } from "../../../protocol/core-scene-commands";
import { meshAuthoringCommands } from "../../../protocol/mesh-authoring-commands" ;
import { nonMeshGeometryCommands } from "../../../protocol/nonmesh-geometry-commands" ;
import { animationRenderCommands } from "../../../protocol/animation-render-commands" ;
import { ioMediaCommands } from "../../../protocol/io-media-commands" ;
import { ioMediaCommands , type IOSceneFormat } from "../../../protocol/io-media-commands" ;
import { isWorkbenchCommandAvailable } from "../../../protocol/workbench-command-groups" ;
import { shaderGraphCommands } from "../../../protocol/shader-graph-commands" ;
import { renderCompositorCommands } from "../../../protocol/render-compositor-commands" ;
@@ -122,6 +122,14 @@ function recentProjectIssueMessage(code: RecentProjectIssue["code"]): string {
return "项目元数据不一致" ;
}
function encodeBase64 ( buffer : ArrayBuffer ) : string {
const bytes = new Uint8Array ( buffer ) ;
let binary = "" ;
const chunkSize = 0x8000 ;
for ( let offset = 0 ; offset < bytes . length ; offset += chunkSize ) binary += String . fromCharCode ( . . . bytes . subarray ( offset , Math . min ( offset + chunkSize , bytes . length ) ) ) ;
return btoa ( binary ) ;
}
function StorageBudgetPanel ( { budget , onCleanup } : { budget : StorageBudgetResult | null ; onCleanup : ( ) = > void } ) {
const values = budget ? [
[ "项目" , budget . projectBytes ] ,
@@ -2715,7 +2723,8 @@ export function App() {
return ;
}
if ( event . key === "F3" ) {
if ( interactive Target && target ? . matches ( "input, textarea, select, [contenteditable='true']" )) return ;
const textEntry Target = target ? . matches ( "textarea, select, [contenteditable='true'], input:not([type='file']) " ) ;
if ( interactiveTarget && textEntryTarget ) return ;
event . preventDefault ( ) ;
dispatchUI ( { type : "toggleOperatorSearch" , open : true } ) ;
return ;
@@ -2969,6 +2978,56 @@ export function App() {
return gpuAssets ;
} ;
const importSceneFile = async ( file : File , format : IOSceneFormat ) : Promise < void > = > {
const identity = nextUserActionIdentity ( "OPEN" ) ;
if ( ! acquireProjectActionLock ( identity ) ) return ;
startUserAction ( identity ) ;
const controller = new AbortController ( ) ;
openAbortControllerRef . current = controller ;
const client = webClientRef . current ;
try {
if ( ! client ) {
failUserAction ( identity , "OPEN_ENGINE_UNAVAILABLE" ) ;
return ;
}
const input = await readFileBytes ( file , { signal : controller.signal } ) ;
const inputSha256 = await sha256Hex ( input ) ;
const result = await client . importScene ( format , file . name , file . type || "application/octet-stream" , encodeBase64 ( input ) ) ;
const projectId = file . name . replace ( /\.[^.]+$/i , "" ) . replace ( /[^A-Za-z0-9_-]+/g , "-" ) . replace ( /^-+|-+$/g , "" ) . slice ( 0 , 64 ) || "untitled" ;
projectIdRef . current = projectId ;
projectDisplayNameRef . current = file . name ;
rememberLastProjectId ( projectId ) ;
projectRevisionRef . current = result . snapshot . revision ;
committedProjectRevisionRef . current = 0 ;
committedProjectHashRef . current = inputSha256 ;
setSaveTransaction ( createSaveTransactionState ( { revision : 0 , sha256 : inputSha256 } ) ) ;
setDirtyState ( createDirtyState ( result . snapshot . revision ) ) ;
setVolumeProject ( { projectId , sourceBlendSha256 : inputSha256 } ) ;
setPreview ( null ) ;
setLodLevels ( null ) ;
setGPUTextureAssets ( [ ] ) ;
setSnapshot ( result . snapshot ) ;
setSelectedObjectIds ( new Set ( result . snapshot . activeObjectId ? [ result . snapshot . activeObjectId ] : [ ] ) ) ;
setGeometryBuffers ( result . geometryBuffers ) ;
setNonMeshGeometryBuffers ( result . nonMeshGeometryBuffers ? ? [ ] ) ;
setFrame ( result . snapshot . frame . current ) ;
setEngineStatus ( ` Engine: ${ format } imported r ${ result . snapshot . revision } ( ${ result . snapshot . nodes . length } objects) ` ) ;
void cachePackedAssets ( projectId , result . snapshot ) . then ( ( assets ) = > {
if ( projectIdRef . current === projectId ) setGPUTextureAssets ( assets ) ;
} ) ;
void restoreCachedLODs ( projectId , result . snapshot ) ;
succeedUserAction ( identity ) ;
}
catch ( error ) {
failUserAction ( identity , error instanceof FileByteReadError ? error . code : "IO_IMPORT_FAILED" ) ;
setEngineStatus ( recordDiagnostic ( "ENGINE" , "BLEND_OPEN_FAILED" , error , { fileName : file.name , format } ) ) ;
}
finally {
if ( openAbortControllerRef . current === controller ) openAbortControllerRef . current = null ;
releaseProjectActionLock ( identity ) ;
}
} ;
const openBlendFile = async ( file : File ) : Promise < void > = > {
const identity = nextUserActionIdentity ( "OPEN" ) ;
if ( ! acquireProjectActionLock ( identity ) ) return ;
@@ -3497,7 +3556,7 @@ export function App() {
{ ( [ "Layout" , "Modeling" , "Animation" ] as WorkspaceId [ ] ) . map ( ( item ) = > < button key = { item } className = { item === workspace ? "workspace-tab active" : "workspace-tab" } type = "button" aria-current = { item === workspace ? "page" : undefined } onClick = { ( ) = > dispatchUI ( { type : "switchWorkspace" , workspaceId : item } ) } > { item } < / button > ) }
< / nav >
< div className = "topbar-actions" > < select aria-label = "最近项目" data-testid = "recent-projects" defaultValue = "" onChange = { ( event ) = > { const projectId = event . target . value ; const displayName = event . target . selectedOptions [ 0 ] ? . textContent ? ? projectId ; if ( ! projectId ) return ; void recoverCachedProject ( projectId , displayName ) ; event . currentTarget . value = "" ; } } > < option value = "" > 最 近 项 目 ( { recentProjects . length } ) < / option > { recentProjects . map ( ( project ) = > < option key = { project . projectId } value = { project . projectId } > { project . displayName } < / option > ) } < / select > < button type = "button" aria-label = "打开 .blend" onClick = { ( ) = > fileInputRef . current ? . click ( ) } > 打 开 < / button > < button type = "button" aria-label = "关闭项目" disabled = { ! snapshot } onClick = { closeProject } > 关 闭 < / button > < button type = "button" aria-label = "恢复项目" onClick = { ( ) = > void recoverCachedProject ( ) } > 恢 复 < / button > < button type = "button" aria-label = "保存项目" onClick = { ( ) = > void saveBlend ( ) } > 保 存 < / button > < button type = "button" aria-label = "导出 GLB" onClick = { ( ) = > void reportGLBExport ( ) } > GLB < / button > < button type = "button" aria-label = "导出诊断报告" data-testid = "export-diagnostics" disabled = { diagnostics . length === 0 } onClick = { exportDiagnosticReport } > 诊 断 < / button > < button type = "button" aria-label = "撤销" onClick = { ( ) = > void applyEditCommand ( { type : "undo" } ) } > ↶ < / button > < button type = "button" aria-label = "重做" onClick = { ( ) = > void applyEditCommand ( { type : "redo" } ) } > ↷ < / button > < button ref = { operatorSearchTriggerRef } type = "button" aria-label = "操作搜索" aria-haspopup = "dialog" onClick = { ( ) = > dispatchUI ( { type : "toggleOperatorSearch" , open : true } ) } > F3 < / button > { workerFaultTestMode ? < button type = "button" data-testid = "inject-worker-crash" aria-label = "注入 Worker 崩溃" onClick = { injectWorkerCrash } > Fault < / button > : null } < / div >
< input ref = { fileInputRef } className = "file-input-hidden" type = "file" accept = { ioFormatUIAccept ( IO_FORMAT_UI_REGISTRY ) } aria-label = "打开 .blend 文件" data-testid = "blend-file-input" data-io-format-import-routes = { IO_FORMAT_UI_REGISTRY . importRoutes . map ( ( route ) = > route . format ) . join ( "," ) } onChange = { ( event ) = > { const file = event . target . files ? . [ 0 ] ; if ( file ) { const gate = gateIOFormatFileSelection ( file . name , IO_FORMAT_UI_REGISTRY ) ; if ( gate . status === "BLOCKED" ) setEngineStatus ( recordDiagnostic ( "ENGINE" , "IO_FORMAT_UNSUPPORTED" , gate , { fileName : file.name } ) ) ; else void openBlendFile ( file ) ; } event . target . value = "" ; } } / >
< input ref = { fileInputRef } className = "file-input-hidden" type = "file" accept = { ioFormatUIAccept ( IO_FORMAT_UI_REGISTRY ) } aria-label = "打开 .blend 文件" data-testid = "blend-file-input" data-io-format-import-routes = { IO_FORMAT_UI_REGISTRY . importRoutes . map ( ( route ) = > route . format ) . join ( "," ) } onChange = { ( event ) = > { const file = event . target . files ? . [ 0 ] ; if ( file ) { const gate = gateIOFormatFileSelection ( file . name , IO_FORMAT_UI_REGISTRY ) ; if ( gate . status === "BLOCKED" ) setEngineStatus ( recordDiagnostic ( "ENGINE" , "IO_FORMAT_UNSUPPORTED" , gate , { fileName : file.name } ) ) ; else if ( file . name . toLowerCase ( ) . endsWith ( ".blend" ) ) void openBlendFile ( file ) ; else { const route = IO_FORMAT_UI_REGISTRY . importRoutes . find ( ( candidate ) = > candidate . execution === "LOCAL" && candidate . extensions . some ( ( extension ) = > file . name . toLowerCase ( ) . endsWith ( extension ) ) ) ; if ( route ) void importSceneFile ( file , route . format ) ; else setEngineStatus ( recordDiagnostic ( "ENGINE" , "IO_FORMAT_UNSUPPORTED" , gate , { fileName : file.name } ) ) ; } } event . target . value = "" ; } } / >
< / header >
{ workerFault ? < div className = "worker-fault-banner" role = "alert" data-testid = "worker-fault-banner" > < span > { workerFault . source === "engine" ? "Engine Worker" : "Storage Worker" } stopped ; current project list and scene are retained . < / span > < button type = "button" data-testid = "restart-and-recover" onClick = { ( ) = > void restartWorkersAndRecover ( ) } disabled = { workerRecoveryStatus === "RUNNING" } > { workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复" } < / button > { workerRecoveryStatus === "FAILED" ? < span data-testid = "worker-recovery-error" > Recovery failed ; retry is safe . < / span > : null } < / div > : null }
{ recentProjectIssues . length > 0 ? < div className = "recent-project-repair-banner" role = "alert" data-testid = "recent-project-repair-banner" > < span > 最 近 项 目 中 有 { recentProjectIssues . length } 个 条 目 无 法 验 证 ; 当 前 场 景 不 会 被 删 除 。 < / span > < div className = "recent-project-repair-list" > { recentProjectIssues . map ( ( issue ) = > < div key = { issue . project . projectId } className = "recent-project-repair-item" data-project-id = { issue . project . projectId } data-issue-code = { issue . code } > < span > { issue . project . displayName } : { recentProjectIssueMessage ( issue . code ) } < / span > < button type = "button" data-testid = { ` remove-recent-project- ${ issue . project . projectId } ` } aria-label = { ` 移除失效项目引用 ${ issue . project . displayName } ` } onClick = { ( ) = > void removeInvalidRecentProject ( issue . project . projectId ) } > 移 除 引 用 < / button > < / div > ) } < / div > < / div > : null }