@@ -62,8 +62,11 @@ import {
type NanoVDBViewportAssetIR ,
type NanoVDBViewportProjectContextIR ,
} from "../volume/nanovdb-viewport" ;
import { composePaintColorPatch , composePaintWeightPatch } from "../../../protocol/paint" ;
import { composePaintColorPatch , composePaintWeightPatch , createSculptMeshAttributes , createSculptStroke } from "../../../protocol/paint" ;
import type { SculptBrush } 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" ;
import { createIMECompositionState , reduceIMEComposition , shouldBlockOperatorShortcuts , type IMECompositionEvent } from "../../../protocol/ime-composition" ;
import { observeKeyboardEvent , type KeyboardObservation } from "../../../protocol/keyboard-contract" ;
import { createInitialUserActionStates , reduceUserActionStates , type UserActionIdentity , type UserActionKind } from "../../../protocol/user-action-state" ;
@@ -79,7 +82,6 @@ import { filterIOFormatOperatorCommands, gateIOFormatFileSelection, ioFormatUIAc
import ioFormatRuntimeReceiptFreshnessJSON from "../capabilities/io-format-runtime-receipts-freshness.json" ;
import ioFormatRuntimeReceiptFreshnessExpectedJSON from "../capabilities/io-format-runtime-receipts-freshness-expected.json" ;
import { parseIOFormatReceiptFreshness , resolveFreshIOFormatRuntimeRoute , type IOFormatReceiptFreshnessExpectedIR } from "../../../protocol/io-format-receipt-freshness" ;
import "./app-shell.css" ;
const IO_FORMAT_UI_REGISTRY = parseIOFormatUIRegistry ( ioFormatUIRegistryJSON ) ;
const IO_FORMAT_RUNTIME_RECEIPTS = parseIOFormatReceiptFreshness ( ioFormatRuntimeReceiptFreshnessJSON ) ;
@@ -643,14 +645,16 @@ function GreasePencilCanvas2D({ drawing, scope, selection, onSelect }: {
onKeyDown = { keyboardSelect } / > ;
}
function Properties ( { snapshot , selectedFaceIndices , selectedVertexIndices , greasePencilSelection , onGreasePencilCanvasPointSelect , onCommand , onImportImage , onRequestAsset , onAppendLibraryObject , onQueryRenderCapability , onApplyDecimate , onPreviewDecimate , onGenerateLOD , onSetModifierEnabled , previewActive , onCancelPreview } : {
function Properties ( { snapshot , editorWorkflow , selectedFaceIndices , selectedVertexIndices , greasePencilSelection , onGreasePencilCanvasPointSelect , onCommand , onImportImage , onImportVFont , onRequestAsset , onAppendLibraryObject , onQueryRenderCapability , onApplyDecimate , onPreviewDecimate , onGenerateLOD , onSetModifierEnabled , previewActive , onCancelPreview } : {
snapshot : SceneSnapshotIR | null ;
editorWorkflow : EditorWorkflowIR | null ;
selectedFaceIndices : number [ ] ;
selectedVertexIndices : number [ ] ;
greasePencilSelection : GreasePencilSelectionStateIR | null ;
onGreasePencilCanvasPointSelect : ( point : GreasePencilPointRef , additive : boolean , baseSelectionRevision : number ) = > void ;
onCommand : ( command : WebEngineEditCommand ) = > void ;
onImportImage : ( file : File ) = > void ;
onImportVFont : ( file : File ) = > void ;
onRequestAsset : ( assetId : string ) = > void ;
onAppendLibraryObject : ( source : ArrayBuffer , request : LibraryMainAppendRequestIR ) = > Promise < void > ;
onQueryRenderCapability : ( request : RenderCapabilityRequest ) = > void ;
@@ -690,6 +694,14 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
const [ paintMirrorAxis , setPaintMirrorAxis ] = useState < 0 | 1 | 2 > ( 0 ) ;
const [ paintSelectionMask , setPaintSelectionMask ] = useState ( 0.5 ) ;
const [ paintGroup , setPaintGroup ] = useState ( "WebPaint" ) ;
const [ sculptBrush , setSculptBrush ] = useState < SculptBrush > ( "DRAW" ) ;
const [ sculptRadius , setSculptRadius ] = useState ( 0.25 ) ;
const [ sculptStrength , setSculptStrength ] = useState ( 0.5 ) ;
const [ sculptPressure , setSculptPressure ] = useState ( 1 ) ;
const [ sculptSymmetry , setSculptSymmetry ] = useState < [ boolean , boolean , boolean ] > ( [ false , false , false ] ) ;
const [ sculptMirrorObjectSpace , setSculptMirrorObjectSpace ] = useState ( false ) ;
const [ sculptMaskValue , setSculptMaskValue ] = useState ( 0 ) ;
const [ sculptActiveFaceSet , setSculptActiveFaceSet ] = useState ( 1 ) ;
const [ greasePencilLayerId , setGreasePencilLayerId ] = useState ( "" ) ;
const [ greasePencilLayerName , setGreasePencilLayerName ] = useState ( "Web Layer" ) ;
const [ greasePencilStrokeIndex , setGreasePencilStrokeIndex ] = useState ( 0 ) ;
@@ -698,15 +710,30 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
const [ greasePencilTargetFrame , setGreasePencilTargetFrame ] = useState ( 12 ) ;
const [ greasePencilReorderError , setGreasePencilReorderError ] = useState ( "" ) ;
const [ curveSplineIndex , setCurveSplineIndex ] = useState ( 0 ) ;
const [ curvePointIndex , setCurvePointIndex ] = useState ( 0 ) ;
const [ curveHandleSide , setCurveHandleSide ] = useState < "LEFT" | "RIGHT" > ( "LEFT" ) ;
const [ surfaceSplineIndex , setSurfaceSplineIndex ] = useState ( 0 ) ;
const [ curveTopologyError , setCurveTopologyError ] = useState ( "" ) ;
const [ fontBody , setFontBody ] = useState ( "" ) ;
const [ fontCharacterIndex , setFontCharacterIndex ] = useState ( 0 ) ;
const [ fontActiveTextBox , setFontActiveTextBox ] = useState ( 0 ) ;
const [ fontCharacterKern , setFontCharacterKern ] = useState ( 0 ) ;
const [ fontCharacterMaterial , setFontCharacterMaterial ] = useState ( 0 ) ;
const [ fontCharacterStyleFlags , setFontCharacterStyleFlags ] = useState ( 0 ) ;
const [ fontTextBoxX , setFontTextBoxX ] = useState ( 0 ) ;
const [ fontTextBoxY , setFontTextBoxY ] = useState ( 0 ) ;
const [ fontTextBoxWidth , setFontTextBoxWidth ] = useState ( 1 ) ;
const [ fontTextBoxHeight , setFontTextBoxHeight ] = useState ( 1 ) ;
const [ fontLinks , setFontLinks ] = useState ( { regular : "" , bold : "" , italic : "" , boldItalic : "" } ) ;
const [ textSource , setTextSource ] = useState ( "" ) ;
const [ librarySourceFile , setLibrarySourceFile ] = useState < File | null > ( null ) ;
const [ libraryRequestFile , setLibraryRequestFile ] = useState < File | null > ( null ) ;
const [ libraryAppendError , setLibraryAppendError ] = useState ( "" ) ;
const activeNode = snapshot ? . nodes . find ( ( node ) = > node . id === snapshot . activeObjectId ) ;
const activeMesh = activeNode ? . dataId ? snapshot ? . meshes . find ( ( mesh ) = > mesh . id === activeNode . dataId ) : undefined ;
const activeNonMeshData = activeNode ? . dataId ? snapshot ? . nonMeshData ? . find ( ( data ) = > data . id === activeNode . dataId ) : undefined ;
const activeCurve = activeNode ? . dataId ? snapshot ? . nonMeshData ? . find ( ( data ) = > data . id === activeNode . dataId && data . type === "CURVE" ) : undefined ;
const activeSurface = activeNode ? . dataId ? snapshot ? . nonMeshData ? . find ( ( data ) = > data . id === activeNode . dataId && data . type === "SURFACE" ) : undefined ;
const activeFont = activeNode ? . dataId ? snapshot ? . nonMeshData ? . find ( ( data ) = > data . id === activeNode . dataId && data . type === "FONT" ) : undefined ;
const activeText = activeNode ? . dataId ? snapshot ? . texts ? . find ( ( data ) = > data . id === activeNode . dataId ) : undefined ;
const activeSound = activeNode ? . dataId ? snapshot ? . sounds ? . find ( ( data ) = > data . id === activeNode . dataId ) : undefined ;
@@ -738,11 +765,32 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
} , [ activeMaterial ] ) ;
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 ] ) ;
useEffect ( ( ) = > {
const characterIndex = Math . min ( fontCharacterIndex , Math . max ( 0 , ( activeFont ? . fontCharacters ? . length ? ? 1 ) - 1 ) ) ;
const character = activeFont ? . fontCharacters ? . [ characterIndex ] ;
const textBoxIndex = Math . min ( activeFont ? . activeFontTextBox ? ? 0 , Math . max ( 0 , ( activeFont ? . fontTextBoxes ? . length ? ? 1 ) - 1 ) ) ;
const textBox = activeFont ? . fontTextBoxes ? . [ textBoxIndex ] ? ? { x : 0 , y : 0 , width : 1 , height : 1 } ;
setFontCharacterIndex ( characterIndex ) ;
setFontActiveTextBox ( textBoxIndex ) ;
setFontCharacterKern ( character ? . kern ? ? 0 ) ;
setFontCharacterMaterial ( character ? . materialIndex ? ? 0 ) ;
setFontCharacterStyleFlags ( character ? . styleFlags ? ? 0 ) ;
setFontTextBoxX ( textBox . x ) ;
setFontTextBoxY ( textBox . y ) ;
setFontTextBoxWidth ( textBox . width ) ;
setFontTextBoxHeight ( textBox . height ) ;
} , [ activeFont ? . id , activeFont ? . fontCharacters , activeFont ? . fontTextBoxes , activeFont ? . activeFontTextBox , fontCharacterIndex ] ) ;
useEffect ( ( ) = > setTextSource ( activeText ? . source ? ? "" ) , [ activeText ? . id , activeText ? . source ] ) ;
useEffect ( ( ) = > {
if ( ! activeCurve || curveSplineIndex >= activeCurve . splineCount ) setCurveSplineIndex ( 0 ) ;
const pointCount = activeCurve ? . handlePointIndices ? . length ? ? activeCurve ? . pointCount ? ? 0 ;
if ( curvePointIndex >= pointCount ) setCurvePointIndex ( 0 ) ;
setCurveTopologyError ( "" ) ;
} , [ activeCurve ? . id , activeCurve ? . splineCount , curveSpline Index] ) ;
} , [ activeCurve ? . id , activeCurve ? . splineCount , activeCurve ? . handlePointIndices ? . length , activeCurve ? . pointCount , curveSplineIndex , curvePoint Index] ) ;
useEffect ( ( ) = > {
if ( ! activeSurface || surfaceSplineIndex >= ( activeSurface . splineDimensions ? . length ? ? 0 ) ) setSurfaceSplineIndex ( 0 ) ;
} , [ activeSurface ? . id , activeSurface ? . splineDimensions ? . length , surfaceSplineIndex ] ) ;
useEffect ( ( ) = > {
if ( ! activeGreasePencil ) setGreasePencilLayerId ( "" ) ;
else if ( ! activeGreasePencil . layers . some ( ( layer ) = > layer . id === greasePencilLayerId ) ) setGreasePencilLayerId ( activeGreasePencil . layers [ 0 ] ? . id ? ? "" ) ;
@@ -786,6 +834,15 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
const patch = composePaintWeightPatch ( activeNode . id , paintGroup , snapshot . revision , snapshot . revision , currentGroupWeights , selectedPaintBrushWeights , paintWeight ) ;
onCommand ( { type : "setVertexWeights" , objectId : patch.objectId , vertexGroup : patch.vertexGroup , indices : patch.indices , values : patch.values , normalize : patch.normalize } ) ;
} ;
const applySculptStroke = ( ) : void = > {
if ( ! activeMesh ) return ;
onCommand ( { type : "sculptStroke" , stroke : createSculptStroke ( activeMesh . id , sculptBrush , sculptRadius , sculptStrength , sculptPressure , sculptSymmetry , sculptMirrorObjectSpace ) } ) ;
} ;
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 translateGreasePencilPoint = ( ) : void = > {
if ( ! activeGreasePencil || ! activeGreasePencilFrame || ! snapshot ) return ;
const selectedPoints = greasePencilPointSelection
@@ -872,6 +929,79 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
setCurveTopologyError ( error instanceof Error ? error.message : String ( error ) ) ;
}
} ;
const curvePointIndices = activeCurve
? activeCurve . handlePointIndices ? ? Array . from ( { length : activeCurve.pointCount } , ( _ , index ) = > index )
: [ ] ;
const nudgeCurveHandle = ( ) : void = > {
if ( ! activeCurve ? . handlePoints || curvePointIndices . length === 0 ) return ;
const pointIndex = curvePointIndices [ Math . min ( curvePointIndex , curvePointIndices . length - 1 ) ] ;
const packedPointIndex = curvePointIndices . indexOf ( pointIndex ) ;
if ( pointIndex === undefined || packedPointIndex < 0 ) return ;
const sideOffset = curveHandleSide === "RIGHT" ? 3 : 0 ;
const offset = packedPointIndex * 6 + sideOffset ;
const current = activeCurve . handlePoints . slice ( offset , offset + 3 ) ;
if ( current . length !== 3 ) return ;
const position : [ number , number , number ] = [ current [ 0 ] + 0.1 , current [ 1 ] , current [ 2 ] ] ;
onCommand ( nonMeshGeometryCommands . setCurveHandle ( activeCurve . id , pointIndex , curveHandleSide , position ) ) ;
} ;
const applyCurveSplines = ( splineTypeOverride ? : "POLY" | "BEZIER" | "NURBS" ) : void = > {
if ( ! activeCurve ) return ;
const splineCount = Math . max ( activeCurve . splineCount , activeCurve . splineTypes ? . length ? ? 0 ) ;
const splineTypes = Array . from ( { length : splineCount } , ( _ , index ) = > activeCurve . splineTypes ? . [ index ] ? ? "POLY" ) ;
if ( splineTypeOverride && splineTypes . length > 0 ) splineTypes [ Math . min ( curveSplineIndex , splineTypes . length - 1 ) ] = splineTypeOverride ;
const pointCount = activeCurve . pointCount ;
const cyclicU = Array . from ( { length : splineCount } , ( _ , index ) = > activeCurve . cyclicU ? . [ index ] ? ? false ) ;
const pointWeights = [ . . . ( activeCurve . pointWeights ? ? Array . from ( { length : pointCount } , ( ) = > 1 ) ) ] ;
onCommand ( nonMeshGeometryCommands . setCurveSplines (
activeCurve . id ,
splineTypes ,
activeCurve . splineOffsets ? ? [ ] ,
splineTypes . map ( ( type ) = > type === "NURBS" ? 3 : 1 ) ,
activeCurve . controlPoints ? ? [ ] ,
pointWeights ,
cyclicU ,
activeCurve . handleTypes ? ? [ ] ,
activeCurve . handlePoints ? ? [ ] ,
) ) ;
} ;
const applySurfaceTopology = ( axis ? : "U" | "V" ) : void = > {
if ( ! activeSurface ) return ;
const splineCount = activeSurface . splineDimensions ? . length ? ? 0 ;
const cyclicU = Array . from ( { length : splineCount } , ( _ , index ) = > activeSurface . cyclicU ? . [ index ] ? ? false ) ;
const cyclicV = Array . from ( { length : splineCount } , ( _ , index ) = > activeSurface . cyclicV ? . [ index ] ? ? false ) ;
const selectedIndex = Math . min ( surfaceSplineIndex , Math . max ( 0 , splineCount - 1 ) ) ;
if ( axis === "U" && splineCount > 0 ) cyclicU [ selectedIndex ] = ! cyclicU [ selectedIndex ] ;
if ( axis === "V" && splineCount > 0 ) cyclicV [ selectedIndex ] = ! cyclicV [ selectedIndex ] ;
onCommand ( nonMeshGeometryCommands . setSurfaceTopology (
activeSurface . id ,
activeSurface . splineDimensions ? ? [ ] ,
activeSurface . controlPoints ? ? [ ] ,
activeSurface . pointWeights ? ? Array . from ( { length : activeSurface.pointCount } , ( ) = > 1 ) ,
cyclicU ,
cyclicV ,
) ) ;
} ;
const applyFontAdvanced = ( ) : void = > {
if ( ! activeFont ) return ;
const characters = [ . . . ( activeFont . fontCharacters ? ? [ ] ) ] ;
if ( characters . length === 0 ) characters . push ( { kern : fontCharacterKern , materialIndex : fontCharacterMaterial , styleFlags : fontCharacterStyleFlags } ) ;
else {
const characterIndex = Math . min ( fontCharacterIndex , characters . length - 1 ) ;
characters [ characterIndex ] = { . . . characters [ characterIndex ] , kern : fontCharacterKern , materialIndex : fontCharacterMaterial , styleFlags : fontCharacterStyleFlags } ;
}
const textBoxes = [ . . . ( activeFont . fontTextBoxes ? ? [ ] ) ] ;
const activeIndex = Math . min ( fontActiveTextBox , Math . max ( 0 , textBoxes . length - 1 ) ) ;
if ( textBoxes . length === 0 ) textBoxes . push ( { x : fontTextBoxX , y : fontTextBoxY , width : fontTextBoxWidth , height : fontTextBoxHeight } ) ;
else textBoxes [ activeIndex ] = { x : fontTextBoxX , y : fontTextBoxY , width : fontTextBoxWidth , height : fontTextBoxHeight } ;
onCommand ( nonMeshGeometryCommands . setFontAdvanced ( activeFont . id , characters , textBoxes , activeIndex ) ) ;
} ;
const setFontLink = ( slot : "regular" | "bold" | "italic" | "boldItalic" , value : string ) : void = > {
setFontLinks ( ( current ) = > ( { . . . current , [ slot ] : value } ) ) ;
} ;
const applyFontLinks = ( ) : void = > {
if ( ! activeFont ) return ;
onCommand ( nonMeshGeometryCommands . setFontLinks ( activeFont . id , fontLinks ) ) ;
} ;
const appendLibrary = async ( ) : Promise < void > = > {
if ( ! snapshot || ! librarySourceFile || ! libraryRequestFile ) return ;
try {
@@ -924,6 +1054,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
< div className = "property-section" > < h3 > Transform < / h3 > < label > 位 置 < output > 0.000 , 0.000 , 0.000 < / output > < / label > < label > 旋 转 < output > 0 ° , 0 ° , 0 ° < / output > < / label > < label > 缩 放 < output > 1.000 , 1.000 , 1.000 < / output > < / label > < / div >
{ activeNode ? < div className = "property-section" > < h3 > Object & Hierarchy < / h3 > < label > 名 称 < input aria-label = "对象名称" value = { renameValue } onChange = { ( event ) = > setRenameValue ( event . target . value ) } / > < / label > < div className = "property-actions" > < button type = "button" onClick = { ( ) = > renameValue && onCommand ( { type : "renameId" , id : activeNode.id , name : renameValue } ) } > 重 命 名 < / button > < button type = "button" onClick = { ( ) = > onCommand ( { type : "applyObjectTransform" , objectId : activeNode.id } ) } > 应 用 变 换 < / button > < button type = "button" onClick = { ( ) = > onCommand ( { type : "setObjectOrigin" , objectId : activeNode.id , mode : "GEOMETRY" } ) } > 原 点 到 几 何 体 < / button > < / div > < label > Collection < select aria-label = "移动到 Collection" value = "" onChange = { ( event ) = > event . target . value && onCommand ( { type : "moveObjectToCollection" , objectId : activeNode.id , collectionId : event.target.value } ) } > < option value = "" > 选 择 . . . < / option > { snapshot ? . collections . map ( ( collection ) = > < option key = { collection . id } value = { collection . id } > { collection . name } < / option > ) } < / select > < / label > < / div > : null }
{ snapshot ? < div className = "property-section" data-testid = "scene-context-workbench" > < h3 > Scene Context < / h3 > < output data-testid = "scene-context-counts" > Scenes { snapshot . scenes . length } · Collections { snapshot . collections . length } · Armatures { snapshot . armatures ? . length ? ? 0 } · Libraries { snapshot . libraries ? . length ? ? 0 } < / output > { snapshot . scenes . map ( ( scene ) = > < output key = { scene . id } data-resource-id = { scene . id } > Scene : { scene . name } { scene . renderEngine ? ` · ${ scene . renderEngine } ` : "" } < / output > ) } { snapshot . collections . slice ( 0 , 32 ) . map ( ( collection ) = > < output key = { collection . id } data-resource-id = { collection . id } > Collection : { collection . name } · { collection . objectIds . length } objects · { collection . childCollectionIds . length } children < / output > ) } { snapshot . armatures ? . slice ( 0 , 16 ) . map ( ( armature ) = > < output key = { armature . id } data-resource-id = { armature . id } > Armature : { armature . name } · { armature . bones . length } bones < / output > ) } { snapshot . libraries ? . slice ( 0 , 16 ) . map ( ( library ) = > < output key = { library . id } data-resource-id = { library . id } > Library : { library . name } · { library . status } < / output > ) } < / div > : null }
{ snapshot ? < div className = "property-section" data-testid = "editor-workflow-workbench" data-status = { snapshot . editorWorkflowStatus ? ? "BLOCKED" } data-revision = { editorWorkflow ? . context . revision ? ? snapshot . revision } > < h3 > Editor Workflow < / h3 > < output data-testid = "editor-workflow-status" > { snapshot . editorWorkflowStatus ? ? "BLOCKED" } < / output > { editorWorkflow ? < > < output data-testid = "editor-workflow-context" > { editorWorkflow . context . workspaceId } · { editorWorkflow . context . activeAreaId } · { editorWorkflow . context . activeEditor } < / output > < output data-testid = "editor-workflow-workspace-count" > { editorWorkflow . workspaces . length } workspaces < / output > { editorWorkflow . workspaces . slice ( 0 , 16 ) . map ( ( item ) = > < output key = { item . id } data-workspace-id = { item . id } > { item . name } · { item . areas . length } areas · active { item . activeAreaId } < / output > ) } < / > : < output data-testid = "editor-workflow-unavailable" > Editor workflow unavailable < / output > } < / div > : null }
{ snapshot ? < div className = "property-section" data-testid = "capability-family-workbench" > < h3 > Capability Families < / h3 > < output data-testid = "capability-family-command-count" > { Object . keys ( WEB_ENGINE_COMMAND_CAPABILITIES ) . length } typed Main commands < / output > { [ . . . new Set ( Object . values ( WEB_ENGINE_COMMAND_CAPABILITIES ) . map ( ( capability ) = > capability . family ) ) ] . sort ( ) . map ( ( family ) = > < output key = { family } data-capability-family = { family } > { family } < / output > ) } < / div > : null }
{ snapshot && ( snapshot . releaseCandidateStatus || snapshot . scriptingPolicyStatus || snapshot . renderRoutingStatus || snapshot . serverJobBindingStatus || snapshot . performanceFaultMatrixStatus || snapshot . editorWorkflowStatus ) ? < div className = "property-section" data-testid = "release-status-workbench" > < h3 > Runtime Status < / h3 > < output data-testid = "runtime-status-summary" > Scripting { snapshot . scriptingPolicyStatus ? ? "UNKNOWN" } · Render { snapshot . renderRoutingStatus ? ? "UNKNOWN" } · Jobs { snapshot . serverJobBindingStatus ? ? "UNKNOWN" } < / output > < output > Editor workflow { snapshot . editorWorkflowStatus ? ? "UNKNOWN" } · Performance faults { snapshot . performanceFaultMatrixStatus ? ? "UNKNOWN" } < / output > < output > Release candidate { snapshot . releaseCandidateStatus ? ? "UNKNOWN" } < / output > < / div > : null }
{ activeNode ? . dataId ? . startsWith ( "mesh:" ) ? < div className = "property-section" data-testid = "geometry-node-workbench" > < h3 > Geometry Nodes < / h3 > < output data-testid = "geometry-node-graph-count" > { snapshot ? . geometryNodeGraphs ? . length ? ? 0 } graphs < / output > { activeGeometryGraph ? < > < output data-testid = "geometry-node-active-graph" > { activeGeometryGraph . name } < / output > < output > { activeGeometryGraph . nodes . length } nodes / { activeGeometryGraph . links . length } links < / output > < button type = "button" onClick = { ( ) = > onCommand ( geometryNodeCommands . setGraph ( activeNode . dataId ! , activeGeometryGraph ) ) } > 提 交 当 前 节 点 图 < / button > < / > : < output > 当 前 文 件 没 有 Geometry Node 图 < / output > } < / div > : null }
@@ -959,12 +1090,19 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
{ activeScene ? . viewLayers ? . filter ( ( layer ) = > layer . aovs ? . length ) . map ( ( layer ) = > < div className = "property-section" data-testid = "scene-view-layer-aov-workbench" key = { ` aovs- ${ layer . name } ` } > < h3 > { layer . name } AOVs < / h3 > { layer . aovs ? . map ( ( aov ) = > < output key = { aov . name } > { aov . name } · { aov . type === 1 ? "Color" : "Value" } < / output > ) } < button type = "button" onClick = { ( ) = > onCommand ( animationRenderCommands . setSceneViewLayerAOVs ( activeScene . id , layer . name , [ . . . ( layer . aovs ? ? [ ] ) . map ( ( aov ) = > ( { name : aov.name , type : ( aov . type === 1 ? 1 : 0 ) as 0 | 1 } ) ) , { name : ` AOV_ ${ ( layer . aovs ? . length ? ? 0 ) + 1 } ` , type : 1 } ] ) ) } > Add Color AOV < / button > < / div > ) ? ? null }
{ activeScene ? . viewLayers ? . filter ( ( layer ) = > layer . lightgroups ? . length ) . map ( ( layer ) = > < div className = "property-section" data-testid = "scene-view-layer-lightgroup-workbench" key = { ` lightgroups- ${ layer . name } ` } > < h3 > { layer . name } Lightgroups < / h3 > { layer . lightgroups ? . map ( ( group ) = > < output key = { group } > { group } < / output > ) } < button type = "button" onClick = { ( ) = > onCommand ( animationRenderCommands . setSceneViewLayerLightgroups ( activeScene . id , layer . name , [ . . . ( layer . lightgroups ? ? [ ] ) , ` Lightgroup_ ${ ( layer . lightgroups ? . length ? ? 0 ) + 1 } ` ] ) ) } > Add Lightgroup < / button > < / div > ) ? ? null }
{ activeScene ? . simplifySettings ? < div className = "property-section" data-testid = "scene-simplify-settings-workbench" > < h3 > Render Simplify < / h3 > < output > Subdivision { activeScene . simplifySettings . subsurfViewport } / { activeScene . simplifySettings . subsurfRender } · Particles { activeScene . simplifySettings . particlesViewport . toFixed ( 2 ) } / { activeScene . simplifySettings . particlesRender . toFixed ( 2 ) } · Volumes { activeScene . simplifySettings . volumes . toFixed ( 2 ) } < / output > < div className = "property-actions" > < button type = "button" onClick = { ( ) = > onCommand ( animationRenderCommands . setSceneSimplifySettings ( activeScene . id , { subsurfViewport : Math.max ( 0 , activeScene . simplifySettings ! . subsurfViewport - 1 ) } ) ) } > 降 低 细 分 < / button > < button type = "button" onClick = { ( ) = > onCommand ( animationRenderCommands . setSceneSimplifySettings ( activeScene . id , { particlesViewport : Math.max ( 0 , activeScene . simplifySettings ! . particlesViewport - 0.1 ) , volumes : Math.max ( 0 , activeScene . simplifySettings ! . volumes - 0.1 ) } ) ) } > 降 低 复 杂 度 < / button > < / div > < / div > : null }
{ activeFont ? < div className = "property-section" data-testid = "font-workbench" > < h3 > Font < / h3 > < label > Text < textarea aria-label = "Font body" value = { fontBody } maxLength = { 65536 } onChange = { ( event ) = > setFontBody ( event . target . value ) } / > < / label > < div className = "property-actions" > < button type = "button" disabled = { fontBody === activeFont . text } onClick = { ( ) = > onCommand ( nonMeshGeometryCommands . setFontBody ( activeFont . id , fontBody ) ) } > Apply Text < / button > { activeFont . fontProperties ? < button type = "button" onClick = { ( ) = > onCommand ( nonMeshGeometryCommands . setFontProperties ( activeFont . id , { extrude : Math.min ( 1000 , activeFont . fontProperties ! . extrude + 0.01 ) } ) ) } > Increase Extrude < / button > : null } < / div > < output > { activeFont . text ? . length ? ? 0 } characters < / output > < / div > : null }
{ activeFont ? < >
< div className = "property-section" data-testid = "font-workbench" > < h3 > Font < / h3 > < label > Text < textarea aria-label = "Font body" value = { fontBody } maxLength = { 65536 } onChange = { ( event ) = > setFontBody ( event . target . value ) } / > < / label > < div className = "property-actions" > < button type = "button" disabled = { fontBody === activeFont . text } onClick = { ( ) = > onCommand ( nonMeshGeometryCommands . setFontBody ( activeFont . id , fontBody ) ) } > Apply Text < / button > { activeFont . fontProperties ? < button type = "button" onClick = { ( ) = > onCommand ( nonMeshGeometryCommands . setFontProperties ( activeFont . id , { extrude : Math.min ( 1000 , activeFont . fontProperties ! . extrude + 0.01 ) } ) ) } > Increase Extrude < / button > : null } < label className = "file-button" > Import TTF / OTF / PFB < input data-testid = "vfont-file-input" type = "file" accept = ".ttf,.otf,.pfb,font/ttf,font/otf,application/x-font-type1,application/x-font-pfb" aria-label = "Import TTF OTF PFB font" onChange = { ( event ) = > { const file = event . target . files ? . [ 0 ] ; if ( file ) onImportVFont ( file ) ; event . target . value = "" ; } } / > < / label > < / div > < output > { activeFont . text ? . length ? ? 0 } characters < / output > < / div >
< div className = "property-section" data-testid = "font-layout-workbench" data-command = "setFontAdvanced" > < h3 > Advanced Font Layout < / h3 > < label > Character < select aria-label = "Font character" value = { Math . min ( fontCharacterIndex , Math . max ( 0 , ( activeFont . fontCharacters ? . length ? ? 1 ) - 1 ) ) } onChange = { ( event ) = > { const index = Number ( event . target . value ) ; setFontCharacterIndex ( index ) ; const character = activeFont . fontCharacters ? . [ index ] ; if ( character ) { setFontCharacterKern ( character . kern ) ; setFontCharacterMaterial ( character . materialIndex ) ; setFontCharacterStyleFlags ( character . styleFlags ) ; } } } > { ( activeFont . fontCharacters ? . length ? activeFont . fontCharacters : [ { kern : 0 , materialIndex : 0 , styleFlags : 0 } ] ) . map ( ( _ , index ) = > < option key = { index } value = { index } > { index + 1 } < / option > ) } < / select > < / label > < label > Kern < input aria-label = "Font character kern" type = "number" step = "0.01" value = { fontCharacterKern } onChange = { ( event ) = > setFontCharacterKern ( Number ( event . target . value ) || 0 ) } / > < / label > < label > Material Index < input aria-label = "Font character material index" type = "number" min = "0" step = "1" value = { fontCharacterMaterial } onChange = { ( event ) = > setFontCharacterMaterial ( Math . max ( 0 , Number ( event . target . value ) || 0 ) ) } / > < / label > < label > Style Flags < input aria-label = "Font character style flags" type = "number" min = "0" step = "1" value = { fontCharacterStyleFlags } onChange = { ( event ) = > setFontCharacterStyleFlags ( Math . max ( 0 , Number ( event . target . value ) || 0 ) ) } / > < / label > < label > Text Box < select aria-label = "Font text box" value = { Math . min ( fontActiveTextBox , Math . max ( 0 , ( activeFont . fontTextBoxes ? . length ? ? 1 ) - 1 ) ) } onChange = { ( event ) = > { const index = Number ( event . target . value ) ; setFontActiveTextBox ( index ) ; const textBox = activeFont . fontTextBoxes ? . [ index ] ; if ( textBox ) { setFontTextBoxX ( textBox . x ) ; setFontTextBoxY ( textBox . y ) ; setFontTextBoxWidth ( textBox . width ) ; setFontTextBoxHeight ( textBox . height ) ; } } } > { ( activeFont . fontTextBoxes ? . length ? activeFont . fontTextBoxes : [ { x : 0 , y : 0 , width : 1 , height : 1 } ] ) . map ( ( _ , index ) = > < option key = { index } value = { index } > { index + 1 } < / option > ) } < / select > < / label > < div className = "property-actions" > < label > X < input aria-label = "Font text box X" type = "number" step = "0.01" value = { fontTextBoxX } onChange = { ( event ) = > setFontTextBoxX ( Number ( event . target . value ) || 0 ) } / > < / label > < label > Y < input aria-label = "Font text box Y" type = "number" step = "0.01" value = { fontTextBoxY } onChange = { ( event ) = > setFontTextBoxY ( Number ( event . target . value ) || 0 ) } / > < / label > < label > Width < input aria-label = "Font text box width" type = "number" min = "0" step = "0.01" value = { fontTextBoxWidth } onChange = { ( event ) = > setFontTextBoxWidth ( Math . max ( 0 , Number ( event . target . value ) || 0 ) ) } / > < / label > < label > Height < input aria-label = "Font text box height" type = "number" min = "0" step = "0.01" value = { fontTextBoxHeight } onChange = { ( event ) = > setFontTextBoxHeight ( Math . max ( 0 , Number ( event . target . value ) || 0 ) ) } / > < / label > < button type = "button" onClick = { applyFontAdvanced } > Apply Layout < / button > < / div > < output > { activeFont . fontCharacters ? . length ? ? 0 } character records · { activeFont . fontTextBoxes ? . length ? ? 0 } text boxes · active { activeFont . activeFontTextBox ? ? 0 } < / output > < / div >
< 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 }
{ 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 >
< div className = "property-section" > < h3 > Viewport Display < / h3 > < label > 显 示 颜 色 < span className = "swatch" / > < / label > < label > 可 见 性 < input aria-label = "可见性" type = "checkbox" defaultChecked / > < / label > < / div >
{ activeCurve && curveTopologyOperatorGate ( "TOGGLE_CYCLIC" ) . status === "READY" ? < div className = "property-section" data-testid = "curve-topology-editor" data-ready-operators = "TOGGLE_CYCLIC" data-curve-id = { activeCurve . id } data-cyclic-u = { ( activeCurve . cyclicU ? ? [ ] ) . join ( "," ) } data-spline-types = { ( activeCurve . splineTypes ? ? [ ] ) . join ( "," ) } data-point-count = { activeCurve . pointCount } > < h3 > Curve Topology < / h3 > < label > Spline < select aria-label = "Curve spline" value = { Math . min ( curveSplineIndex , Math . max ( 0 , activeCurve . splineCount - 1 ) ) } onChange = { ( event ) = > setCurveSplineIndex ( Number ( event . target . value ) ) } > { Array . from ( { length : activeCurve.splineCount } , ( _ , index ) = > < option key = { index } value = { index } > { index + 1 } { activeCurve . splineTypes ? . [ index ] ? ? "UNKNOWN" } < / option > ) } < / select > < / label > < label > Cyclic U < input aria-label = "Curve cyclic U" type = "checkbox" checked = { activeCurve . cyclicU ? . [ curveSplineIndex ] ? ? false } onChange = { toggleCurveCyclic } / > < / label > < output data-testid = "curve-cyclic-state" > { activeCurve . cyclicU ? . [ curveSplineIndex ] ? "Cyclic" : "Open" } < / output > { curveTopologyError ? < output role = "alert" > { curveTopologyError } < / output > : null } < / div > : null }
{ activeCurve ? < div className = "property-section" data-testid = "curve-advanced-workbench" data-command = "setCurveHandle,setCurveSplines" > < h3 > Advanced Curve < / h3 > < label > Handle point < select aria-label = "Curve handle point" value = { Math . min ( curvePointIndex , Math . max ( 0 , curvePointIndices . length - 1 ) ) } onChange = { ( event ) = > setCurvePointIndex ( Number ( event . target . value ) ) } > { curvePointIndices . map ( ( pointIndex , index ) = > < option key = { pointIndex } value = { index } > { pointIndex + 1 } < / option > ) } < / select > < / label > < label > Handle side < select aria-label = "Curve handle side" value = { curveHandleSide } onChange = { ( event ) = > setCurveHandleSide ( event . target . value as "LEFT" | "RIGHT" ) } > < option value = "LEFT" > Left < / option > < option value = "RIGHT" > Right < / option > < / select > < / label > < div className = "property-actions" > < button type = "button" disabled = { ! activeCurve . handlePoints || curvePointIndices . length === 0 } onClick = { nudgeCurveHandle } > Nudge Handle X < / button > < button type = "button" disabled = { activeCurve . splineCount === 0 } onClick = { ( ) = > applyCurveSplines ( ) } > Apply Current Splines < / button > < button type = "button" disabled = { activeCurve . splineCount === 0 } onClick = { ( ) = > applyCurveSplines ( "POLY" ) } > Set Selected Poly < / button > < button type = "button" disabled = { activeCurve . splineCount === 0 } onClick = { ( ) = > applyCurveSplines ( "BEZIER" ) } > Set Selected Bezier < / button > < button type = "button" disabled = { activeCurve . splineCount === 0 } onClick = { ( ) = > applyCurveSplines ( "NURBS" ) } > Set Selected NURBS < / button > < / div > < output > { activeCurve . pointCount } points · { activeCurve . splineCount } splines · handles { activeCurve . handlePoints ? . length ? ? 0 } < / output > < / div > : null }
{ activeSurface ? < div className = "property-section" data-testid = "surface-topology-workbench" data-command = "setSurfaceTopology" > < h3 > Advanced Surface < / h3 > < label > Spline < select aria-label = "Surface spline" value = { Math . min ( surfaceSplineIndex , Math . max ( 0 , ( activeSurface . splineDimensions ? . length ? ? 1 ) - 1 ) ) } onChange = { ( event ) = > setSurfaceSplineIndex ( Number ( event . target . value ) ) } > { ( activeSurface . splineDimensions ? ? [ ] ) . map ( ( dimension , index ) = > < option key = { index } value = { index } > { index + 1 } : { dimension . u } × { dimension . v } < / option > ) } < / select > < / label > < output > { activeSurface . pointCount } points · { activeSurface . splineDimensions ? . length ? ? 0 } spline surfaces · U { ( activeSurface . cyclicU ? ? [ ] ) . map ( ( value ) = > value ? "Cyclic" : "Open" ) . join ( ", " ) || "n/a" } · V { ( activeSurface . cyclicV ? ? [ ] ) . map ( ( value ) = > value ? "Cyclic" : "Open" ) . join ( ", " ) || "n/a" } < / output > < div className = "property-actions" > < button type = "button" disabled = { ! activeSurface . splineDimensions ? . length || ! activeSurface . controlPoints ? . length } onClick = { ( ) = > applySurfaceTopology ( "U" ) } > Toggle U Cyclic < / button > < button type = "button" disabled = { ! activeSurface . splineDimensions ? . length || ! activeSurface . controlPoints ? . length } onClick = { ( ) = > applySurfaceTopology ( "V" ) } > Toggle V Cyclic < / button > < button type = "button" disabled = { ! activeSurface . splineDimensions ? . length || ! activeSurface . controlPoints ? . length } onClick = { ( ) = > applySurfaceTopology ( ) } > Apply Current Topology < / button > < / div > < / div > : null }
{ activeMesh ? < div className = "property-section" > < h3 > UV Maps < / h3 > < label > 活 动 UV < select aria-label = "活动 UV Map" value = { activeMesh . activeUVMap ? ? "" } onChange = { ( event ) = > event . target . value && onCommand ( { type : "setActiveUVMap" , meshId : activeMesh.id , name : event.target.value } ) } > < option value = "" > None < / option > { activeMesh . uvLayers ? . map ( ( layer ) = > < option key = { layer . name } value = { layer . name } > { layer . name } < / option > ) } < / select > < / label > < div className = "property-actions" > < button type = "button" onClick = { ( ) = > onCommand ( { type : "createUVMap" , meshId : activeMesh.id , name : ` UVMap. ${ ( activeMesh . uvLayers ? . length ? ? 0 ) + 1 } ` } ) } > 新 增 UV < / button > < button type = "button" disabled = { selectedFaceIndices . length === 0 } onClick = { ( ) = > onCommand ( { type : "unwrapUV" , meshId : activeMesh.id , faceIndices : selectedFaceIndices , method : "PLANAR" } ) } > Planar < / button > < button type = "button" disabled = { selectedFaceIndices . length === 0 } onClick = { ( ) = > onCommand ( { type : "unwrapUV" , meshId : activeMesh.id , faceIndices : selectedFaceIndices , method : "CUBE" } ) } > Cube < / button > < / div > < / div > : null }
{ activeGreasePencil ? < div
className = "property-section"
@@ -1003,6 +1141,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
< 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" >
< 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 >
@@ -1108,6 +1247,9 @@ function OperatorSearch({ commands, onClose }: { commands: readonly OperatorComm
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 ( "" ) ;
useEffect ( ( ) = > {
if ( ! playing ) return undefined ;
const timer = window . setInterval ( ( ) = > {
@@ -1126,6 +1268,30 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
const animation = snapshot ? . animations . find ( ( candidate ) = > candidate . targetId === snapshot . activeObjectId ) ;
const actions = snapshot ? . animations . filter ( ( candidate ) = > candidate . targetId === snapshot . activeObjectId ) ? ? [ ] ;
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 ;
useEffect ( ( ) = > {
const nextTrack = nlaTracks . find ( ( track ) = > track . id === selectedNlaTrackId ) ? ? nlaTracks . find ( ( track ) = > track . selected ) ? ? nlaTracks [ 0 ] ? ? null ;
const nextTrackId = nextTrack ? . id ? ? null ;
if ( nextTrackId !== selectedNlaTrackId ) setSelectedNlaTrackId ( nextTrackId ) ;
const nextStrip = nextTrack ? . strips . find ( ( strip ) = > strip . id === selectedNlaStripId ) ? ? nextTrack ? . strips . find ( ( strip ) = > strip . selected ) ? ? nextTrack ? . strips [ 0 ] ? ? null ;
const nextStripId = nextStrip ? . id ? ? null ;
if ( nextStripId !== selectedNlaStripId ) setSelectedNlaStripId ( nextStripId ) ;
} , [ nlaTracks , selectedNlaStripId , selectedNlaTrackId ] ) ;
useEffect ( ( ) = > {
setNlaFrameStartDraft ( selectedNlaStrip ? String ( selectedNlaStrip . frameStart ) : "" ) ;
} , [ selectedNlaStrip ? . frameStart , selectedNlaStrip ? . id ] ) ;
const commitNlaFrameStart = ( ) : void = > {
if ( ! snapshot ? . activeObjectId || ! selectedNlaTrack || ! selectedNlaStrip ) return ;
const frameStart = Number ( nlaFrameStartDraft ) ;
if ( ! Number . isFinite ( frameStart ) ) {
setNlaFrameStartDraft ( String ( selectedNlaStrip . frameStart ) ) ;
return ;
}
if ( frameStart !== selectedNlaStrip . frameStart ) {
onCommand ( animationRenderCommands . moveNLAStrip ( snapshot . activeObjectId , selectedNlaTrack . id , selectedNlaStrip . id , frameStart , snapshot . revision ) ) ;
}
} ;
const activeNode = snapshot ? . nodes . find ( ( candidate ) = > candidate . id === snapshot . activeObjectId ) ;
const greasePencil = snapshot ? . greasePencils ? . find ( ( candidate ) = > candidate . id === activeNode ? . dataId ) ;
const keyframes = [ . . . new Set ( greasePencil
@@ -1139,6 +1305,7 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
< 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 >
{ 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 >
) ;
}
@@ -1154,6 +1321,7 @@ export function App() {
const [ imeComposition , setIMEComposition ] = useState ( createIMECompositionState ) ;
const [ keyboardObservation , setKeyboardObservation ] = useState < KeyboardObservation | null > ( null ) ;
const [ snapshot , setSnapshot ] = useState < SceneSnapshotIR | null > ( null ) ;
const [ editorWorkflow , setEditorWorkflow ] = useState < EditorWorkflowIR | null > ( null ) ;
const [ selectedObjectIds , setSelectedObjectIds ] = useState < Set < string > > ( ( ) = > new Set ( ) ) ;
const [ meshSelection , setMeshSelection ] = useState < MeshEditSelection > ( { meshId : null , mode : "FACE" , indices : new Set ( ) } ) ;
const [ greasePencilSelection , setGreasePencilSelection ] = useState < GreasePencilSelectionStateIR | null > ( null ) ;
@@ -1240,6 +1408,19 @@ export function App() {
URL . revokeObjectURL ( url ) ;
} ;
const dispatchUI = ( command : UICommand ) = > setUIState ( ( state ) = > reduceUICommand ( state , command ) ) ;
useEffect ( ( ) = > {
if ( ! snapshot ? . editorWorkflow || snapshot . editorWorkflowStatus !== "AVAILABLE" ) {
setEditorWorkflow ( null ) ;
return ;
}
try {
setEditorWorkflow ( parseEditorWorkflow ( snapshot . editorWorkflow ) ) ;
}
catch {
// The Worker validates the same payload before publishing the snapshot.
setEditorWorkflow ( null ) ;
}
} , [ snapshot ] ) ;
const closeOperatorSearch = ( ) : void = > {
dispatchUI ( { type : "toggleOperatorSearch" , open : false } ) ;
window . requestAnimationFrame ( ( ) = > operatorSearchTriggerRef . current ? . focus ( ) ) ;
@@ -1556,7 +1737,7 @@ export function App() {
// Cache invalidation is best effort and never blocks an engine command.
}
} ;
const applyEditCommand = async ( command : WebEngineEditCommand ) : Promise < boolean > = > {
const applyEditCommand = async ( command : WebEngineEditCommand , onApplied ? : ( snapshot : SceneSnapshotIR ) = > void ): Promise < boolean > = > {
const client = webClientRef . current ;
if ( ! client || ! snapshot ) {
if ( command . type === "setFrame" ) setFrame ( command . frame ) ;
@@ -1585,6 +1766,7 @@ export function App() {
setPreview ( null ) ;
setLodLevels ( null ) ;
setSnapshot ( nextSnapshot ) ;
onApplied ? . ( nextSnapshot ) ;
setSelectedObjectIds ( new Set ( nextSnapshot . activeObjectId ? [ nextSnapshot . activeObjectId ] : [ ] ) ) ;
setGeometryBuffers ( result . geometryBuffers ) ;
setNonMeshGeometryBuffers ( result . nonMeshGeometryBuffers ? ? [ ] ) ;
@@ -1713,6 +1895,47 @@ export function App() {
bitmap ? . close ( ) ;
}
} ;
const importVFont = async ( file : File ) : Promise < void > = > {
const identity = beginUserAction ( "IMPORT" ) ;
let failureCode = "IMPORT_VFONT_FAILED" ;
try {
const extension = file . name . slice ( file . name . lastIndexOf ( "." ) ) . toLowerCase ( ) ;
const mimeType = extension === ".ttf"
? "font/ttf"
: extension === ".otf"
? "font/otf"
: extension === ".pfb"
? "application/x-font-type1"
: null ;
if ( ! mimeType ) throw new Error ( "TTF, OTF or PFB font files are required" ) ;
const storage = storageClientRef . current ;
if ( ! storage ) throw new Error ( "Storage worker is unavailable for font import" ) ;
const data = await file . arrayBuffer ( ) ;
const sourcePath = ` //fonts/ ${ file . name . replace ( /[\\/]/g , "_" ) } ` ;
const sha256 = await sha256Hex ( data ) ;
let appliedSnapshot : SceneSnapshotIR | null = null ;
failureCode = "IMPORT_VFONT_MAIN_TRANSACTION_FAILED" ;
const result = await importExternalVFontIntoMain ( {
projectId : projectIdRef.current ,
request : { sourcePath , mimeType , byteLength : data.byteLength , sha256 , data } ,
storage ,
engine : {
applyCommand : async ( command ) = > {
const applied = await applyEditCommand ( command , ( nextSnapshot ) = > { appliedSnapshot = nextSnapshot ; } ) ;
if ( ! applied || ! appliedSnapshot ) throw new Error ( "Blender Main did not return the imported font snapshot" ) ;
return { snapshot : appliedSnapshot } ;
} ,
} ,
} ) ;
succeedUserAction ( identity ) ;
setEngineStatus ( ` Font: imported ${ result . vfont . name } ( ${ extension . slice ( 1 ) . toUpperCase ( ) } ) ` ) ;
void refreshStorageBudget ( projectIdRef . current ) ;
}
catch ( error ) {
failUserAction ( identity , failureCode ) ;
setEngineStatus ( recordDiagnostic ( "ENGINE" , "COMMAND_FAILED" , error , { fileName : file.name } ) ) ;
}
} ;
const transformActive = ( tool : "translate" | "rotate" | "scale" , amount = 0.1 , axis : 0 | 1 | 2 = tool === "rotate" ? 2 : 0 , axisVector ? : [ number , number , number ] ) : void = > {
const activeNode = snapshot ? . nodes . find ( ( node ) = > node . id === snapshot . activeObjectId ) ;
if ( ! snapshot || ! activeNode ) return ;
@@ -2541,7 +2764,7 @@ export function App() {
] , IO_FORMAT_UI_REGISTRY ) . filter ( ( command ) = > ! command . ioFormat || resolveFreshIOFormatRuntimeRoute ( IO_FORMAT_RUNTIME_RECEIPTS , IO_FORMAT_RUNTIME_RECEIPT_EXPECTED , { format : command.ioFormat.format , operation : command.ioFormat.operation } ) . status === "READY" ) ;
return (
< main className = "blender-app" data-workspace = { workspace } data-ime-composing = { imeComposition . composing ? "true" : "false" } data-ime-revision = { imeComposition . revision } data-ime-last-event = { imeComposition . lastEvent } data-key-key = { keyboardObservation ? . key } data-key-code = { keyboardObservation ? . code } data-key-location = { keyboardObservation ? . location } data-key-modifiers = { ` ${ keyboardObservation ? . shiftKey ? "S" : "" } ${ keyboardObservation ? . ctrlKey ? "C" : "" } ${ keyboardObservation ? . altKey ? "A" : "" } ${ keyboardObservation ? . metaKey ? "M" : "" } ` } data-key-dead = { keyboardObservation ? . deadKey ? "true" : "false" } data-ui-revision = { uiState . context . revision }
< main className = "blender-app" data-workspace = { workspace } data-editor-workflow-status = { snapshot ? . editorWorkflowStatus ? ? "BLOCKED" } data-editor-workflow-workspace = { editorWorkflow ? . context . workspaceId } data-editor-workflow-area = { editorWorkflow ? . context . activeAreaId } data-editor-workflow-revision = { editorWorkflow ? . context . revision } data-ime-composing = { imeComposition . composing ? "true" : "false" } data-ime-revision = { imeComposition . revision } data-ime-last-event = { imeComposition . lastEvent } data-key-key = { keyboardObservation ? . key } data-key-code = { keyboardObservation ? . code } data-key-location = { keyboardObservation ? . location } data-key-modifiers = { ` ${ keyboardObservation ? . shiftKey ? "S" : "" } ${ keyboardObservation ? . ctrlKey ? "C" : "" } ${ keyboardObservation ? . altKey ? "A" : "" } ${ keyboardObservation ? . metaKey ? "M" : "" } ` } data-key-dead = { keyboardObservation ? . deadKey ? "true" : "false" } data-ui-revision = { uiState . context . revision }
data-user-action-import-status = { userActions . IMPORT . status } data-user-action-import-id = { userActions . IMPORT . identity ? . actionId } data-user-action-import-error = { userActions . IMPORT . errorCode ? ? undefined }
data-user-action-open-status = { userActions . OPEN . status } data-user-action-open-id = { userActions . OPEN . identity ? . actionId } data-user-action-open-error = { userActions . OPEN . errorCode ? ? undefined }
data-user-action-save-status = { userActions . SAVE . status } data-user-action-save-id = { userActions . SAVE . identity ? . actionId } data-user-action-save-error = { userActions . SAVE . errorCode ? ? undefined }
@@ -2589,7 +2812,7 @@ export function App() {
< 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 } 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 ) } 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 = "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 }