@@ -4,9 +4,11 @@ import { WebEngineClient } from "../engine-client/WebEngineClient";
import { loadWebEngineManifest , verifyWasmResource } from "../../../protocol/manifest" ;
import type { ProgressEvent } from "../../../protocol/progress" ;
import type { SceneSnapshotIR } from "../../../protocol/scene-ir" ;
import type { SequencerStripIR , SequencerStripType , SequencerTimelineIR } from "../../../protocol/sequencer" ;
import type { GPUTextureAsset } from "../../../protocol/render-assets" ;
import { collectGPUTextureAssetRequests , createGPUTextureAsset , RenderAssetValidationError } from "../../../protocol/render-assets" ;
import type { MeshEditOperation , MeshElementMode , MeshGeometryBuffer , WebEngineEditCommand , WebEngineLODLevelResult } from "../../../protocol/web-engine" ;
import type { MeshEditOperation , MeshElementMode , MeshGeometryBuffer , MeshPrimitive , WebEngineEditCommand , WebEngineLODLevelResult } from "../../../protocol/web-engine" ;
import type { BlenderOperatorDescriptor , BlenderOperatorPropertySpec , BlenderOperatorPropertyValue } from "../../../protocol/blender-operator" ;
import { coreSceneCommands } from "../../../protocol/core-scene-commands" ;
import { meshAuthoringCommands } from "../../../protocol/mesh-authoring-commands" ;
import { nonMeshGeometryCommands } from "../../../protocol/nonmesh-geometry-commands" ;
@@ -19,6 +21,8 @@ import { parseLibraryMainAppendRequest, type LibraryMainAppendRequestIR } from "
import type { RenderCapabilityRequest } from "../../../protocol/render-capabilities" ;
import { compositorCommands } from "../../../protocol/compositor-commands" ;
import { geometryNodeCommands } from "../../../protocol/geometry-node-commands" ;
import { GEOMETRY_NODE_ALLOWLIST , type GeometryNodeGraphIR } from "../../../protocol/geometry-nodes" ;
import type { ShaderGraphIR , ShaderSocketIR } from "../../../protocol/shader-graph" ;
import { physicsCommands } from "../../../protocol/physics-commands" ;
import { WEB_ENGINE_COMMAND_CAPABILITIES } from "../../../protocol/web-engine-capabilities" ;
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary" ;
@@ -34,7 +38,7 @@ import type { GreasePencilPointPreview, GreasePencilPointRef } from "../three-ad
import { buildLODCacheKey } from "../three-adapter/lod" ;
import { decodeLODGeometry , encodeLODGeometry } from "../../../protocol/mesh-cache" ;
import type { LODCacheRecord } from "../../../protocol/lod" ;
import { modifierStackHash } from "../../../protocol/modifier" ;
import { modifierParameterMetadata , modifierStackHash } from "../../../protocol/modifier" ;
import { exportGLB } from "../../../protocol/glb-export" ;
import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export" ;
import { normalizeProjectAssetPath } from "../../../protocol/asset-path" ;
@@ -63,7 +67,7 @@ import {
type NanoVDBViewportProjectContextIR ,
} from "../volume/nanovdb-viewport" ;
import { composePaintColorPatch , composePaintWeightPatch , createSculptMeshAttributes , createSculptStroke } from "../../../protocol/paint" ;
import type { SculptBrush } from "../../../protocol/sculpt" ;
import type { SculptBrush , SculptStrokeSampleIR } from "../../../protocol/sculpt" ;
import { createDefaultWebWorkspaceState , reduceUICommand , type EditorType , type UICommand , type WorkspaceId } from "../../../protocol/ui-schema" ;
import { parseEditorWorkflow , type EditorWorkflowIR } from "../../../protocol/editor-workflow" ;
import { importExternalVFontIntoMain } from "../fonts/external-vfont-import" ;
@@ -104,6 +108,112 @@ function StorageBudgetPanel({ budget, onCleanup }: { budget: StorageBudgetResult
return < section className = "storage-budget-panel" data-testid = "storage-budget-panel" data-project-id = { budget ? . projectId ? ? "" } data-total-bytes = { budget ? . totalBytes ? ? 0 } aria-label = "存储预算" > < strong > 存 储 预 算 < / strong > { values . map ( ( [ label , bytes ] ) = > < span key = { label } data-category = { label } data-bytes = { bytes } > { label } { formatStorageBytes ( bytes ) } < / span > ) } < output > 合 计 { formatStorageBytes ( budget ? . totalBytes ? ? 0 ) } < / output > < button type = "button" data-testid = "cleanup-project-assets" aria-label = "清理项目孤儿资源" disabled = { ! budget ? . projectId || budget . projectId === "untitled" } onClick = { onCleanup } > 清 理 孤 儿 资 源 < / button > < / section > ;
}
interface OperatorPaletteCommand {
id : string ;
label : string ;
keywords? : string ;
execute ? : ( ) = > unknown ;
}
function BlenderOperatorPalette ( { client , commands , onClose , onApplied } : {
client : WebEngineClient | null ;
commands : ReadonlyArray < OperatorPaletteCommand > ;
onClose : ( ) = > void ;
onApplied : ( result : Awaited < ReturnType < WebEngineClient [ "invokeBlenderOperator" ] > > , descriptor : BlenderOperatorDescriptor ) = > void ;
} ) {
const [ query , setQuery ] = useState ( "" ) ;
const [ catalog , setCatalog ] = useState < BlenderOperatorDescriptor [ ] > ( [ ] ) ;
const [ selected , setSelected ] = useState < BlenderOperatorDescriptor | null > ( null ) ;
const [ values , setValues ] = useState < Record < string , BlenderOperatorPropertyValue > > ( { } ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const [ executing , setExecuting ] = useState ( false ) ;
useEffect ( ( ) = > {
let active = true ;
if ( ! client ) {
setLoading ( false ) ;
return ( ) = > { active = false ; } ;
}
setLoading ( true ) ;
void client . listBlenderOperators ( ) . then ( ( { operators } ) = > {
if ( active ) setCatalog ( operators ) ;
} ) . catch ( ( reason : unknown ) = > {
if ( active ) setError ( reason instanceof Error ? reason.message : String ( reason ) ) ;
} ) . finally ( ( ) = > {
if ( active ) setLoading ( false ) ;
} ) ;
return ( ) = > { active = false ; } ;
} , [ client ] ) ;
const normalizedQuery = query . trim ( ) . toLowerCase ( ) ;
const staticMatches = commands . filter ( ( command ) = > ` ${ command . label } ${ command . keywords ? ? "" } ` . toLowerCase ( ) . includes ( normalizedQuery ) ) ;
const nativeMatches = catalog . filter ( ( operator ) = > ` ${ operator . id } ${ operator . label } ${ operator . family } ${ operator . description ? ? "" } ` . toLowerCase ( ) . includes ( normalizedQuery ) ) ;
const chooseOperator = ( operator : BlenderOperatorDescriptor ) : void = > {
const next : Record < string , BlenderOperatorPropertyValue > = { } ;
for ( const property of operator . properties ) {
if ( property . defaultValue !== undefined ) next [ property . name ] = property . defaultValue ;
else if ( property . type === "enum" && property . enumItems ? . [ 0 ] ) next [ property . name ] = property . enumItems [ 0 ] . value ;
else if ( property . type === "boolean" ) next [ property . name ] = false ;
else next [ property . name ] = "" ;
}
setValues ( next ) ;
setSelected ( operator ) ;
setError ( null ) ;
} ;
const renderProperty = ( property : BlenderOperatorPropertySpec ) : React . ReactNode = > {
const value = values [ property . name ] ;
const label = property . label ? ? property . name ;
if ( property . type === "boolean" ) {
return < label key = { property . name } className = "operator-property" > < input type = "checkbox" checked = { Boolean ( value ) } onChange = { ( event ) = > setValues ( ( current ) = > ( { . . . current , [ property . name ] : event . target . checked } ) ) } / > { label } < / label > ;
}
if ( property . type === "enum" ) {
return < label key = { property . name } className = "operator-property" > { label } < select value = { String ( value ? ? "" ) } onChange = { ( event ) = > setValues ( ( current ) = > ( { . . . current , [ property . name ] : event . target . value } ) ) } > { ( property . enumItems ? ? [ ] ) . map ( ( item ) = > < option key = { item . value } value = { item . value } > { item . label } < / option > ) } < / select > < / label > ;
}
return < label key = { property . name } className = "operator-property" > { label } < input type = { property . type === "number" ? "number" : "text" } value = { String ( value ? ? "" ) } min = { property . min } max = { property . max } step = { property . type === "number" ? "any" : undefined } required = { property . required } onChange = { ( event ) = > setValues ( ( current ) = > ( { . . . current , [ property . name ] : property . type === "number" ? event.target.value : event.target.value } ) ) } / > < / label > ;
} ;
const executeOperator = async ( event : React.FormEvent ) : Promise < void > = > {
event . preventDefault ( ) ;
if ( ! selected || ! client ) return ;
const properties : Record < string , BlenderOperatorPropertyValue > = { } ;
for ( const property of selected . properties ) {
const value = values [ property . name ] ;
if ( property . required && ( value === undefined || value === "" ) ) {
setError ( ` ${ property . label ? ? property . name } 为必填项 ` ) ;
return ;
}
if ( property . type === "number" ) {
if ( value === "" || value === undefined ) continue ;
const numberValue = Number ( value ) ;
if ( ! Number . isFinite ( numberValue ) ) {
setError ( ` ${ property . label ? ? property . name } 必须是数字 ` ) ;
return ;
}
properties [ property . name ] = numberValue ;
}
else if ( value !== undefined && value !== "" ) properties [ property . name ] = value ;
}
setExecuting ( true ) ;
setError ( null ) ;
try {
const result = await client . invokeBlenderOperator ( { operator : selected.id , properties } ) ;
onApplied ( result , selected ) ;
onClose ( ) ;
}
catch ( reason ) {
setError ( reason instanceof Error ? reason.message : String ( reason ) ) ;
}
finally {
setExecuting ( false ) ;
}
} ;
return < div className = "operator-search-backdrop" role = "presentation" onMouseDown = { ( event ) = > { if ( event . target === event . currentTarget ) onClose ( ) ; } } > < section className = "operator-search-dialog" role = "dialog" aria-modal = "true" aria-label = "操作搜索" > < header > < strong > 操 作 搜 索 < / strong > < button type = "button" aria-label = "关闭操作搜索" onClick = { onClose } > × < / button > < / header > < input autoFocus value = { query } placeholder = "搜索 Blender 操作..." aria-label = "搜索 Blender 操作" onChange = { ( event ) = > setQuery ( event . target . value ) } / > { loading ? < p > 正 在 加 载 原 生 操 作 目 录 … < / p > : null } { error ? < p role = "alert" > { error } < / p > : null } < div className = "operator-search-results" role = "listbox" > { staticMatches . map ( ( command ) = > < button key = { ` command: ${ command . id } ` } type = "button" onClick = { ( ) = > { onClose ( ) ; void command . execute ? . ( ) ; } } > { command . label } < / button > ) } { nativeMatches . map ( ( operator ) = > < button key = { ` native: ${ operator . id } ` } type = "button" onClick = { ( ) = > chooseOperator ( operator ) } > { operator . label } < small > { operator . id } < / small > < / button > ) } { ! loading && staticMatches . length === 0 && nativeMatches . length === 0 ? < p > 没 有 匹 配 的 操 作 < / p > : null } < / div > { selected ? < form className = "operator-property-form" onSubmit = { ( event ) = > void executeOperator ( event ) } > < h3 > { selected . label } < / h3 > { selected . properties . map ( renderProperty ) } < button type = "submit" disabled = { executing || ! client } > { executing ? "执行中…" : "执行" } < / button > < / form > : null } < / section > < / div > ;
}
async function sha256Hex ( data : ArrayBuffer ) : Promise < string > {
const digest = await crypto . subtle . digest ( "SHA-256" , data ) ;
return Array . from ( new Uint8Array ( digest ) , ( value ) = > value . toString ( 16 ) . padStart ( 2 , "0" ) ) . join ( "" ) ;
@@ -685,6 +795,16 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
const [ materialCoat , setMaterialCoat ] = useState ( 0 ) ;
const [ materialCoatRoughness , setMaterialCoatRoughness ] = useState ( 0.03 ) ;
const [ materialEmissionStrength , setMaterialEmissionStrength ] = useState ( 1 ) ;
const [ geometryGraphDraft , setGeometryGraphDraft ] = useState < GeometryNodeGraphIR | null > ( null ) ;
const [ geometryNodeType , setGeometryNodeType ] = useState < string > ( GEOMETRY_NODE_ALLOWLIST [ 2 ] ) ;
const [ geometryNodeName , setGeometryNodeName ] = useState ( "Web Node" ) ;
const [ geometryLinkFrom , setGeometryLinkFrom ] = useState ( "" ) ;
const [ geometryLinkTo , setGeometryLinkTo ] = useState ( "" ) ;
const [ shaderGraphDraft , setShaderGraphDraft ] = useState < ShaderGraphIR | null > ( null ) ;
const [ shaderNodeType , setShaderNodeType ] = useState < "RGB" | "VALUE" | "MATH" | "IMAGE_TEXTURE" | "PRINCIPLED" | "MATERIAL_OUTPUT" > ( "VALUE" ) ;
const [ shaderNodeName , setShaderNodeName ] = useState ( "Web Node" ) ;
const [ shaderLinkFrom , setShaderLinkFrom ] = useState ( "" ) ;
const [ shaderLinkTo , setShaderLinkTo ] = useState ( "" ) ;
const [ renameValue , setRenameValue ] = useState ( "" ) ;
const [ paintColor , setPaintColor ] = useState ( "#cc6633" ) ;
const [ paintWeight , setPaintWeight ] = useState ( 1 ) ;
@@ -702,6 +822,8 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
const [ sculptMirrorObjectSpace , setSculptMirrorObjectSpace ] = useState ( false ) ;
const [ sculptMaskValue , setSculptMaskValue ] = useState ( 0 ) ;
const [ sculptActiveFaceSet , setSculptActiveFaceSet ] = useState ( 1 ) ;
const [ sculptRemeshVoxelSize , setSculptRemeshVoxelSize ] = useState ( 0.05 ) ;
const [ sculptCursorSamples , setSculptCursorSamples ] = useState < SculptStrokeSampleIR [ ] > ( [ ] ) ;
const [ greasePencilLayerId , setGreasePencilLayerId ] = useState ( "" ) ;
const [ greasePencilLayerName , setGreasePencilLayerName ] = useState ( "Web Layer" ) ;
const [ greasePencilStrokeIndex , setGreasePencilStrokeIndex ] = useState ( 0 ) ;
@@ -751,6 +873,56 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
const activeWorld = snapshot ? . worlds . find ( ( world ) = > world . id === activeNode ? . dataId ) ;
const activeScene = snapshot ? . scenes . find ( ( scene ) = > scene . id === snapshot . sceneId ) ? ? snapshot ? . scenes [ 0 ] ;
const packedImages = snapshot ? . images . filter ( ( image ) = > image . packed && image . assetId ) ? ? [ ] ;
useEffect ( ( ) = > {
let drawing = false ;
const viewportPoint = ( event : PointerEvent ) : SculptStrokeSampleIR | null = > {
const viewport = document . querySelector < HTMLElement > ( "[data-editor='3D Viewport']" ) ;
const target = event . target ;
if ( ! viewport || ! ( target instanceof Node ) || ! viewport . contains ( target ) ) return null ;
const rect = viewport . getBoundingClientRect ( ) ;
const width = Math . max ( 1 , rect . width ) ;
const height = Math . max ( 1 , rect . height ) ;
const x = Math . max ( 0 , Math . min ( 1 , ( event . clientX - rect . left ) / width ) ) ;
const y = Math . max ( 0 , Math . min ( 1 , ( event . clientY - rect . top ) / height ) ) ;
return {
position : [ x * 2 - 1 , ( 1 - y ) * 2 - 1 , 0 ] ,
normal : [ 0 , 0 , 1 ] ,
radius : sculptRadius ,
strength : sculptStrength ,
pressure : Math.max ( 0 , Math . min ( 1 , event . pressure || sculptPressure ) ) ,
time : event.timeStamp / 1000 ,
} ;
} ;
const beginStroke = ( event : PointerEvent ) : void = > {
if ( event . button !== 0 ) return ;
const point = viewportPoint ( event ) ;
if ( ! point ) return ;
drawing = true ;
setSculptCursorSamples ( [ point ] ) ;
} ;
const extendStroke = ( event : PointerEvent ) : void = > {
if ( ! drawing && event . buttons === 0 ) return ;
const point = viewportPoint ( event ) ;
if ( ! point ) return ;
drawing = true ;
setSculptCursorSamples ( ( current ) = > {
const previous = current [ current . length - 1 ] ;
if ( previous && Math . hypot ( point . position [ 0 ] - previous . position [ 0 ] , point . position [ 1 ] - previous . position [ 1 ] ) < 0.002 ) return current ;
return [ . . . current , point ] . slice ( - 2048 ) ;
} ) ;
} ;
const endStroke = ( ) : void = > { drawing = false ; } ;
window . addEventListener ( "pointerdown" , beginStroke ) ;
window . addEventListener ( "pointermove" , extendStroke ) ;
window . addEventListener ( "pointerup" , endStroke ) ;
window . addEventListener ( "pointercancel" , endStroke ) ;
return ( ) = > {
window . removeEventListener ( "pointerdown" , beginStroke ) ;
window . removeEventListener ( "pointermove" , extendStroke ) ;
window . removeEventListener ( "pointerup" , endStroke ) ;
window . removeEventListener ( "pointercancel" , endStroke ) ;
} ;
} , [ sculptPressure , sculptRadius , sculptStrength ] ) ;
useEffect ( ( ) = > {
if ( ! activeMaterial ) return ;
setMaterialColor ( ` # ${ activeMaterial . baseColor . slice ( 0 , 3 ) . map ( ( component ) = > Math . round ( Math . max ( 0 , Math . min ( 1 , component ) ) * 255 ) . toString ( 16 ) . padStart ( 2 , "0" ) ) . join ( "" ) } ` ) ;
@@ -763,6 +935,18 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
setMaterialCoatRoughness ( activeMaterial . coatRoughness ? ? 0.03 ) ;
setMaterialEmissionStrength ( activeMaterial . emissionStrength ? ? 1 ) ;
} , [ activeMaterial ] ) ;
useEffect ( ( ) = > {
if ( activeGeometryGraph ) setGeometryGraphDraft ( JSON . parse ( JSON . stringify ( activeGeometryGraph ) ) as GeometryNodeGraphIR ) ;
else if ( activeMesh ) setGeometryGraphDraft ( geometryNodeCommands . createGraph ( activeMesh . id , ` ${ activeMesh . name } Geometry ` ) ) ;
else setGeometryGraphDraft ( null ) ;
} , [ activeGeometryGraph ? . id , activeGeometryGraph ? . graphHash , activeMesh ? . id ] ) ;
useEffect ( ( ) = > {
if ( ! activeMaterial ) {
setShaderGraphDraft ( null ) ;
return ;
}
setShaderGraphDraft ( shaderGraphCommands . setPrincipledGraph ( activeMaterial . id , activeMaterial . baseColor , activeMaterial . roughness , activeMaterial . metallic ) ) ;
} , [ activeMaterial ? . id ] ) ;
useEffect ( ( ) = > setRenameValue ( activeNode ? . name ? ? "" ) , [ activeNode ? . id ] ) ;
useEffect ( ( ) = > setFontBody ( activeFont ? . text ? ? "" ) , [ activeFont ? . id , activeFont ? . text ] ) ;
useEffect ( ( ) = > setFontLinks ( activeFont ? . fontLinks ? ? { regular : "" , bold : "" , italic : "" , boldItalic : "" } ) , [ activeFont ? . id , activeFont ? . fontLinks ] ) ;
@@ -836,13 +1020,31 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
} ;
const applySculptStroke = ( ) : void = > {
if ( ! activeMesh ) return ;
onCommand ( { type : "sculptStroke" , stroke : createSculptStroke ( activeMesh . id , sculptBrush , sculptRadius , sculptStrength , sculptPressure , sculptSymmetry , sculptMirrorObjectSpace ) } ) ;
onCommand ( { type : "sculptStroke" , stroke : createSculptStroke ( activeMesh . id , sculptBrush , sculptRadius , sculptStrength , sculptPressure , sculptSymmetry , sculptMirrorObjectSpace , sculptCursorSamples ) } ) ;
setSculptCursorSamples ( [ ] ) ;
} ;
const applySculptRemesh = ( ) : void = > {
if ( ! client || ! activeMesh ) return ;
void client . invokeBlenderOperator ( {
operator : "object.voxel_remesh" ,
properties : { objectId : activeMesh.id , voxel_size : sculptRemeshVoxelSize } ,
} ) . catch ( ( ) = > undefined ) ;
} ;
const applySculptMeshAttributes = ( ) : void = > {
if ( ! activeMesh || ! snapshot ) return ;
const faceCount = ( activeMesh as typeof activeMesh & { faceCount? : number } ) . faceCount ? ? 0 ;
onCommand ( { type : "setSculptMeshAttributes" , attributes : createSculptMeshAttributes ( activeMesh . id , activeMesh . vertexCount , faceCount , sculptMaskValue , sculptActiveFaceSet , snapshot . revision ) } ) ;
} ;
const commitGeometryGraph = ( ) : void = > {
if ( activeMesh && geometryGraphDraft ) onCommand ( geometryNodeCommands . setGraph ( activeMesh . id , geometryGraphDraft ) ) ;
} ;
const commitShaderGraph = ( ) : void = > {
if ( activeMaterial && shaderGraphDraft ) onCommand ( { type : "setShaderGraph" , materialId : activeMaterial.id , graph : shaderGraphDraft } ) ;
} ;
const parseGraphSocket = ( value : string ) : { nodeId : string ; socketId : string } | null = > {
const separator = value . lastIndexOf ( ":" ) ;
return separator > 0 ? { nodeId : value.slice ( 0 , separator ) , socketId : value.slice ( separator + 1 ) } : null ;
} ;
const translateGreasePencilPoint = ( ) : void = > {
if ( ! activeGreasePencil || ! activeGreasePencilFrame || ! snapshot ) return ;
const selectedPoints = greasePencilPointSelection
@@ -1096,6 +1298,87 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
< div className = "property-section" data-testid = "font-links-workbench" data-command = "setFontLinks" > < h3 > Font Style Links < / h3 > { ( [ "regular" , "bold" , "italic" , "boldItalic" ] as const ) . map ( ( slot ) = > { const selected = fontLinks [ slot ] ; const resources = snapshot ? . vfonts ? ? [ ] ; return < label key = { slot } > { slot } < select aria-label = { ` Font ${ slot } link ` } value = { selected } disabled = { resources . length === 0 } onChange = { ( event ) = > setFontLink ( slot , event . target . value ) } > < option value = "" > Default < / option > { selected && ! resources . some ( ( font ) = > font . id === selected ) ? < option value = { selected } > { selected } < / option > : null } { resources . map ( ( font ) = > < option key = { font . id } value = { font . id } > { font . name } { font . packed ? " (packed)" : "" } < / option > ) } < / select > < / label > ; } ) } < div className = "property-actions" > < button type = "button" disabled = { ! snapshot ? . vfonts ? . length } onClick = { applyFontLinks } > Apply Links < / button > < / div > < output > { Object . entries ( fontLinks ) . map ( ( [ slot , fontId ] ) = > ` ${ slot } : ${ fontId || "default" } ` ) . join ( " · " ) || "No explicit style links" } < / output > < / div >
< / > : null }
{ activeVolume ? < div className = "property-section" data-testid = "volume-workbench" > < h3 > Volume < / h3 > < output > { activeVolume . sourcePath ? ? "No source path" } < / output > < output > { activeVolume . volumeGrids ? . length ? ? 0 } grids < / output > { activeVolume . volumeProperties ? < > < output > Density { activeVolume . volumeProperties . displayDensity . toFixed ( 3 ) } · Step { activeVolume . volumeProperties . stepSize . toFixed ( 4 ) } < / output > < div className = "property-actions" > < button type = "button" onClick = { ( ) = > onCommand ( nonMeshGeometryCommands . setVolumeProperties ( activeVolume . id , activeVolume . sourcePath ? ? "" , Math . min ( 1 e6 , activeVolume . volumeProperties ! . displayDensity + 0.1 ) , activeVolume . volumeProperties ! . interpolation , activeVolume . volumeProperties ! . stepSize ) ) } > Increase Density < / button > < button type = "button" onClick = { ( ) = > onCommand ( nonMeshGeometryCommands . setVolumeProperties ( activeVolume . id , activeVolume . sourcePath ? ? "" , activeVolume . volumeProperties ! . displayDensity , activeVolume . volumeProperties ! . interpolation === "LINEAR" ? "NEAREST" : "LINEAR" , activeVolume . volumeProperties ! . stepSize ) ) } > Toggle Interpolation < / button > < / div > < / > : < output > Volume properties unavailable < / output > } < / div > : null }
{ activeNode ? . type === "ARMATURE" ? ( ( ) = > {
const armature = snapshot ? . armatures ? . find ( ( item ) = > item . objectId === activeNode . id || item . id === activeNode . dataId ) ;
if ( ! armature ) return null ;
const commandBones = armature . bones . map ( ( bone ) = > ( {
name : bone.name ,
parentName : armature.bones.find ( ( candidate ) = > candidate . id === bone . parentId ) ? . name ? ? null ,
head : bone.head ,
tail : bone.tail ,
roll : bone.roll ? ? 0 ,
connected : bone.connected ? ? false ,
} ) ) ;
return < div className = "property-section" data-testid = "armature-authoring-workbench" >
< h3 > Armature < / h3 >
< output > { armature . bones . length } bones < / output >
< form className = "property-grid" onSubmit = { ( event ) = > {
event . preventDefault ( ) ;
const data = new FormData ( event . currentTarget ) ;
const name = String ( data . get ( "newBoneName" ) ? ? "" ) . trim ( ) ;
if ( ! name ) return ;
const number = ( field : string ) = > Number ( data . get ( field ) ) ;
onCommand ( { type : "setArmatureBones" , objectId : activeNode.id , bones : [ . . . commandBones , {
name ,
parentName : String ( data . get ( "newBoneParent" ) ? ? "" ) || null ,
head : [ number ( "newHeadX" ) , number ( "newHeadY" ) , number ( "newHeadZ" ) ] ,
tail : [ number ( "newTailX" ) , number ( "newTailY" ) , number ( "newTailZ" ) ] ,
roll : number ( "newRoll" ) ,
connected : data.get ( "newConnected" ) === "on" ,
} ] } ) ;
} } >
< label > Name < input name = "newBoneName" defaultValue = { ` Bone. ${ String ( armature . bones . length + 1 ) . padStart ( 3 , "0" ) } ` } / > < / label >
< label > Parent < select name = "newBoneParent" defaultValue = "" > < option value = "" > None < / option > { armature . bones . map ( ( bone ) = > < option key = { bone . id } value = { bone . name } > { bone . name } < / option > ) } < / select > < / label >
{ ( [ "X" , "Y" , "Z" ] as const ) . map ( ( axis , index ) = > < label key = { ` new-head- ${ axis } ` } > Head { axis } < input name = { ` newHead ${ axis } ` } type = "number" step = "0.01" defaultValue = { index === 2 ? 0 : 0 } / > < / label > ) }
{ ( [ "X" , "Y" , "Z" ] as const ) . map ( ( axis , index ) = > < label key = { ` new-tail- ${ axis } ` } > Tail { axis } < input name = { ` newTail ${ axis } ` } type = "number" step = "0.01" defaultValue = { index === 2 ? 1 : 0 } / > < / label > ) }
< label > Roll < input name = "newRoll" type = "number" step = "0.01" defaultValue = { 0 } / > < / label >
< label > Connected < input name = "newConnected" type = "checkbox" / > < / label >
< div className = "property-actions" > < button type = "submit" > Add Bone < / button > < / div >
< / form >
{ armature . bones . map ( ( bone ) = > {
const currentParent = armature . bones . find ( ( candidate ) = > candidate . id === bone . parentId ) ? . name ? ? "" ;
return < div className = "property-subsection" key = { bone . id } data-resource-id = { bone . id } >
< h4 > { bone . name } < / h4 >
< form className = "property-grid" onSubmit = { ( event ) = > {
event . preventDefault ( ) ;
const data = new FormData ( event . currentTarget ) ;
const number = ( field : string ) = > Number ( data . get ( field ) ) ;
const nextName = String ( data . get ( "boneName" ) ? ? "" ) . trim ( ) ;
onCommand ( { type : "setArmatureBones" , objectId : activeNode.id , bones : commandBones.map ( ( item ) = > item . name === bone . name ? {
name : nextName ,
parentName : String ( data . get ( "parentName" ) ? ? "" ) || null ,
head : [ number ( "headX" ) , number ( "headY" ) , number ( "headZ" ) ] ,
tail : [ number ( "tailX" ) , number ( "tailY" ) , number ( "tailZ" ) ] ,
roll : number ( "roll" ) ,
connected : data.get ( "connected" ) === "on" ,
} : { . . . item , parentName : item.parentName === bone . name ? nextName : item.parentName } ) } ) ;
} } >
< label > Name < input name = "boneName" defaultValue = { bone . name } / > < / label >
< label > Parent < select name = "parentName" defaultValue = { currentParent } > < option value = "" > None < / option > { armature . bones . filter ( ( candidate ) = > candidate . id !== bone . id ) . map ( ( candidate ) = > < option key = { candidate . id } value = { candidate . name } > { candidate . name } < / option > ) } < / select > < / label >
{ ( [ "X" , "Y" , "Z" ] as const ) . map ( ( axis , index ) = > < label key = { ` head- ${ axis } ` } > Head { axis } < input name = { ` head ${ axis } ` } type = "number" step = "0.01" defaultValue = { bone . head [ index ] } / > < / label > ) }
{ ( [ "X" , "Y" , "Z" ] as const ) . map ( ( axis , index ) = > < label key = { ` tail- ${ axis } ` } > Tail { axis } < input name = { ` tail ${ axis } ` } type = "number" step = "0.01" defaultValue = { bone . tail [ index ] } / > < / label > ) }
< label > Roll < input name = "roll" type = "number" step = "0.01" defaultValue = { bone . roll ? ? 0 } / > < / label >
< label > Connected < input name = "connected" type = "checkbox" defaultChecked = { bone . connected ? ? false } / > < / label >
< div className = "property-actions" > < button type = "submit" > Apply Rest Pose < / button > < button type = "button" onClick = { ( ) = > onCommand ( { type : "setArmatureBones" , objectId : activeNode.id , bones : commandBones.filter ( ( item ) = > item . name !== bone . name ) . map ( ( item ) = > item . parentName === bone . name ? { . . . item , parentName : null , connected : false } : item ) } ) } > Remove Bone < / button > < / div >
< / form >
< form className = "property-grid" onSubmit = { ( event ) = > {
event . preventDefault ( ) ;
const data = new FormData ( event . currentTarget ) ;
const number = ( field : string ) = > Number ( data . get ( field ) ) ;
onCommand ( { type : "setPoseBoneTransform" , objectId : activeNode.id , boneName : bone.name ,
translation : [ number ( "locationX" ) , number ( "locationY" ) , number ( "locationZ" ) ] ,
rotationEuler : [ number ( "rotationX" ) , number ( "rotationY" ) , number ( "rotationZ" ) ] ,
scale : [ number ( "scaleX" ) , number ( "scaleY" ) , number ( "scaleZ" ) ] } ) ;
} } >
{ ( [ "X" , "Y" , "Z" ] as const ) . map ( ( axis ) = > < label key = { ` location- ${ axis } ` } > Location { axis } < input name = { ` location ${ axis } ` } type = "number" step = "0.01" defaultValue = { 0 } / > < / label > ) }
{ ( [ "X" , "Y" , "Z" ] as const ) . map ( ( axis ) = > < label key = { ` rotation- ${ axis } ` } > Rotation { axis } < input name = { ` rotation ${ axis } ` } type = "number" step = "0.01" defaultValue = { 0 } / > < / label > ) }
{ ( [ "X" , "Y" , "Z" ] as const ) . map ( ( axis ) = > < label key = { ` scale- ${ axis } ` } > Scale { axis } < input name = { ` scale ${ axis } ` } type = "number" min = "0.0001" step = "0.01" defaultValue = { 1 } / > < / label > ) }
< div className = "property-actions" > < button type = "submit" > Apply Pose < / button > < / div >
< / form >
< / div > ;
} ) }
< / div > ;
} ) ( ) : null }
{ activeMetaball ? < div className = "property-section" data-testid = "metaball-workbench" > < h3 > Metaball < / h3 > < output > { activeMetaball . elements ? . length ? ? 0 } elements < / output > { activeMetaball . elements ? . length ? < div className = "property-actions" > < button type = "button" onClick = { ( ) = > onCommand ( nonMeshGeometryCommands . setMetaballElements ( activeMetaball . id , activeMetaball . elements ! . map ( ( element , index ) = > index === 0 ? { . . . element , radius : Math.min ( 100000 , element . radius + 0.05 ) } : element ) ) ) } > Increase Active Radius < / button > < / div > : < output > No editable elements < / output > } < / div > : null }
{ activeNonMeshData ? < div className = "property-section" data-testid = "nonmesh-advanced-workbench" data-command = "deleteNonMeshData" > < h3 > Non - Mesh Data < / h3 > < output > { activeNonMeshData . name } · { activeNonMeshData . type } · { activeNonMeshData . pointCount } points < / output > < button type = "button" onClick = { ( ) = > onCommand ( nonMeshGeometryCommands . deleteNonMeshData ( activeNonMeshData . id ) ) } > Delete Active Data < / button > < / div > : null }
< div className = "property-section" data-testid = "render-capability-workbench" > < h3 > Render Capabilities < / h3 > < button type = "button" onClick = { ( ) = > onQueryRenderCapability ( renderCompositorCommands . boundedShaderGraph ( ) ) } > 检 查 Shader < / button > < button type = "button" onClick = { ( ) = > onQueryRenderCapability ( renderCompositorCommands . webGPUBackend ( ) ) } > 检 查 WebGPU < / button > < button type = "button" onClick = { ( ) = > onQueryRenderCapability ( renderCompositorCommands . postProcess ( [ "FXAA" , "TAA" ] ) ) } > 检 查 Post Process < / button > { snapshot ? . scenes [ 0 ] ? . compositorGraph ? < button type = "button" onClick = { ( ) = > { compositorCommands . evaluate ( snapshot . scenes [ 0 ] . compositorGraph ! ) ; } } > 准 备 Compositor 图 < / button > : null } < / div >
@@ -1141,7 +1424,7 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
< output > { activeGreasePencil . layerCount } layers / { activeGreasePencil . frameCount } frames / { activeGreasePencil . strokeCount } strokes < / output >
< / div > : null }
{ activeMesh && activeNode ? < div className = "property-section" data-testid = "paint-editor" > < h3 > Paint < / h3 > < label > Vertex color < input aria-label = "Paint vertex color" type = "color" value = { paintColor } onChange = { ( event ) = > setPaintColor ( event . target . value ) } / > < / label > < label > Vertex group < input aria-label = "Paint vertex group" value = { paintGroup } onChange = { ( event ) = > setPaintGroup ( event . target . value ) } / > < / label > < label > Weight < input aria-label = "Paint vertex weight" type = "range" min = "0" max = "1" step = "0.01" value = { paintWeight } onChange = { ( event ) = > setPaintWeight ( Number ( event . target . value ) ) } / > < output > { paintWeight . toFixed ( 2 ) } < / output > < / label > < label > Selection mask < input aria-label = "Paint selection mask" type = "range" min = "0" max = "1" step = "0.01" value = { paintSelectionMask } onChange = { ( event ) = > setPaintSelectionMask ( Number ( event . target . value ) ) } / > < output > { paintSelectionMask . toFixed ( 2 ) } < / output > < / label > < label > Normalize weights < input aria-label = "Paint normalize weights" type = "checkbox" checked = { paintNormalize } onChange = { ( event ) = > setPaintNormalize ( event . target . checked ) } / > < / label > < label > Limit influences < select aria-label = "Paint influence limit" value = { paintLimit } onChange = { ( event ) = > setPaintLimit ( Number ( event . target . value ) ) } > { [ 1 , 2 , 3 , 4 , 8 ] . map ( ( limit ) = > < option key = { limit } value = { limit } > { limit } < / option > ) } < / select > < / label > < label > Mirror X / Y / Z < input aria-label = "Paint mirror weights" type = "checkbox" checked = { paintMirror } onChange = { ( event ) = > setPaintMirror ( event . target . checked ) } / > < / label > { paintMirror ? < label > Mirror axis < select aria-label = "Paint mirror axis" value = { paintMirrorAxis } onChange = { ( event ) = > setPaintMirrorAxis ( Number ( event . target . value ) as 0 | 1 | 2 ) } > < option value = { 0 } > X < / option > < option value = { 1 } > Y < / option > < option value = { 2 } > Z < / option > < / select > < / label > : null } < div className = "property-actions" > < button type = "button" disabled = { selectedVertexIndices . length === 0 } onClick = { ( ) = > { const rgb = [ 1 , 3 , 5 ] . map ( ( offset ) = > Number . parseInt ( paintColor . slice ( offset , offset + 2 ) , 16 ) / 255 ) as [ number , number , number ] ; onCommand ( { type : "setVertexColors" , meshId : activeMesh.id , attributeName : "WebPaintColor" , domain : "POINT" , indices : selectedVertexIndices , colors : selectedVertexIndices.flatMap ( ( ) = > [ . . . rgb , 1 ] ) } ) ; } } > Apply Color < / button > < button type = "button" disabled = { selectedVertexIndices . length === 0 || ! paintGroup } onClick = { ( ) = > onCommand ( { type : "setVertexWeights" , objectId : activeNode.id , vertexGroup : paintGroup , indices : selectedVertexIndices , values : selectedVertexIndices.map ( ( ) = > paintWeight ) , normalize : paintNormalize , limit : paintLimit , mirror : paintMirror , . . . ( paintMirror ? { mirrorAxis : paintMirrorAxis , mirrorTolerance : 1e - 4 } : { } ) } ) } > Apply Weight < / button > < button type = "button" disabled = { selectedVertexIndices . length === 0 || paintSelectionMask <= 0 } onClick = { blendPaintColor } > Blend Color < / button > < button type = "button" disabled = { selectedVertexIndices . length === 0 || ! paintGroup || paintSelectionMask <= 0 } onClick = { blendPaintWeight } > Blend Weight < / button > < / div > < output > { selectedVertexIndices . length } selected vertices < / output > < output data-testid = "paint-color-attribute" > { activeMesh . attributes ? . some ( ( attribute ) = > attribute . name === "WebPaintColor" && attribute . domain === "POINT" ) ? "WebPaintColor POINT" : "No WebPaintColor" } < / output > < output data-testid = "paint-vertex-group" > { activeMesh . vertexGroups ? . some ( ( group ) = > group . name === paintGroup ) ? paintGroup : "No paint group" } < / output > < / div > : null }
{ activeMesh ? < div className = "property-section" data-testid = "sculpt-editor" data-command = "sculptStroke,setSculptMeshAttributes" > < h3 > Sculpt < / h3 > < label > Brush < select aria-label = "Sculpt brush" value = { sculptBrush } onChange = { ( event ) = > setSculptBrush ( event . target . value as SculptBrush ) } > < option value = "DRAW" > Draw < / option > < option value = "INFLATE" > Inflate < / option > < option value = "GRAB" > Grab < / option > < option value = "SMOOTH" > Smooth < / option > < / select > < / label > < label > Radius < input aria-label = "Sculpt radius" type = "number" min = "0.001" step = "0.01" value = { sculptRadius } onChange = { ( event ) = > setSculptRadius ( Math . max ( 0.001 , Number ( event . target . value ) || 0.001 ) ) } / > < / label > < label > Strength < input aria-label = "Sculpt strength" type = "range" min = "-1" max = "1" step = "0.01" value = { sculptStrength } onChange = { ( event ) = > setSculptStrength ( Number ( event . target . value ) ) } / > < output > { sculptStrength . toFixed ( 2 ) } < / output > < / label > < label > Pressure < input aria-label = "Sculpt pressure" type = "range" min = "0" max = "1" step = "0.01" value = { sculptPressure } onChange = { ( event ) = > setSculptPressure ( Number ( event . target . value ) ) } / > < output > { sculptPressure . toFixed ( 2 ) } < / output > < / label > < label > Symmetry X / Y / Z < span className = "property-actions" > { ( [ 0 , 1 , 2 ] as const ) . map ( ( axis ) = > < label key = { axis } > < input aria-label = { ` Sculpt symmetry ${ axis === 0 ? "X" : axis === 1 ? "Y" : "Z" } ` } type = "checkbox" checked = { sculptSymmetry [ axis ] } onChange = { ( event ) = > setSculptSymmetry ( ( current ) = > current . map ( ( value , index ) = > index === axis ? event.target.checked : value ) as [ boolean , boolean , boolean ] ) } / > { axis === 0 ? "X" : axis === 1 ? "Y" : "Z" } < / label > ) } < / span > < / label > < label > Mirror object space < input aria-label = "Sculpt mirror object space" type = "checkbox" checked = { sculptMirrorObjectSpace } onChange = { ( event ) = > setSculptMirrorObjectSpace ( event . target . checked ) } / > < / label > < div className = "property-actions" > < button type = "button" onClick = { applySculptStroke } > Apply Sculpt Stroke < / button > < / div > < label > Mask < input aria-label = "Sculpt mask" type = "range" min = "0" max = "1" step = "0.01" value = { sculptMaskValue } onChange = { ( event ) = > setSculptMaskValue ( Number ( event . target . value ) ) } / > < output > { sculptMaskValue . toFixed ( 2 ) } < / output > < / label > < label > Active face set < input aria-label = "Sculpt active face set" type = "number" min = "-1 " step = "1" value = { sculptActiveFaceSet } onChange = { ( event ) = > setSculptActiveFaceSet ( Math . max ( - 1 , Math . floor ( Number ( event . target . value ) || 0 ) ) ) } / > < / label > < div className = "property-actions" > < button type = "button" disabled = { activeMesh . vertexCount <= 0 } onClick = { applySculptMeshAttributes } > Apply Sculpt Attributes < / button > < / div > < output > { activeMesh . vertexCount } vertices · { ( activeMesh as typeof activeMesh & { faceCount? : number } ) . faceCount ? ? 0 } faces < / output > < / div > : null }
{ activeMesh ? < div className = "property-section" data-testid = "sculpt-editor" data-command = "sculptStroke,setSculptMeshAttributes,invokeBlenderOperator " > < h3 > Sculpt < / h3 > < label > Brush < select aria-label = "Sculpt brush" value = { sculptBrush } onChange = { ( event ) = > setSculptBrush ( event . target . value as SculptBrush ) } > < option value = "DRAW" > Draw < / option > < option value = "INFLATE" > Inflate < / option > < option value = "GRAB" > Grab < / option > < option value = "SMOOTH" > Smooth < / option > < / select > < / label > < label > Radius < input aria-label = "Sculpt radius" type = "number" min = "0.001" step = "0.01" value = { sculptRadius } onChange = { ( event ) = > setSculptRadius ( Math . max ( 0.001 , Number ( event . target . value ) || 0.001 ) ) } / > < / label > < label > Strength < input aria-label = "Sculpt strength" type = "range" min = "-1" max = "1" step = "0.01" value = { sculptStrength } onChange = { ( event ) = > setSculptStrength ( Number ( event . target . value ) ) } / > < output > { sculptStrength . toFixed ( 2 ) } < / output > < / label > < label > Pressure < input aria-label = "Sculpt pressure" type = "range" min = "0" max = "1" step = "0.01" value = { sculptPressure } onChange = { ( event ) = > setSculptPressure ( Number ( event . target . value ) ) } / > < output > { sculptPressure . toFixed ( 2 ) } < / output > < / label > < label > Symmetry X / Y / Z < span className = "property-actions" > { ( [ 0 , 1 , 2 ] as const ) . map ( ( axis ) = > < label key = { axis } > < input aria-label = { ` Sculpt symmetry ${ axis === 0 ? "X" : axis === 1 ? "Y" : "Z" } ` } type = "checkbox" checked = { sculptSymmetry [ axis ] } onChange = { ( event ) = > setSculptSymmetry ( ( current ) = > current . map ( ( value , index ) = > index === axis ? event.target.checked : value ) as [ boolean , boolean , boolean ] ) } / > { axis === 0 ? "X" : axis === 1 ? "Y" : "Z" } < / label > ) } < / span > < / label > < label > Mirror object space < input aria-label = "Sculpt mirror object space" type = "checkbox" checked = { sculptMirrorObjectSpace } onChange = { ( event ) = > setSculptMirrorObjectSpace ( event . target . checked ) } / > < / label > < div className = "property-actions" > < button type = "button" onClick = { applySculptStroke } > Apply Sculpt Stroke ( { sculptCursorSamples . length || 3 } samples ) < / button > < / div > < label > Mask < input aria-label = "Sculpt mask" type = "range" min = "0" max = "1" step = "0.01" value = { sculptMaskValue } onChange = { ( event ) = > setSculptMaskValue ( Number ( event . target . value ) ) } / > < output > { sculptMaskValue . toFixed ( 2 ) } < / output > < / label > < label > Active face set < input aria-label = "Sculpt active face set" type = "number" min = "0 " step = "1" value = { sculptActiveFaceSet } onChange = { ( event ) = > setSculptActiveFaceSet ( Math . max ( 0 , Math . floor ( Number ( event . target . value ) || 0 ) ) ) } / > < / label > < div className = "property-actions" > < button type = "button" disabled = { activeMesh . vertexCount <= 0 } onClick = { applySculptMeshAttributes } > Apply Sculpt Attributes < / button > < / div > < label > Remesh voxel size < input aria-label = "Sculpt remesh voxel size" type = "number" min = "0.001" step = "0.001" value = { sculptRemeshVoxelSize } onChange = { ( event ) = > setSculptRemeshVoxelSize ( Math . max ( 0.001 , Number ( event . target . value ) || 0.001 ) ) } / > < / label > < div className = "property-actions" > < button type = "button" disabled = { ! client } onClick = { applySculptRemesh } > Voxel Remesh < / button > < / div > < output> { activeMesh . vertexCount } vertices · { ( activeMesh as typeof activeMesh & { faceCount? : number } ) . faceCount ? ? 0 } faces < / output > < / div > : null }
{ activeMesh ? < div className = "property-section" >
< h3 > Material Slots < / h3 >
< div className = "material-slots" > { activeMesh . materialSlotIds ? . map ( ( id , index ) = > < span key = { ` ${ id } : ${ index } ` } > { index + 1 } . { snapshot ? . materials . find ( ( material ) = > material . id === id ) ? . name ? ? "Empty" } < / span > ) } < / div >
@@ -1164,8 +1447,41 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
< label > 法 线 < select aria-label = "材质法线图片" value = "" onChange = { ( event ) = > event . target . value && onCommand ( { type : "setMaterialImageNode" , materialId : activeMaterial.id , imageId : event.target.value , usage : "NORMAL" , uvMap : activeMesh.activeUVMap ? ? undefined } ) } > < option value = "" > 选 择 Normal . . . < / option > { snapshot ? . images . map ( ( image ) = > < option key = { image . id } value = { image . id } > { image . name } < / option > ) } < / select > < / label >
< / > : null }
< / div > : null }
{ activeMesh && geometryGraphDraft ? ( ( ) = > {
const outputs = geometryGraphDraft . nodes . flatMap ( ( node ) = > node . sockets . filter ( ( socket ) = > socket . direction === "OUTPUT" ) . map ( ( socket ) = > ( { key : ` ${ node . id } : ${ socket . id } ` , label : ` ${ node . name } · ${ socket . name } ` , nodeId : node.id , socketId : socket.id } ) ) ) ;
const inputs = geometryGraphDraft . nodes . flatMap ( ( node ) = > node . sockets . filter ( ( socket ) = > socket . direction === "INPUT" ) . map ( ( socket ) = > ( { key : ` ${ node . id } : ${ socket . id } ` , label : ` ${ node . name } · ${ socket . name } ` , nodeId : node.id , socketId : socket.id } ) ) ) ;
return < div className = "property-section node-graph-authoring" data-testid = "geometry-node-authoring" >
< h3 > Geometry Node Graph < / h3 >
< div className = "node-graph-toolbar" > < label > 节 点 类 型 < select value = { geometryNodeType } onChange = { ( event ) = > setGeometryNodeType ( event . target . value ) } > { GEOMETRY_NODE_ALLOWLIST . map ( ( type ) = > < option key = { type } value = { type } > { type } < / option > ) } < / select > < / label > < label > 名 称 < input value = { geometryNodeName } onChange = { ( event ) = > setGeometryNodeName ( event . target . value ) } / > < / label > < button type = "button" onClick = { ( ) = > setGeometryGraphDraft ( geometryNodeCommands . addNode ( geometryGraphDraft , geometryNodeType , geometryNodeName ) ) } > 添 加 节 点 < / button > < / div >
< div className = "node-graph-link-toolbar" > < label > 输 出 < select value = { geometryLinkFrom } onChange = { ( event ) = > setGeometryLinkFrom ( event . target . value ) } > < option value = "" > 选 择 输 出 … < / option > { outputs . map ( ( item ) = > < option key = { item . key } value = { item . key } > { item . label } < / option > ) } < / select > < / label > < label > 输 入 < select value = { geometryLinkTo } onChange = { ( event ) = > setGeometryLinkTo ( event . target . value ) } > < option value = "" > 选 择 输 入 … < / option > { inputs . map ( ( item ) = > < option key = { item . key } value = { item . key } > { item . label } < / option > ) } < / select > < / label > < button type = "button" disabled = { ! parseGraphSocket ( geometryLinkFrom ) || ! parseGraphSocket ( geometryLinkTo ) } onClick = { ( ) = > { const from = parseGraphSocket ( geometryLinkFrom ) ; const to = parseGraphSocket ( geometryLinkTo ) ; if ( from && to ) setGeometryGraphDraft ( geometryNodeCommands . connect ( geometryGraphDraft , { fromNodeId : from.nodeId , fromSocketId : from.socketId , toNodeId : to.nodeId , toSocketId : to.socketId } ) ) ; } } > 连 接 < / button > < / div >
< div className = "node-graph-list" > { geometryGraphDraft . nodes . map ( ( node ) = > < article className = "node-graph-node" key = { node . id } > < header > < strong > { node . name } < / strong > < small > { node . type } < / small > < button type = "button" disabled = { node . type === "NodeGroupInput" || node . type === "NodeGroupOutput" } onClick = { ( ) = > setGeometryGraphDraft ( geometryNodeCommands . removeNode ( geometryGraphDraft , node . id ) ) } > 移 除 < / button > < / header > { node . sockets . filter ( ( socket ) = > socket . direction === "INPUT" ) . map ( ( socket ) = > < label key = { socket . id } > { socket . name } { socket . defaultValue !== undefined ? < input type = { typeof socket . defaultValue === "boolean" ? "checkbox" : "text" } checked = { typeof socket . defaultValue === "boolean" ? socket.defaultValue : undefined } value = { typeof socket . defaultValue === "boolean" ? undefined : Array . isArray ( socket . defaultValue ) ? socket . defaultValue . join ( "," ) : String ( socket . defaultValue ) } onChange = { ( event ) = > { const raw = typeof socket . defaultValue === "boolean" ? event.target.checked : event.target.value ; const value = Array . isArray ( socket . defaultValue ) ? raw . toString ( ) . split ( "," ) . map ( ( item ) = > Number ( item . trim ( ) ) ) . filter ( ( item ) = > Number . isFinite ( item ) ) : typeof socket . defaultValue === "number" ? Number ( raw ) : raw ; setGeometryGraphDraft ( geometryNodeCommands . setSocketValue ( geometryGraphDraft , node . id , socket . id , value ) ) ; } } / > : < span className = "node-graph-socket-empty" > linked < / span > } < / label > ) } < / article > ) } < / div >
< div className = "node-graph-links" > { geometryGraphDraft . links . map ( ( link , index ) = > < div key = { ` ${ link . fromNodeId } : ${ link . fromSocketId } : ${ link . toNodeId } : ${ link . toSocketId } : ${ index } ` } > < span > { link . fromNodeId } : { link . fromSocketId } → { link . toNodeId } : { link . toSocketId } < / span > < button type = "button" onClick = { ( ) = > setGeometryGraphDraft ( geometryNodeCommands . disconnect ( geometryGraphDraft , link ) ) } > 断 开 < / button > < / div > ) } < / div >
< button type = "button" onClick = { commitGeometryGraph } > 提 交 Geometry 图 < / button >
< / div > ;
} ) ( ) : null }
{ activeMaterial && shaderGraphDraft ? ( ( ) = > {
const outputs = shaderGraphDraft . nodes . flatMap ( ( node ) = > node . sockets . filter ( ( socket ) = > socket . direction === "OUTPUT" ) . map ( ( socket ) = > ( { key : ` ${ node . id } : ${ socket . id } ` , label : ` ${ node . name } · ${ socket . name } ` } ) ) ) ;
const inputs = shaderGraphDraft . nodes . flatMap ( ( node ) = > node . sockets . filter ( ( socket ) = > socket . direction === "INPUT" ) . map ( ( socket ) = > ( { key : ` ${ node . id } : ${ socket . id } ` , label : ` ${ node . name } · ${ socket . name } ` } ) ) ) ;
const renderValue = ( nodeId : string , socket : ShaderSocketIR ) : React . ReactNode = > {
if ( socket . defaultValue === undefined ) return < span className = "node-graph-socket-empty" > linked < / span > ;
return < input type = { typeof socket . defaultValue === "boolean" ? "checkbox" : "text" } checked = { typeof socket . defaultValue === "boolean" ? socket.defaultValue : undefined } value = { typeof socket . defaultValue === "boolean" ? undefined : Array . isArray ( socket . defaultValue ) ? socket . defaultValue . join ( "," ) : String ( socket . defaultValue ) } onChange = { ( event ) = > { const raw = typeof socket . defaultValue === "boolean" ? event.target.checked : event.target.value ; const value = Array . isArray ( socket . defaultValue ) ? raw . toString ( ) . split ( "," ) . map ( ( item ) = > Number ( item . trim ( ) ) ) . filter ( ( item ) = > Number . isFinite ( item ) ) : typeof socket . defaultValue === "number" ? Number ( raw ) : raw ; setShaderGraphDraft ( shaderGraphCommands . setSocketValue ( shaderGraphDraft , nodeId , socket . id , value ) ) ; } } / > ;
} ;
return < div className = "property-section node-graph-authoring" data-testid = "shader-graph-authoring" >
< h3 > Shader Graph < / h3 >
< div className = "node-graph-toolbar" > < label > 节 点 类 型 < select value = { shaderNodeType } onChange = { ( event ) = > setShaderNodeType ( event . target . value as typeof shaderNodeType ) } > { ( [ "RGB" , "VALUE" , "MATH" , "IMAGE_TEXTURE" , "PRINCIPLED" , "MATERIAL_OUTPUT" ] as const ) . map ( ( type ) = > < option key = { type } value = { type } > { type } < / option > ) } < / select > < / label > < label > 名 称 < input value = { shaderNodeName } onChange = { ( event ) = > setShaderNodeName ( event . target . value ) } / > < / label > < button type = "button" disabled = { shaderNodeType === "MATERIAL_OUTPUT" && shaderGraphDraft . nodes . some ( ( node ) = > node . type === "MATERIAL_OUTPUT" ) } onClick = { ( ) = > setShaderGraphDraft ( shaderGraphCommands . addNode ( shaderGraphDraft , shaderNodeType , shaderNodeName ) ) } > 添 加 节 点 < / button > < / div >
< div className = "node-graph-link-toolbar" > < label > 输 出 < select value = { shaderLinkFrom } onChange = { ( event ) = > setShaderLinkFrom ( event . target . value ) } > < option value = "" > 选 择 输 出 … < / option > { outputs . map ( ( item ) = > < option key = { item . key } value = { item . key } > { item . label } < / option > ) } < / select > < / label > < label > 输 入 < select value = { shaderLinkTo } onChange = { ( event ) = > setShaderLinkTo ( event . target . value ) } > < option value = "" > 选 择 输 入 … < / option > { inputs . map ( ( item ) = > < option key = { item . key } value = { item . key } > { item . label } < / option > ) } < / select > < / label > < button type = "button" disabled = { ! parseGraphSocket ( shaderLinkFrom ) || ! parseGraphSocket ( shaderLinkTo ) } onClick = { ( ) = > { const from = parseGraphSocket ( shaderLinkFrom ) ; const to = parseGraphSocket ( shaderLinkTo ) ; if ( from && to ) setShaderGraphDraft ( shaderGraphCommands . connect ( shaderGraphDraft , { fromNodeId : from.nodeId , fromSocketId : from.socketId , toNodeId : to.nodeId , toSocketId : to.socketId } ) ) ; } } > 连 接 < / button > < / div >
< div className = "node-graph-list" > { shaderGraphDraft . nodes . map ( ( node ) = > < article className = "node-graph-node" key = { node . id } > < header > < strong > { node . name } < / strong > < small > { node . type } < / small > < button type = "button" disabled = { node . type === "MATERIAL_OUTPUT" } onClick = { ( ) = > setShaderGraphDraft ( shaderGraphCommands . removeNode ( shaderGraphDraft , node . id ) ) } > 移 除 < / button > < / header > { node . sockets . filter ( ( socket ) = > socket . direction === "INPUT" ) . map ( ( socket ) = > < label key = { socket . id } > { socket . name } { renderValue ( node . id , socket ) } < / label > ) } < / article > ) } < / div >
< div className = "node-graph-links" > { shaderGraphDraft . links . map ( ( link , index ) = > < div key = { ` ${ link . fromNodeId } : ${ link . fromSocketId } : ${ link . toNodeId } : ${ link . toSocketId } : ${ index } ` } > < span > { link . fromNodeId } : { link . fromSocketId } → { link . toNodeId } : { link . toSocketId } < / span > < button type = "button" onClick = { ( ) = > setShaderGraphDraft ( shaderGraphCommands . disconnect ( shaderGraphDraft , link ) ) } > 断 开 < / button > < / div > ) } < / div >
< button type = "button" onClick = { commitShaderGraph } > 提 交 Shader 图 < / button >
< / div > ;
} ) ( ) : null }
{ activeNode ? . constraints ? . length ? < div className = "property-section" > < h3 > Constraints < / h3 > { activeNode . constraints . map ( ( constraint ) = > < label key = { constraint . name } > { constraint . name } < input type = "range" min = "0" max = "1" step = "0.05" value = { constraint . influence } onChange = { ( event ) = > onCommand ( { type : "setConstraint" , objectId : activeNode.id , constraintName : constraint.name , enabled : constraint.enabled , influence : Number ( event . target . value ) } ) } / > < input aria-label = { ` 启用约束 ${ constraint . name } ` } type = "checkbox" checked = { constraint . enabled } onChange = { ( event ) = > onCommand ( { type : "setConstraint" , objectId : activeNode.id , constraintName : constraint.name , enabled : event.target.checked , influence : constraint.influence } ) } / > < / label > ) } < / div > : null }
{ activeMesh ? . modifierStack ? . length ? < div className = "property-section" > < h3 > Modifier Stack < / h3 > { activeMesh . modifierStack . map ( ( modifier ) = > < div className = "modifier-row" key = { modifier . uuid } > < span > { modifier . name } < / span > { ( [ "showViewport" , "showRender" , "showEditMode" , "showOnCage" ] as const ) . map ( ( field , index ) = > < label key = { field } > { [ "V" , "R" , "E" , "C" ] [ index ] } < input type = "checkbox" checked = { Boolean ( modifier [ field ] ) } onChange = { ( event ) = > { if ( field === "showViewport" ) onSetModifierEnabled ( activeMesh . id , modifier . uuid , event . target . checked ) ; else onCommand ( { type : "setModifierVisibility" , meshId : activeMesh.id , modifierUuid : modifier.uuid , showViewport : modifier.showViewport , showRender : modifier.showRender , showEditMode : Boolean ( modifier . showEditMode ) , showOnCage : Boolean ( modifier . showOnCage ) , [ field ] : event . target . checked } ) ; } } aria-label = { ` ${ [ "Viewport" , "Render" , "Edit" , "Cage" ] [ index ] } ${ modifier . name } ` } / > < / label > ) } < / div > ) } < / div > : null }
{ activeMesh ? ( ( ) = > {
const meshMetadata = activeMesh as typeof activeMesh & { modifierMetadata ? : { types? : string [ ] } } ;
const availableTypes = meshMetadata . modifierMetadata ? . types ? . filter ( Boolean ) ? ? Array . from ( new Set ( ( activeMesh . modifierStack ? ? [ ] ) . map ( ( modifier ) = > modifier . type ) . filter ( Boolean ) ) ) ;
const firstType = availableTypes [ 0 ] ? ? "" ;
return < div className = "property-section modifier-stack-authoring" > < div className = "modifier-stack-header" > < h3 > Modifier Stack < / h3 > < div className = "modifier-authoring-controls" > < input aria-label = "Modifier type" defaultValue = { firstType } list = "modifier-type-metadata" data - modifier - type - input placeholder = "Modifier type" / > < datalist id = "modifier-type-metadata" > { availableTypes . map ( ( type ) = > < option key = { type } value = { type } / > ) } < / datalist > < button type = "button" onClick = { ( event ) = > { const input = event . currentTarget . parentElement ? . querySelector < HTMLInputElement > ( "[data-modifier-type-input]" ) ; const modifierType = input ? . value . trim ( ) || firstType ; if ( modifierType ) onCommand ( { type : "addModifier" , meshId : activeMesh.id , modifierType } ) ; } } > Add < / button > < / div > < / div > { activeMesh . modifierStack ? . map ( ( modifier , index ) = > < div className = "modifier-row" key = { modifier . uuid } > < div className = "modifier-row-heading" > < span > { modifier . name } < / span > < span className = "modifier-type" > { modifier . type } < / span > < div className = "modifier-order-controls" > < button type = "button" aria-label = { ` Move ${ modifier . name } up ` } disabled = { index === 0 } onClick = { ( ) = > onCommand ( { type : "moveModifier" , meshId : activeMesh.id , modifierUuid : modifier.uuid , direction : "UP" } ) } > ↑ < / button > < button type = "button" aria-label = { ` Move ${ modifier . name } down ` } disabled = { index === ( activeMesh . modifierStack ? . length ? ? 1 ) - 1 } onClick = { ( ) = > onCommand ( { type : "moveModifier" , meshId : activeMesh.id , modifierUuid : modifier.uuid , direction : "DOWN" } ) } > ↓ < / button > < button type = "button" aria-label = { ` Remove ${ modifier . name } ` } onClick = { ( ) = > onCommand ( { type : "removeModifier" , meshId : activeMesh.id , modifierUuid : modifier.uuid } ) } > Remove < / button > < / div > < / div > < div className = "modifier-visibility-controls" > { ( [ "showViewport" , "showRender" , "showEditMode" , "showOnCage" ] as const ) . map ( ( field , fieldIndex ) = > < label key = { field } > { [ "V" , "R" , "E" , "C" ] [ fieldIndex ] } < input type = "checkbox" checked = { Boolean ( modifier [ field ] ) } onChange = { ( event ) = > { if ( field === "showViewport" ) onSetModifierEnabled ( activeMesh . id , modifier . uuid , event . target . checked ) ; else onCommand ( { type : "setModifierVisibility" , meshId : activeMesh.id , modifierUuid : modifier.uuid , showViewport : modifier.showViewport , showRender : modifier.showRender , showEditMode : Boolean ( modifier . showEditMode ) , showOnCage : Boolean ( modifier . showOnCage ) , [ field ] : event . target . checked } ) ; } } aria-label = { ` ${ [ "Viewport" , "Render" , "Edit" , "Cage" ] [ fieldIndex ] } ${ modifier . name } ` } / > < / label > ) } < / div > < div className = "modifier-parameter-controls" > { modifierParameterMetadata ( modifier ) . map ( ( field ) = > < label key = { field . key } > { field . key } { field . kind === "boolean" ? < input type = "checkbox" checked = { Boolean ( field . value ) } onChange = { ( event ) = > onCommand ( { type : "setModifierParameters" , meshId : activeMesh.id , modifierUuid : modifier.uuid , parameters : { . . . ( modifier . parameters ? ? { } ) , [ field . key ] : event . target . checked } } ) } / > : field . kind === "number" ? < input type = "number" value = { Number ( field . value ) } onChange = { ( event ) = > onCommand ( { type : "setModifierParameters" , meshId : activeMesh.id , modifierUuid : modifier.uuid , parameters : { . . . ( modifier . parameters ? ? { } ) , [ field . key ] : Number ( event . target . value ) } } ) } / > : < input type = "text" value = { String ( field . value ? ? "" ) } onChange = { ( event ) = > onCommand ( { type : "setModifierParameters" , meshId : activeMesh.id , modifierUuid : modifier.uuid , parameters : { . . . ( modifier . parameters ? ? { } ) , [ field . key ] : event . target . value } } ) } / > } < / label > ) } < / div > < / div > ) } < / div > ;
} ) ( ) : null }
< div className = "property-section modifier-panel" >
< h3 > Decimate < / h3 >
< label > 模 式 < select aria-label = "Decimate 模式" value = { mode } onChange = { ( event ) = > setMode ( event . target . value as SimplifyMode ) } > < option value = "COLLAPSE" > Collapse < / option > < option value = "UNSUBDIV" > Un - Subdivide < / option > < option value = "DISSOLVE_PLANAR" > Dissolve Planar < / option > < / select > < / label >
@@ -1245,11 +1561,90 @@ function OperatorSearch({ commands, onClose }: { commands: readonly OperatorComm
) ;
}
function SequencerAuthoringPanel ( { scene , revision , frame , onCommand } : { scene : SceneSnapshotIR [ "scenes" ] [ number ] ; revision : number ; frame : number ; onCommand : ( command : WebEngineEditCommand ) = > void } ) {
const timeline = scene . sequencerTimeline ? ? {
schemaVersion : 1 ,
id : ` sequencer: ${ scene . id } ` ,
revision ,
frameStart : 1 ,
frameEnd : 250 ,
fpsNumerator : 24000 ,
fpsDenominator : 1000 ,
strips : [ ] ,
} satisfies SequencerTimelineIR ;
const [ selectedId , setSelectedId ] = useState < string | null > ( null ) ;
const [ newType , setNewType ] = useState < Exclude < SequencerStripType , "EFFECT" > > ( "MOVIE" ) ;
const strips = timeline . strips ;
const selected = strips . find ( ( strip ) = > strip . id === selectedId ) ? ? strips [ 0 ] ? ? null ;
useEffect ( ( ) = > {
if ( selected ? . id !== selectedId ) setSelectedId ( selected ? . id ? ? null ) ;
} , [ selected ? . id , selectedId ] ) ;
const commit = ( nextStrips : SequencerStripIR [ ] ) : void = > {
onCommand ( { type : "setSequencerStrips" , sceneId : scene.id , timeline : { . . . timeline , revision , strips : nextStrips } } ) ;
} ;
const addStrip = ( ) : void = > {
const id = ` strip:web- ${ Date . now ( ) . toString ( 36 ) } ` ;
const duration = 24 ;
const strip : SequencerStripIR = {
id ,
name : ` Web ${ newType } ` ,
type : newType ,
channel : 1 ,
frameStart : frame ,
frameEnd : frame + duration ,
sourceStart : 0 ,
sourceEnd : duration ,
speed : 1 ,
muted : false ,
locked : false ,
. . . ( newType === "SCENE" ? { sourceId : scene.id } : { } ) ,
. . . ( newType === "META" ? { childStripIds : [ ] } : { } ) ,
. . . ( newType === "MOVIE" ? { sourcePath : ` //web-proxy/ ${ id . slice ( 6 ) } .mp4 ` , mimeType : "video/mp4" } : { } ) ,
. . . ( newType === "SOUND" ? { sourcePath : ` //web-proxy/ ${ id . slice ( 6 ) } .wav ` , mimeType : "audio/wav" } : { } ) ,
. . . ( newType === "IMAGE" ? { sourcePath : ` //web-proxy/ ${ id . slice ( 6 ) } .png ` , mimeType : "image/png" } : { } ) ,
} ;
commit ( [ . . . strips , strip ] ) ;
setSelectedId ( id ) ;
} ;
const removeSelected = ( ) : void = > {
if ( ! selected ) return ;
const next = strips . filter ( ( strip ) = > strip . id !== selected . id ) . map ( ( strip ) = > ( {
. . . strip ,
inputStripIds : strip.inputStripIds?.filter ( ( id ) = > id !== selected . id ) ,
childStripIds : strip.childStripIds?.filter ( ( id ) = > id !== selected . id ) ,
} ) ) ;
commit ( next ) ;
setSelectedId ( null ) ;
} ;
const updateSelected = ( patch : Partial < SequencerStripIR > ) : void = > {
if ( ! selected ) return ;
commit ( strips . map ( ( strip ) = > strip . id === selected . id ? { . . . strip , . . . patch } : strip ) ) ;
} ;
const moveSelected = ( frameDelta : number , channelDelta = 0 ) : void = > {
if ( ! selected ) return ;
updateSelected ( { frameStart : selected.frameStart + frameDelta , frameEnd : selected.frameEnd + frameDelta , channel : Math.max ( 1 , selected . channel + channelDelta ) } ) ;
} ;
const splitSelected = ( ) : void = > {
if ( ! selected || selected . frameEnd - selected . frameStart < 2 ) return ;
const splitFrame = selected . frameStart + Math . floor ( ( selected . frameEnd - selected . frameStart ) / 2 ) ;
const sourceSplit = selected . sourceStart + Math . round ( ( splitFrame - selected . frameStart ) * selected . speed ) ;
const rightId = ` ${ selected . id } :right ` ;
if ( strips . some ( ( strip ) = > strip . id === rightId ) ) return ;
const left = { . . . selected , frameEnd : splitFrame , sourceEnd : sourceSplit } ;
const right = { . . . selected , id : rightId , name : ` ${ selected . name } Right ` , frameStart : splitFrame , sourceStart : sourceSplit } ;
commit ( strips . flatMap ( ( strip ) = > strip . id === selected . id ? [ left , right ] : [ strip ] ) ) ;
setSelectedId ( rightId ) ;
} ;
return < section className = "sequencer-authoring" aria-label = "Sequencer Strip Editing" > < div className = "sequencer-toolbar" > < select aria-label = "New sequencer strip type" value = { newType } onChange = { ( event ) = > setNewType ( event . target . value as Exclude < SequencerStripType , "EFFECT" > ) } > < option value = "SCENE" > Scene < / option > < option value = "MOVIE" > Movie < / option > < option value = "SOUND" > Sound < / option > < option value = "IMAGE" > Image < / option > < option value = "META" > Meta < / option > < / select > < button type = "button" onClick = { addStrip } > Add Strip < / button > < button type = "button" disabled = { ! selected } onClick = { removeSelected } > Remove < / button > < / div > < label > Strip < select aria-label = "Sequencer strip" value = { selected ? . id ? ? "" } onChange = { ( event ) = > setSelectedId ( event . target . value || null ) } > < option value = "" > Select Strip … < / option > { strips . map ( ( strip ) = > < option key = { strip . id } value = { strip . id } > { strip . name } · { strip . type } < / option > ) } < / select > < / label > { selected ? < div className = "sequencer-strip-editor" > < output > { selected . id } · { selected . type } < / output > < label > Start < input type = "number" value = { selected . frameStart } onChange = { ( event ) = > updateSelected ( { frameStart : Number ( event . target . value ) } ) } / > < / label > < label > End < input type = "number" value = { selected . frameEnd } onChange = { ( event ) = > updateSelected ( { frameEnd : Number ( event . target . value ) } ) } / > < / label > < label > Channel < input type = "number" min = { 1 } max = { 128 } value = { selected . channel } onChange = { ( event ) = > updateSelected ( { channel : Math.max ( 1 , Math . min ( 128 , Number ( event . target . value ) ) ) } ) } / > < / label > < label > Muted < input type = "checkbox" checked = { selected . muted } onChange = { ( event ) = > updateSelected ( { muted : event.target.checked } ) } / > < / label > < div className = "property-actions" > < button type = "button" onClick = { ( ) = > moveSelected ( - 1 ) } > Move − 1 < / button > < button type = "button" onClick = { ( ) = > moveSelected ( 1 ) } > Move + 1 < / button > < button type = "button" onClick = { ( ) = > moveSelected ( 0 , - 1 ) } > Channel − 1 < / button > < button type = "button" onClick = { ( ) = > moveSelected ( 0 , 1 ) } > Channel + 1 < / button > < button type = "button" onClick = { ( ) = > updateSelected ( { frameStart : selected.frameStart + 1 , sourceStart : selected.sourceStart + Math . max ( 1 , Math . round ( selected . speed ) ) } ) } disabled = { selected . frameStart + 1 >= selected . frameEnd } > Trim start < / button > < button type = "button" onClick = { ( ) = > updateSelected ( { frameEnd : selected.frameEnd - 1 , sourceEnd : Math.max ( selected . sourceStart + 1 , selected . sourceEnd - Math . max ( 1 , Math . round ( selected . speed ) ) ) } ) } disabled = { selected . frameEnd - 1 <= selected . frameStart } > Trim end < / button > < button type = "button" onClick = { splitSelected } > Split < / button > < / div > < / div > : < output > { scene . sequencerStatus === "AVAILABLE" ? "No strips" : "Sequencer timeline will be created in Main" } < / output > } < / section > ;
}
function Timeline ( { snapshot , frame , start , end , onFrameChange , onCommand } : { snapshot : SceneSnapshotIR | null ; frame : number ; start : number ; end : number ; onFrameChange : ( value : number ) = > void ; onCommand : ( command : WebEngineEditCommand ) = > void } ) {
const [ playing , setPlaying ] = useState ( false ) ;
const [ selectedNlaTrackId , setSelectedNlaTrackId ] = useState < string | null > ( null ) ;
const [ selectedNlaStripId , setSelectedNlaStripId ] = useState < string | null > ( null ) ;
const [ nlaFrameStartDraft , setNlaFrameStartDraft ] = useState ( "" ) ;
const [ actionNameDraft , setActionNameDraft ] = useState ( "" ) ;
const [ markerNameDraft , setMarkerNameDraft ] = useState ( "" ) ;
useEffect ( ( ) = > {
if ( ! playing ) return undefined ;
const timer = window . setInterval ( ( ) = > {
@@ -1267,6 +1662,8 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
const fourth = Math . round ( start + ( end - start ) * 0.8 ) ;
const animation = snapshot ? . animations . find ( ( candidate ) = > candidate . targetId === snapshot . activeObjectId ) ;
const actions = snapshot ? . animations . filter ( ( candidate ) = > candidate . targetId === snapshot . activeObjectId ) ? ? [ ] ;
const activeScene = snapshot ? . scenes ? . [ 0 ] ;
const markers = activeScene ? . timelineMarkers ? ? [ ] ;
const nlaTracks = snapshot ? . nlaTracks ? . filter ( ( track ) = > track . ownerId === snapshot . activeObjectId ) ? ? [ ] ;
const selectedNlaTrack = nlaTracks . find ( ( track ) = > track . id === selectedNlaTrackId ) ? ? null ;
const selectedNlaStrip = selectedNlaTrack ? . strips . find ( ( strip ) = > strip . id === selectedNlaStripId ) ? ? null ;
@@ -1299,12 +1696,16 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
: animation ? . channels . flatMap ( ( channel ) = > channel . keyframes . map ( ( keyframe ) = > keyframe . frame ) ) ? ? [ ] ) ]
. filter ( ( keyframe ) = > keyframe >= start && keyframe <= end )
. sort ( ( left , right ) = > left - right ) ;
const activeKeyframe = animation ? . channels [ 0 ] ? . keyframes . find ( ( keyframe ) = > keyframe . frame === frame ) ;
return (
< div className = "timeline-content" >
< div className = "timeline-controls" > < button type = "button" aria-label = "跳到第一帧" onClick = { ( ) = > { setPlaying ( false ) ; onFrameChange ( start ) ; } } > | ◀ < / button > < button type = "button" aria-label = "上一帧" onClick = { ( ) = > onFrameChange ( Math . max ( start , frame - 1 ) ) } > ◀ < / button > < button type = "button" aria-label = "播放" onClick = { ( ) = > setPlaying ( ( current ) = > ! current ) } > { playing ? "Ⅱ" : "▶" } < / button > < button type = "button" aria-label = "下一帧" onClick = { ( ) = > onFrameChange ( Math . min ( end , frame + 1 ) ) } > ▶ | < / button > < button type = "button" aria-label = "跳到最后一帧" onClick = { ( ) = > { setPlaying ( false ) ; onFrameChange ( end ) ; } } > ▶ | < / button > < output className = "frame-number" > { frame } < / output > { snapshot ? . activeObjectId ? < > < button type = "button" aria-label = "插入位置关键帧" onClick = { ( ) = > onCommand ( animationRenderCommands . insertKeyframe ( snapshot . activeObjectId ! , frame , "LOCATION" ) ) } > Loc < / button > < button type = "button" aria-label = "插入旋转关键帧" onClick = { ( ) = > onCommand ( animationRenderCommands . insertKeyframe ( snapshot . activeObjectId ! , frame , "ROTATION_EULER" ) ) } > Rot < / button > < button type = "button" aria-label = "插入缩放关键帧" onClick = { ( ) = > onCommand ( animationRenderCommands . insertKeyframe ( snapshot . activeObjectId ! , frame , "SCALE" ) ) } > Scale < / button > < button type = "button" aria-label = "删除当前关键帧" onClick = { ( ) = > onCommand ( animationRenderCommands . deleteKeyframe ( snapshot . activeObjectId ! , frame ) ) } > Del Key < / button > < / > : null } < / div >
< input className = "frame-slider" type = "range" min = { start } max = { end } value = { Math . min ( end , Math . max ( start , frame ) ) } onChange = { ( event ) = > onFrameChange ( Number ( event . target . value ) ) } aria-label = "当前帧" / >
< div className = "timeline-scale" > < span > { start } < / span > < span > { mid } < / span > < span > { second } < / span > < span > { third } < / span > < span > { fourth } < / span > < span > { end } < / span > < / div >
< div className = "dope-sheet" aria-label = "Dope Sheet" > < span className = "channel-name" > { greasePencil ? . name ? ? animation ? . name ? ? "No Action" } < / span > < div className = "key-track" > { keyframes . map ( ( keyframe ) = > < button key = { keyframe } type = "button" className = { keyframe === frame ? "key-dot active" : "key-dot" } style = { { left : ` ${ ( ( keyframe - start ) / Math . max ( 1 , end - start ) ) * 100 } % ` } } aria-label = { ` ${ greasePencil ? "Grease Pencil 帧" : "关键帧" } ${ keyframe } ` } onClick = { ( ) = > onFrameChange ( keyframe ) } / > ) } < / div > { ! greasePencil && animation ? . channels [ 0 ] ? < select aria-label = "FCurve 插值" value = { animation . channels [ 0 ] . interpolation ? ? "BEZIER" } onChange = { ( event ) = > onCommand ( { type : "setFCurveInterpolation" , animationId : animation.id , path : animation.channels [ 0 ] . path , interpolation : event.target.value as "CONSTANT" | "LINEAR" | "BEZIER" } ) } > < option value = "CONSTANT" > Constant < / option > < option value = "LINEAR" > Linear < / option > < option value = "BEZIER" > Bezier < / option > < / select > : < span / > } { snapshot ? . activeObjectId && actions . length > 0 ? < select aria-label = "活动 Action" defaultValue = "" onChange = { ( event ) = > onCommand ( animationRenderCommands . setActiveAction ( snapshot . activeObjectId ! , event . target . value || null ) ) } > < option value = "" > 选 择 Action . . . < / option > { actions . map ( ( action ) = > < option key = { action . id } value = { action . id } > { action . name } < / option > ) } < / select > : null } { snapshot ? . activeObjectId && nlaTracks . length > 0 ? < button type = "button" onClick = { ( ) = > onCommand ( animationRenderCommands . setNLAStack ( snapshot . activeObjectId ! , nlaTracks , snapshot . revision ) ) } > 提 交 NLA < / button > : null } < / div >
< div className = "dope-sheet" aria-label = "Dope Sheet" > < span className = "channel-name" > { greasePencil ? . name ? ? animation ? . name ? ? "No Action" } < / span > < div className = "key-track" > { keyframes . map ( ( keyframe ) = > < button key = { keyframe } type = "button" className = { keyframe === frame ? "key-dot active" : "key-dot" } style = { { left : ` ${ ( ( keyframe - start ) / Math . max ( 1 , end - start ) ) * 100 } % ` } } aria-label = { ` ${ greasePencil ? "Grease Pencil 帧" : "关键帧" } ${ keyframe } ` } onClick = { ( ) = > onFrameChange ( keyframe ) } / > ) } < / div > { ! greasePencil && animation ? . channels [ 0 ] ? < select aria-label = "FCurve 插值" value = { animation . channels [ 0 ] . interpolation ? ? "BEZIER" } onChange = { ( event ) = > onCommand ( { type : "setFCurveInterpolation" , animationId : animation.id , path : animation.channels [ 0 ] . path , interpolation : event.target.value as "CONSTANT" | "LINEAR" | "BEZIER" } ) } > < option value = "CONSTANT" > Constant < / option > < option value = "LINEAR" > Linear < / option > < option value = "BEZIER" > Bezier < / option > < / select > : < span / > } { snapshot ? . activeObjectId ? < > < input aria-label = "Action name" value = { actionNameDraft } placeholder = "Action name" onChange = { ( event ) = > setActionNameDraft ( event . target . value ) } / > < button type = "button" onClick = { ( ) = > { const name = actionNameDraft . trim ( ) ; if ( ! name ) return ; onCommand ( animationRenderCommands . createAction ( name , snapshot . activeObjectId ! ) ) ; setActionNameDraft ( "" ) ; } } > New Action < / button > { animation ? < button type = "button" onClick = { ( ) = > onCommand ( animationRenderCommands . removeAction ( animation . id ) ) } > Remove Action < / button > : null } { actions . length > 0 ? < select aria-label = "活动 Action" defaultValue = "" onChange = { ( event ) = > onCommand ( animationRenderCommands . setActiveAction ( snapshot . activeObjectId ! , event . target . value || null ) ) } > < option value = "" > 选 择 Action . . . < / option > { actions . map ( ( action ) = > < option key = { action . id } value = { action . id } > { action . name } < / option > ) } < / select > : null } < / > : null } { snapshot ? . activeObjectId && nlaTracks . length > 0 ? < button type = "button" onClick = { ( ) = > onCommand ( animationRenderCommands . setNLAStack ( snapshot . activeObjectId ! , nlaTracks , snapshot . revision ) ) } > 提 交 NLA < / button > : null } < / div >
{ ! greasePencil && animation ? . channels [ 0 ] ? < div className = "keyframe-editor" aria-label = "Keyframe Editor" > < label > Frame < input aria-label = "Keyframe frame" type = "number" value = { frame } onChange = { ( event ) = > onFrameChange ( Number ( event . target . value ) ) } / > < / label > < label > Value < input aria-label = "Keyframe value" type = "number" value = { activeKeyframe ? . value [ 0 ] ? ? 0 } onChange = { ( event ) = > onCommand ( animationRenderCommands . setKeyframe ( animation . id , animation . channels [ 0 ] . path , frame , { value : [ Number ( event . target . value ) ] } ) ) } / > < / label > < label > Left X < input aria-label = "Keyframe left handle frame" type = "number" value = { activeKeyframe ? . handleLeft ? . [ 0 ] ? ? frame } onChange = { ( event ) = > onCommand ( animationRenderCommands . setKeyframe ( animation . id , animation . channels [ 0 ] . path , frame , { handleLeft : [ Number ( event . target . value ) , activeKeyframe ? . handleLeft ? . [ 1 ] ? ? activeKeyframe ? . value [ 0 ] ? ? 0 ] } ) ) } / > < / label > < label > Left Y < input aria-label = "Keyframe left handle value" type = "number" value = { activeKeyframe ? . handleLeft ? . [ 1 ] ? ? activeKeyframe ? . value [ 0 ] ? ? 0 } onChange = { ( event ) = > onCommand ( animationRenderCommands . setKeyframe ( animation . id , animation . channels [ 0 ] . path , frame , { handleLeft : [ activeKeyframe ? . handleLeft ? . [ 0 ] ? ? frame , Number ( event . target . value ) ] } ) ) } / > < / label > < label > Right X < input aria-label = "Keyframe right handle frame" type = "number" value = { activeKeyframe ? . handleRight ? . [ 0 ] ? ? frame } onChange = { ( event ) = > onCommand ( animationRenderCommands . setKeyframe ( animation . id , animation . channels [ 0 ] . path , frame , { handleRight : [ Number ( event . target . value ) , activeKeyframe ? . handleRight ? . [ 1 ] ? ? activeKeyframe ? . value [ 0 ] ? ? 0 ] } ) ) } / > < / label > < label > Right Y < input aria-label = "Keyframe right handle value" type = "number" value = { activeKeyframe ? . handleRight ? . [ 1 ] ? ? activeKeyframe ? . value [ 0 ] ? ? 0 } onChange = { ( event ) = > onCommand ( animationRenderCommands . setKeyframe ( animation . id , animation . channels [ 0 ] . path , frame , { handleRight : [ activeKeyframe ? . handleRight ? . [ 0 ] ? ? frame , Number ( event . target . value ) ] } ) ) } / > < / label > < label > Interpolation < select aria-label = "Keyframe interpolation" value = { activeKeyframe ? . interpolation ? ? animation . channels [ 0 ] . interpolation ? ? "BEZIER" } onChange = { ( event ) = > onCommand ( animationRenderCommands . setKeyframe ( animation . id , animation . channels [ 0 ] . path , frame , { interpolation : event.target.value as "CONSTANT" | "LINEAR" | "BEZIER" } ) ) } > < option value = "CONSTANT" > Constant < / option > < option value = "LINEAR" > Linear < / option > < option value = "BEZIER" > Bezier < / option > < / select > < / label > < / div > : null }
{ activeScene ? < div className = "timeline-markers" aria-label = "Timeline Markers" > < input aria-label = "Marker name" value = { markerNameDraft } placeholder = "Marker name" onChange = { ( event ) = > setMarkerNameDraft ( event . target . value ) } / > < button type = "button" onClick = { ( ) = > { const name = markerNameDraft . trim ( ) ; if ( ! name ) return ; onCommand ( animationRenderCommands . setTimelineMarkers ( activeScene . id , [ . . . markers , { frame , name } ] ) ) ; setMarkerNameDraft ( "" ) ; } } > Add Marker < / button > { markers . map ( ( marker , index ) = > < div className = "timeline-marker" key = { ` ${ marker . frame } : ${ index } ` } > < input aria-label = { ` Marker ${ index + 1 } name ` } value = { marker . name } onChange = { ( event ) = > onCommand ( animationRenderCommands . setTimelineMarkers ( activeScene . id , markers . map ( ( entry , markerIndex ) = > markerIndex === index ? { . . . entry , name : event.target.value } : entry ) ) ) } / > < input aria-label = { ` Marker ${ index + 1 } frame ` } type = "number" value = { marker . frame } onChange = { ( event ) = > onCommand ( animationRenderCommands . setTimelineMarkers ( activeScene . id , markers . map ( ( entry , markerIndex ) = > markerIndex === index ? { . . . entry , frame : Number ( event . target . value ) } : entry ) ) ) } / > < button type = "button" aria-label = { ` Delete marker ${ index + 1 } ` } onClick = { ( ) = > onCommand ( animationRenderCommands . setTimelineMarkers ( activeScene . id , markers . filter ( ( _ , markerIndex ) = > markerIndex !== index ) ) ) } > × < / button > < / div > ) } < / div > : null }
{ activeScene && snapshot ? < SequencerAuthoringPanel scene = { activeScene } revision = { snapshot . revision } frame = { frame } onCommand = { onCommand } / > : null }
{ snapshot ? . activeObjectId && nlaTracks . length > 0 ? < div className = "nla-strip-editor" aria-label = "NLA Strip 编辑" > < label > NLA Track < select data-testid = "nla-track-select" aria-label = "NLA Track" value = { selectedNlaTrackId ? ? "" } onChange = { ( event ) = > { const trackId = event . target . value || null ; setSelectedNlaTrackId ( trackId ) ; const track = nlaTracks . find ( ( candidate ) = > candidate . id === trackId ) ; setSelectedNlaStripId ( track ? . strips . find ( ( strip ) = > strip . selected ) ? . id ? ? track ? . strips [ 0 ] ? . id ? ? null ) ; } } > < option value = "" > 选 择 Track . . . < / option > { nlaTracks . map ( ( track ) = > < option key = { track . id } value = { track . id } > { track . name } < / option > ) } < / select > < / label > < label > NLA Strip < select data-testid = "nla-strip-select" aria-label = "NLA Strip" value = { selectedNlaStripId ? ? "" } onChange = { ( event ) = > setSelectedNlaStripId ( event . target . value || null ) } disabled = { ! selectedNlaTrack || selectedNlaTrack . strips . length === 0 } > < option value = "" > 选 择 Strip . . . < / option > { selectedNlaTrack ? . strips . map ( ( strip ) = > < option key = { strip . id } value = { strip . id } > { strip . id } ( { strip . frameStart } – { strip . frameEnd } ) < / option > ) } < / select > < / label > < label > Start < input data-testid = "nla-strip-frame-start" aria-label = "NLA Strip 起始帧" type = "number" step = "1" value = { nlaFrameStartDraft } disabled = { ! selectedNlaStrip } onChange = { ( event ) = > setNlaFrameStartDraft ( event . target . value ) } onBlur = { commitNlaFrameStart } onKeyDown = { ( event ) = > { if ( event . key === "Enter" ) { event . preventDefault ( ) ; commitNlaFrameStart ( ) ; } } } / > < / label > < button type = "button" data-testid = "move-nla-strip" disabled = { ! selectedNlaStrip } onMouseDown = { ( event ) = > event . preventDefault ( ) } onClick = { commitNlaFrameStart } > 移 动 Strip < / button > < / div > : null }
< / div >
) ;
@@ -2752,7 +3153,10 @@ export function App() {
const operatorCommands = filterIOFormatOperatorCommands < OperatorCommand > ( [
. . . ( [ "Layout" , "Modeling" , "Animation" ] as WorkspaceId [ ] ) . filter ( ( id ) = > id !== workspace ) . map ( ( id ) = > ( { id : ` workspace. ${ id } ` , label : ` Switch to ${ id } ` , keywords : "workspace" , execute : ( ) = > dispatchUI ( { type : "switchWorkspace" , workspaceId : id } ) } ) ) ,
{ id : "mode.toggle" , label : uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode" , keywords : "mode tab" , execute : ( ) = > dispatchUI ( { type : "setMode" , mode : uiState.context.mode === "Object" ? "Edit" : "Object" } ) } ,
. . . ( snapshot && uiState . context . mode === "Object" && isWorkbenchCommandAvailable ( "createPrimitive" ) ? [ { id : "object.add-cube" , label : "Add Cube" , keywords : "object primitive mesh" , execute : ( ) = > { void applyEditCommand ( coreSceneCommands . createCube ( ) ) ; } } ] : [ ] ) ,
. . . ( snapshot && uiState . context . mode === "Object" && isWorkbenchCommandAvailable ( "createPrimitive" ) ? ( [
[ "CUBE" , "Cube" ] , [ "PLANE" , "Plane" ] , [ "UV_SPHERE" , "UV Sphere" ] , [ "ICO_SPHERE" , "Ico Sphere" ] ,
[ "CYLINDER" , "Cylinder" ] , [ "CONE" , "Cone" ] , [ "TORUS" , "Torus" ] ,
] as ReadonlyArray < readonly [ MeshPrimitive , string ] > ) . map ( ( [ primitive , label ] ) = > ( { id : ` object.add- ${ primitive . toLowerCase ( ) } ` , label : ` Add ${ label } ` , keywords : "object primitive mesh" , execute : ( ) = > { void applyEditCommand ( coreSceneCommands . createPrimitive ( primitive ) ) ; } } ) ) : [ ] ) ,
. . . ( snapshot ? . activeObjectId && isWorkbenchCommandAvailable ( "applyObjectTransform" ) ? [ { id : "object.apply-transform" , label : "Apply Transform" , keywords : "object location rotation scale" , execute : ( ) = > { void applyEditCommand ( coreSceneCommands . applyTransform ( snapshot . activeObjectId ! ) ) ; } } ] : [ ] ) ,
. . . ( snapshot ? [
{ id : "edit.undo" , label : "Undo" , keywords : "history" , execute : ( ) = > { void applyEditCommand ( { type : "undo" } ) ; } } ,
@@ -2808,14 +3212,14 @@ export function App() {
{ 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 }
< StorageBudgetPanel budget = { storageBudget } onCleanup = { ( ) = > void cleanupProjectAssets ( ) } / >
< div className = "workspace-toolbar" > < span > { workspaceLabel } < / span > < button type = "button" className = "mode-chip" onClick = { ( ) = > dispatchUI ( { type : "setMode" , mode : uiState.context.mode === "Object" ? "Edit" : "Object" } ) } > { uiState . context . mode } Mode < / button > { uiState . context . mode === "Edit" ? < > < div className = "segmented" aria-label = "网格选择模式" > { ( [ "VERT" , "EDGE" , "FACE" ] as MeshElementMode [ ] ) . map ( ( mode ) = > < button key = { mode } type = "button" className = { meshSelection . mode === mode ? "active" : "" } onClick = { ( ) = > setMeshSelectionMode ( mode ) } > { mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face" } < / button > ) } < / div > < button type = "button" onClick = { selectAllMeshElements } > Select All < / button > { ( [ "MERGE" , "DISSOLVE" , "EXTRUDE" , "INSET" , "BEVEL" , "LOOP_CUT" ] as MeshEditOperation [ ] ) . map ( ( operation ) = > < button key = { operation } type = "button" disabled = { meshSelection . indices . size === 0 } onClick = { ( ) = > runMeshEdit ( operation ) } > { operation . replace ( "_" , " " ) } < / button > ) } < button type = "button" disabled = { meshSelection . mode !== "FACE" || meshSelection . indices . size === 0 || ! snapshot ? . activeObjectId } onClick = { ( ) = > snapshot ? . activeObjectId && void applyEditCommand ( { type : "separateMeshFaces" , objectId : snapshot.activeObjectId , faceIndices : [ . . . meshSelection . indices ] , name : "Separated" } ) } > Separate < / button > < / > : < > < button type = "button" aria-label = "添加立方体" onClick = { ( ) = > void applyEditCommand ( { type : " createPrimitive" , primitive : "CUBE" , location : [ 0 , 0 , 0 ] } ) } > Add Cube < / button > < button type = "button" aria-label = "复制对象" disabled = { ! snapshot ? . activeObjectId } onClick = { ( ) = > snapshot ? . activeObjectId && void applyEditCommand ( { type : "duplicateObject" , objectId : snapshot.activeObjectId , offset : [ 0.25 , 0.25 , 0 ] } ) } > Duplicate < / button > < button type = "button" aria-label = "链接复制对象" disabled = { ! snapshot ? . activeObjectId } onClick = { ( ) = > snapshot ? . activeObjectId && void applyEditCommand ( { type : "duplicateObject" , objectId : snapshot.activeObjectId , offset : [ 0.5 , 0.5 , 0 ] , linked : true } ) } > Linked Duplicate < / button > < button type = "button" aria-label = "删除对象" disabled = { ! snapshot ? . activeObjectId } onClick = { ( ) = > snapshot ? . activeObjectId && void applyEditCommand ( { type : "deleteObject" , objectId : snapshot.activeObjectId } ) } > Delete < / button > < button type = "button" disabled = { selectedObjectIds . size < 2 || ! snapshot ? . activeObjectId } onClick = { ( ) = > { const child = snapshot ? . activeObjectId ; const parent = [ . . . selectedObjectIds ] . find ( ( id ) = > id !== child ) ; if ( child && parent ) void applyEditCommand ( { type : "setParent" , objectId : child , parentId : parent , keepTransform : true } ) ; } } > Parent < / button > < button type = "button" disabled = { ! snapshot ? . activeObjectId } onClick = { ( ) = > snapshot ? . activeObjectId && void applyEditCommand ( { type : "setParent" , objectId : snapshot.activeObjectId , parentId : null , keepTransform : true } ) } > Unparent < / button > < button type = "button" disabled = { selectedObjectIds . size < 2 || ! snapshot ? . activeObjectId } onClick = { ( ) = > snapshot ? . activeObjectId && void applyEditCommand ( { type : "joinObjects" , activeObjectId : snapshot.activeObjectId , objectIds : [ . . . selectedObjectIds ] } ) } > Join < / button > < button type = "button" onClick = { ( ) = > void applyEditCommand ( { type : "createCollection" , name : ` Collection ${ ( snapshot ? . collections . length ? ? 0 ) + 1 } ` } ) } > New Collection < / button > < / > } < span className = "toolbar-spacer" / > < span > { uiState . context . mode === "Edit" ? ` ${ meshSelection . greasePencilPoints ? . length ? ? meshSelection . indices . size } ${ meshSelection . mode . toLowerCase ( ) } selected ` : ` ${ selectedObjectIds . size } selected ` } < / span > < output data-testid = "dirty-status" > { saved ? "已保存" : "未保存" } < / output > < / div >
< div className = "workspace-toolbar" > < span > { workspaceLabel } < / span > < button type = "button" className = "mode-chip" onClick = { ( ) = > dispatchUI ( { type : "setMode" , mode : uiState.context.mode === "Object" ? "Edit" : "Object" } ) } > { uiState . context . mode } Mode < / button > { uiState . context . mode === "Edit" ? < > < div className = "segmented" aria-label = "网格选择模式" > { ( [ "VERT" , "EDGE" , "FACE" ] as MeshElementMode [ ] ) . map ( ( mode ) = > < button key = { mode } type = "button" className = { meshSelection . mode === mode ? "active" : "" } onClick = { ( ) = > setMeshSelectionMode ( mode ) } > { mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face" } < / button > ) } < / div > < button type = "button" onClick = { selectAllMeshElements } > Select All < / button > { ( [ "MERGE" , "DISSOLVE" , "EXTRUDE" , "INSET" , "BEVEL" , "LOOP_CUT" , "SUBDIVIDE" , "FILL" , "TRIANGULATE" , "RECALCULATE_NORMALS" , "BRIDGE_LOOPS" , "DELETE" ] as MeshEditOperation [ ] ) . map ( ( operation ) = > < button key = { operation } type = "button" disabled = { meshSelection . indices . size === 0 && operation !== "RECALCULATE_NORMALS" } onClick = { ( ) = > runMeshEdit ( operation ) } > { operation . replaceAll ( "_" , " " ) } < / button > ) } < button type = "button" disabled = { meshSelection . mode !== "FACE" || meshSelection . indices . size === 0 || ! snapshot ? . activeObjectId } onClick = { ( ) = > snapshot ? . activeObjectId && void applyEditCommand ( { type : "separateMeshFaces" , objectId : snapshot.activeObjectId , faceIndices : [ . . . meshSelection . indices ] , name : "Separated" } ) } > Separate < / button > < / > : < > { ( [ "CUBE" , "PLANE" , "UV_SPHERE" , "ICO_SPHERE" , "CYLINDER" , "CONE" , "TORUS" ] as MeshPrimitive [ ] ) . map ( ( primitive ) = > < button key = { primitive } type = "button" aria-label = { ` 添加 ${ primitive . toLowerCase ( ) . replaceAll ( "_" , " " ) } ` } onClick = { ( ) = > void applyEditCommand ( coreSceneCommands . createPrimitive( primitive ) ) } > Add { primitive . replaceAll ( "_" , " " ) } < / button > ) } <button type = "button" aria-label = "复制对象" disabled = { ! snapshot ? . activeObjectId } onClick = { ( ) = > snapshot ? . activeObjectId && void applyEditCommand ( { type : "duplicateObject" , objectId : snapshot.activeObjectId , offset : [ 0.25 , 0.25 , 0 ] } ) } > Duplicate < / button > < button type = "button" aria-label = "链接复制对象" disabled = { ! snapshot ? . activeObjectId } onClick = { ( ) = > snapshot ? . activeObjectId && void applyEditCommand ( { type : "duplicateObject" , objectId : snapshot.activeObjectId , offset : [ 0.5 , 0.5 , 0 ] , linked : true } ) } > Linked Duplicate < / button > < button type = "button" aria-label = "删除对象" disabled = { ! snapshot ? . activeObjectId } onClick = { ( ) = > snapshot ? . activeObjectId && void applyEditCommand ( { type : "deleteObject" , objectId : snapshot.activeObjectId } ) } > Delete < / button > < button type = "button" disabled = { selectedObjectIds . size < 2 || ! snapshot ? . activeObjectId } onClick = { ( ) = > { const child = snapshot ? . activeObjectId ; const parent = [ . . . selectedObjectIds ] . find ( ( id ) = > id !== child ) ; if ( child && parent ) void applyEditCommand ( { type : "setParent" , objectId : child , parentId : parent , keepTransform : true } ) ; } } > Parent < / button > < button type = "button" disabled = { ! snapshot ? . activeObjectId } onClick = { ( ) = > snapshot ? . activeObjectId && void applyEditCommand ( { type : "setParent" , objectId : snapshot.activeObjectId , parentId : null , keepTransform : true } ) } > Unparent < / button > < button type = "button" disabled = { selectedObjectIds . size < 2 || ! snapshot ? . activeObjectId } onClick = { ( ) = > snapshot ? . activeObjectId && void applyEditCommand ( { type : "joinObjects" , activeObjectId : snapshot.activeObjectId , objectIds : [ . . . selectedObjectIds ] } ) } > Join < / button > < button type = "button" onClick = { ( ) = > void applyEditCommand ( { type : "createCollection" , name : ` Collection ${ ( snapshot ? . collections . length ? ? 0 ) + 1 } ` } ) } > New Collection < / button > < / > } < span className = "toolbar-spacer" / > < span > { uiState . context . mode === "Edit" ? ` ${ meshSelection . greasePencilPoints ? . length ? ? meshSelection . indices . size } ${ meshSelection . mode . toLowerCase ( ) } selected ` : ` ${ selectedObjectIds . size } selected ` } < / span > < output data-testid = "dirty-status" > { saved ? "已保存" : "未保存" } < / output > < / div >
< div className = "workspace-grid" >
< Area className = "viewport-area" editor = "3D Viewport" > < ViewportPlaceholder snapshot = { preview ? . snapshot ? ? snapshot } geometryBuffers = { preview ? . geometryBuffers ? ? geometryBuffers } nonMeshGeometryBuffers = { preview ? . nonMeshGeometryBuffers ? ? nonMeshGeometryBuffers } textureAssets = { gpuTextureAssets } volumeProject = { volumeProject } lodLevels = { preview ? null : lodLevels } selectedObjectIds = { selectedObjectIds } editMode = { uiState . context . mode === "Edit" } meshSelection = { meshSelection } greasePencilSelectionRevision = { greasePencilSelection ? . revision ? ? 0 } onSelect = { selectObject } onElementSelect = { selectMeshElement } onGreasePencilPointSelect = { selectGreasePencilPoint } onGreasePencilMarqueeSelect = { selectGreasePencilMarquee } onTransform = { transformActive } onDiagnostic = { recordDiagnostic } / > < / Area >
< Area className = "outliner-area" editor = "Outliner" > < Outliner snapshot = { snapshot } onSelect = { selectObject } onToggleVisibility = { ( id , visible ) = > void applyEditCommand ( { type : "setObjectVisibility" , objectId : id , visible } ) } / > < / Area >
< Area className = "properties-area" editor = "Properties" > < Properties snapshot = { snapshot } editorWorkflow = { editorWorkflow } selectedFaceIndices = { meshSelection . mode === "FACE" ? [ . . . meshSelection . indices ] : [ ] } selectedVertexIndices = { meshSelection . mode === "VERT" ? [ . . . meshSelection . indices ] : [ ] } greasePencilSelection = { greasePencilSelection } onGreasePencilCanvasPointSelect = { ( point , additive , baseSelectionRevision ) = > { commitGreasePencilSelection ( "CANVAS_2D" , additive ? "TOGGLE" : "REPLACE" , [ point ] , point , baseSelectionRevision ) ; } } onCommand = { ( command ) = > void applyEditCommand ( command ) } onImportImage = { ( file ) = > void importImage ( file ) } onImportVFont = { ( file ) = > void importVFont ( file ) } onRequestAsset = { ( assetId ) = > { const client = webClientRef . current ; if ( ! client ) return ; void client . requestAsset ( assetId ) . then ( ( asset ) = > setEngineStatus ( ` Asset: ${ asset . assetId } ( ${ asset . status } ) ` ) ) . catch ( ( error : unknown ) = > setEngineStatus ( recordDiagnostic ( "ENGINE" , "PBR_ASSET_INVALID" , error , { assetId } ) ) ) ; } } onAppendLibraryObject = { async ( source , request ) = > { const client = webClientRef . current ; if ( ! client ) return ; try { const result = await client . appendLibraryObject ( source , request ) ; setSnapshot ( result . snapshot ) ; setGeometryBuffers ( result . geometryBuffers ) ; setNonMeshGeometryBuffers ( result . nonMeshGeometryBuffers ? ? [ ] ) ; setFrame ( result . snapshot . frame . current ) ; projectRevisionRef . current = result . snapshot . revision ; setEngineStatus ( ` Library append: ${ result . receipt . sourceDataBlockId } ` ) ; } catch ( error ) { setEngineStatus ( recordDiagnostic ( "ENGINE" , "COMMAND_FAILED" , error ) ) ; } } } onQueryRenderCapability = { ( request ) = > { const client = webClientRef . current ; if ( ! client ) return ; void client . queryRenderCapability ( request ) . then ( ( gate ) = > setEngineStatus ( ` Render: ${ gate . status } ( ${ gate . capability } ) ` ) ) . catch ( ( error : unknown ) = > setEngineStatus ( recordDiagnostic ( "ENGINE" , "GLB_EXPORT_BLOCKED" , error ) ) ) ; } } onApplyDecimate = { applyDecimate } onPreviewDecimate = { previewDecimate } onGenerateLOD = { ( meshId , triangleCount ) = > void generateLOD ( meshId , triangleCount ) } onSetModifierEnabled = { setModifierEnabled } previewActive = { Boolean ( preview ) } onCancelPreview = { ( ) = > { setPreview ( null ) ; setLodLevels ( null ) ; setEngineStatus ( ` Engine: SceneIR r ${ snapshot ? . revision ? ? 0 } ` ) ; } } / > < / Area >
< Area className = "timeline-area" editor = "Timeline" > < Timeline snapshot = { snapshot } frame = { frame } start = { frameRange . start } end = { frameRange . end } onFrameChange = { ( value ) = > void applyEditCommand ( { type : "setFrame" , frame : value } ) } onCommand = { ( command ) = > void applyEditCommand ( command ) } / > < / Area >
< / div >
{ uiState . operatorSearchOpen ? < OperatorSearch commands = { operatorCommands } onClose = { closeOperatorSearch } / > : null }
{ uiState . operatorSearchOpen ? < Blender OperatorPalette client = { webClientRef . current } commands = { operatorCommands } onClose = { closeOperatorSearch } onApplied = { ( result , descriptor ) = > { setSnapshot ( result . snapshot ) ; setGeometryBuffers ( result . geometryBuffers ) ; setNonMeshGeometryBuffers ( result . nonMeshGeometryBuffers ? ? [ ] ) ; setFrame ( result . snapshot . frame . current ) ; projectRevisionRef . current = result . snapshot . revision ; setEngineStatus ( ` Operator: ${ descriptor . id } ` ) ; } } / > : null }
< footer className = "status-bar" > < span > Web Blender Modeler V1 < / span > < span data-testid = "scene-stats" > Objects { objectCount } · Vertices { vertexCount } · Faces { faceCount } < / span > { openProgress ? < span className = "open-progress" data-testid = "open-progress" data-phase = { openProgress . phase } data-stage = { openProgress . stage } data-cancellable = { openProgress . cancellable } data-bytes-read = { openProgress . bytesRead } data-total-bytes = { openProgress . totalBytes } > < progress aria-label = "打开文件进度" max = { ( openProgress . totalBytes ? ? 0 ) > 0 ? openProgress.totalBytes : 1 } value = { openProgress . bytesRead ? ? openProgress . fraction ? ? 0 } / > < span > { openProgress . message ? ? "Opening" } < / span > { canCancelOpenRead ? < button type = "button" aria-label = "取消打开" title = "取消打开" onClick = { cancelOpenFileRead } > × < / button > : null } < / span > : null } < span className = "status-spacer" / > < span > { manifestStatus } < / span > < span > { wasmStatus } < / span > < span data-testid = "engine-status" > { engineStatus } < / span > < span > { storageStatus } < / span > < / footer >
< / main >
) ;