Skip to content

Commit 7724e19

Browse files
committed
Add skeleton to graph
1 parent 44d9834 commit 7724e19

File tree

5 files changed

+478
-244
lines changed

5 files changed

+478
-244
lines changed
Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { ReactNode } from "react";
2+
import { extname, basename } from "path";
23

34
import { GiSkeletonInside } from "react-icons/gi";
45

5-
import { SceneLoader, Debug } from "babylonjs";
6+
import { TransformNode, Tools, Debug, SceneLoader, Skeleton, Scene } from "babylonjs";
67

78
import { AssetsBrowserItem } from "./item";
89
import { ContextMenuItem } from "../../../../ui/shadcn/ui/context-menu";
10+
import { UniqueNumber } from "../../../../tools/tools";
11+
12+
const DEFAULT_ANIMATION_FRAMES = 60;
13+
const SKELETON_VIEWER_SCALE = 1;
14+
const SKELETON_CONTAINER_SUFFIX = "_Container";
15+
export const SKELETON_CONTAINER_TYPE = "SkeletonContainer";
916

1017
export class AssetBrowserSkeletonItem extends AssetsBrowserItem {
1118
/**
@@ -28,19 +35,8 @@ export class AssetBrowserSkeletonItem extends AssetsBrowserItem {
2835
return <GiSkeletonInside size="64px" />;
2936
}
3037

31-
/**
32-
* @override
33-
*/
34-
protected async onDoubleClick(): Promise<void> {
35-
await this._handleLoadSkeletonToScene();
36-
}
37-
3838
private async _handleLoadSkeletonToScene(): Promise<void> {
3939
const scene = this.props.editor.layout.preview.scene;
40-
if (!scene) {
41-
return;
42-
}
43-
4440
try {
4541
// Load the BVH file using SceneLoader
4642
const result = await SceneLoader.ImportMeshAsync("", "", this.props.absolutePath, scene);
@@ -49,24 +45,50 @@ export class AssetBrowserSkeletonItem extends AssetsBrowserItem {
4945
const skeleton = result.skeletons[0];
5046

5147
if (skeleton) {
52-
// Create a skeleton viewer to visualize the skeleton
53-
const viewer = new Debug.SkeletonViewer(skeleton, null, scene, false, 1, {
54-
displayMode: Debug.SkeletonViewer.DISPLAY_SPHERE_AND_SPURS,
55-
});
56-
viewer.isEnabled = true;
57-
58-
// Start the animation if available
59-
const highestFrame = skeleton.bones[0]?.animations[0]?.getHighestFrame() ?? 60;
60-
scene.beginAnimation(skeleton, 0, highestFrame, true);
61-
62-
// Notify the user
63-
this.props.editor.layout.console.log(`Loaded skeleton: ${skeleton.name} with ${skeleton.bones.length} bones`);
64-
} else {
65-
this.props.editor.layout.console.warn("No skeleton found in the BVH file");
48+
const extension = extname(this.props.absolutePath).toLowerCase();
49+
if (extension === ".bvh") {
50+
const skeletonName = basename(this.props.absolutePath, extension);
51+
skeleton.name = skeletonName;
52+
}
53+
54+
// Ensure skeleton has a proper ID
55+
if (!skeleton.id) {
56+
skeleton.id = Tools.RandomId();
57+
}
58+
59+
// Create the skeleton container
60+
this._createSkeletonContainer(skeleton, scene);
61+
this.props.editor.layout.graph.refresh();
6662
}
6763
} catch (error) {
68-
console.error("Failed to load BVH file:", error);
6964
this.props.editor.layout.console.error(`Failed to load BVH file: ${error}`);
7065
}
7166
}
67+
68+
/**
69+
* Creates a skeleton container with viewer and animation
70+
*/
71+
private _createSkeletonContainer(skeleton: Skeleton, scene: Scene): void {
72+
const skeletonContainer = new TransformNode(`${skeleton.name}${SKELETON_CONTAINER_SUFFIX}`, scene);
73+
skeletonContainer.id = Tools.RandomId();
74+
skeletonContainer.uniqueId = UniqueNumber.Get();
75+
76+
const viewer = new Debug.SkeletonViewer(skeleton, null, scene, false, SKELETON_VIEWER_SCALE, {
77+
displayMode: Debug.SkeletonViewer.DISPLAY_SPHERE_AND_SPURS,
78+
});
79+
viewer.isEnabled = true;
80+
81+
// Store the skeleton reference and viewer in the container's metadata for easy access
82+
skeletonContainer.metadata = {
83+
...skeletonContainer.metadata,
84+
skeleton: skeleton,
85+
viewer: viewer,
86+
type: SKELETON_CONTAINER_TYPE,
87+
};
88+
89+
const highestFrame = skeleton.bones[0]?.animations[0]?.getHighestFrame() ?? DEFAULT_ANIMATION_FRAMES;
90+
scene.beginAnimation(skeleton, 0, highestFrame, true);
91+
92+
this.props.editor.layout.console.log(`Loaded skeleton: ${skeleton.name} with ${skeleton.bones.length} bones`);
93+
}
7294
}

editor/src/editor/layout/graph.tsx

Lines changed: 158 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Button, Tree, TreeNodeInfo } from "@blueprintjs/core";
33

44
import { FaLink } from "react-icons/fa6";
55
import { IoMdCube } from "react-icons/io";
6-
import { GiSparkles } from "react-icons/gi";
6+
import { GiSparkles, GiSkeletonInside } from "react-icons/gi";
77
import { BsSoundwave } from "react-icons/bs";
88
import { AiOutlinePlus } from "react-icons/ai";
99
import { HiSpeakerWave } from "react-icons/hi2";
@@ -12,9 +12,10 @@ import { MdOutlineQuestionMark } from "react-icons/md";
1212
import { HiOutlineCubeTransparent } from "react-icons/hi";
1313
import { IoCheckmark, IoSparklesSharp } from "react-icons/io5";
1414
import { SiAdobeindesign, SiBabylondotjs } from "react-icons/si";
15+
import { PiBoneLight } from "react-icons/pi";
1516

1617
import { AdvancedDynamicTexture } from "babylonjs-gui";
17-
import { BaseTexture, Node, Scene, Sound, Tools, IParticleSystem, ParticleSystem } from "babylonjs";
18+
import { BaseTexture, Node, Scene, Sound, Tools, IParticleSystem, ParticleSystem, Skeleton } from "babylonjs";
1819

1920
import { Editor } from "../main";
2021

@@ -47,12 +48,13 @@ import {
4748
isCamera,
4849
isCollisionInstancedMesh,
4950
isCollisionMesh,
50-
isEditorCamera,
5151
isInstancedMesh,
5252
isLight,
5353
isMesh,
5454
isNode,
5555
isTransformNode,
56+
isSkeleton,
57+
isBone,
5658
} from "../../tools/guards/nodes";
5759
import {
5860
onNodeModifiedObservable,
@@ -69,6 +71,7 @@ import { EditorGraphContextMenu } from "./graph/graph";
6971
import { getMeshCommands } from "../dialogs/command-palette/mesh";
7072
import { getLightCommands } from "../dialogs/command-palette/light";
7173
import { getCameraCommands } from "../dialogs/command-palette/camera";
74+
import { SKELETON_CONTAINER_TYPE } from "./assets-browser/items/skeleton-item";
7275

7376
export interface IEditorGraphProps {
7477
/**
@@ -262,8 +265,19 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
262265
if (this.state.showOnlyDecals) {
263266
nodes.push(...scene.meshes.filter((mesh) => mesh.metadata?.decal).map((mesh) => this._parseSceneNode(mesh, true)));
264267
}
265-
} else {
266-
nodes = scene.rootNodes.filter((n) => !isEditorCamera(n)).map((n) => this._parseSceneNode(n));
268+
}
269+
270+
// Add skeleton containers (TransformNodes that contain skeletons) to avoid duplication
271+
const skeletonContainers = scene.transformNodes.filter((transformNode) => {
272+
return transformNode.metadata?.type === SKELETON_CONTAINER_TYPE;
273+
});
274+
275+
// Add skeleton containers to the graph
276+
if (skeletonContainers.length > 0) {
277+
const containerNodes = skeletonContainers.map((container) => {
278+
return this._parseSkeletonContainerNode(container);
279+
});
280+
nodes.push(...containerNodes);
267281
}
268282

269283
const guiNode = this._parseGuiNode(scene);
@@ -295,8 +309,25 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
295309
* become unselected to have only the given node selected. All parents are expanded.
296310
* @param node defines the reference tot the node to select in the graph.
297311
*/
298-
public setSelectedNode(node: Node | Sound | IParticleSystem): void {
299-
let source = isSound(node) ? node["_connectedTransformNode"] : isAnyParticleSystem(node) ? node.emitter : node;
312+
public setSelectedNode(node: Node | Sound | IParticleSystem | Skeleton): void {
313+
let source: Node | null = null;
314+
315+
if (isSound(node)) {
316+
source = node["_connectedTransformNode"];
317+
} else if (isAnyParticleSystem(node)) {
318+
if (isNode(node.emitter)) {
319+
source = node.emitter;
320+
}
321+
} else if (isSkeleton(node)) {
322+
// For skeletons, we don't have a parent to expand, just select the skeleton
323+
this._forEachNode(this.state.nodes, (n) => {
324+
n.isSelected = n.nodeData === node;
325+
});
326+
this.setState({ nodes: this.state.nodes });
327+
return;
328+
} else {
329+
source = node;
330+
}
300331

301332
if (!source) {
302333
return;
@@ -586,6 +617,68 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
586617
return rootSoundNode;
587618
}
588619

620+
private _parseSkeletonNode(skeleton: Skeleton): TreeNodeInfo | null {
621+
if (!skeleton.name.toLowerCase().includes(this.state.search.toLowerCase())) {
622+
return null;
623+
}
624+
625+
const info = {
626+
id: skeleton.id,
627+
nodeData: skeleton,
628+
isSelected: false,
629+
childNodes: [],
630+
hasCaret: false,
631+
icon: this._getSkeletonIconComponent(skeleton),
632+
label: this._getNodeLabelComponent(skeleton, skeleton.name),
633+
} as TreeNodeInfo;
634+
635+
// Add bones as children
636+
if (skeleton.bones.length > 0) {
637+
info.childNodes = skeleton.bones.map((bone) => this._parseBoneNode(bone)).filter((b) => b !== null) as TreeNodeInfo[];
638+
info.hasCaret = true;
639+
}
640+
641+
this._forEachNode(this.state.nodes, (n) => {
642+
if (n.id === info.id) {
643+
info.isSelected = n.isSelected;
644+
info.isExpanded = n.isExpanded;
645+
}
646+
});
647+
648+
return info;
649+
}
650+
651+
private _parseBoneNode(bone: any): TreeNodeInfo | null {
652+
if (!bone.name.toLowerCase().includes(this.state.search.toLowerCase())) {
653+
return null;
654+
}
655+
656+
const info = {
657+
id: bone.id,
658+
nodeData: bone,
659+
isSelected: false,
660+
childNodes: [],
661+
hasCaret: false,
662+
icon: this._getBoneIconComponent(bone),
663+
label: this._getNodeLabelComponent(bone, bone.name),
664+
} as TreeNodeInfo;
665+
666+
// Add child bones as children
667+
if (bone.children && bone.children.length > 0) {
668+
info.childNodes = bone.children.map((childBone: any) => this._parseBoneNode(childBone)).filter((b) => b !== null) as TreeNodeInfo[];
669+
info.hasCaret = true;
670+
}
671+
672+
this._forEachNode(this.state.nodes, (n) => {
673+
if (n.id === info.id) {
674+
info.isSelected = n.isSelected;
675+
info.isExpanded = n.isExpanded;
676+
}
677+
});
678+
679+
return info;
680+
}
681+
589682
private _getSoundNode(sound: Sound): TreeNodeInfo {
590683
const info = {
591684
nodeData: sound,
@@ -674,6 +767,40 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
674767
return rootGuiNode;
675768
}
676769

770+
private _parseSkeletonContainerNode(container: Node): TreeNodeInfo {
771+
const info = {
772+
id: container.id,
773+
nodeData: container,
774+
isSelected: false,
775+
childNodes: [],
776+
hasCaret: false,
777+
icon: this._getIcon(container),
778+
label: this._getNodeLabelComponent(container, container.name),
779+
} as TreeNodeInfo;
780+
781+
// Add skeleton as child
782+
if (container.metadata?.type === "SkeletonContainer" && container.metadata?.skeleton) {
783+
const skeletonNode = this._parseSkeletonNode(container.metadata.skeleton);
784+
if (skeletonNode) {
785+
info.childNodes!.push(skeletonNode);
786+
}
787+
}
788+
789+
// Set hasCaret if we have children
790+
if (info.childNodes && info.childNodes.length > 0) {
791+
info.hasCaret = true;
792+
}
793+
794+
this._forEachNode(this.state.nodes, (n) => {
795+
if (n.id === info.id) {
796+
info.isSelected = n.isSelected;
797+
info.isExpanded = n.isExpanded;
798+
}
799+
});
800+
801+
return info;
802+
}
803+
677804
private _parseSceneNode(node: Node, noChildren?: boolean): TreeNodeInfo | null {
678805
if ((isMesh(node) && (node._masterMesh || !isNodeVisibleInGraph(node))) || isCollisionMesh(node) || isCollisionInstancedMesh(node)) {
679806
return null;
@@ -745,6 +872,14 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
745872
info.childNodes?.push(this._getParticleSystemNode(particleSystem));
746873
}
747874
});
875+
876+
// Handle skeleton
877+
if (node.skeleton) {
878+
const skeletonNode = this._parseSkeletonNode(node.skeleton);
879+
if (skeletonNode) {
880+
info.childNodes?.push(skeletonNode);
881+
}
882+
}
748883
}
749884

750885
if (info.childNodes?.length) {
@@ -818,6 +953,14 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
818953
);
819954
}
820955

956+
private _getSkeletonIconComponent(skeleton: Skeleton): ReactNode {
957+
return <div className="cursor-pointer opacity-100">{this._getIcon(skeleton)}</div>;
958+
}
959+
960+
private _getBoneIconComponent(bone: any): ReactNode {
961+
return <div className="cursor-pointer opacity-100">{this._getIcon(bone)}</div>;
962+
}
963+
821964
private _getIcon(object: any): ReactNode {
822965
if (isTransformNode(object)) {
823966
return <HiOutlineCubeTransparent className="w-4 h-4" />;
@@ -855,6 +998,14 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
855998
return <GiSparkles className="w-4 h-4" />;
856999
}
8571000

1001+
if (isSkeleton(object)) {
1002+
return <GiSkeletonInside className="w-4 h-4" />;
1003+
}
1004+
1005+
if (isBone(object)) {
1006+
return <PiBoneLight className="w-4 h-4" />;
1007+
}
1008+
8581009
return <MdOutlineQuestionMark className="w-4 h-4" />;
8591010
}
8601011

0 commit comments

Comments
 (0)