Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions editor/src/editor/layout/assets-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { FileInspectorObject } from "./inspector/file";
import { AssetBrowserGUIItem } from "./assets-browser/items/gui-item";
import { AssetBrowserHDRItem } from "./assets-browser/items/hdr-item";
import { AssetBrowserMeshItem } from "./assets-browser/items/mesh-item";
import { AssetBrowserSkeletonItem } from "./assets-browser/items/skeleton-item";
import { AssetBrowserSceneItem } from "./assets-browser/items/scene-item";
import { AssetBrowserImageItem } from "./assets-browser/items/image-item";
import { AssetBrowserMaterialItem } from "./assets-browser/items/material-item";
Expand All @@ -90,6 +91,7 @@ const ImageSelectable = createSelectable(AssetBrowserImageItem);
const SceneSelectable = createSelectable(AssetBrowserSceneItem);
const MaterialSelectable = createSelectable(AssetBrowserMaterialItem);
const CinematicSelectable = createSelectable(AssetBrowserCinematicItem);
const SkeletonSelectable = createSelectable(AssetBrowserSkeletonItem);

export interface IEditorAssetsBrowserProps {
/**
Expand Down Expand Up @@ -791,6 +793,10 @@ export class EditorAssetsBrowser extends Component<IEditorAssetsBrowserProps, IE
case ".babylon":
return <MeshSelectable {...props} />;

case ".bvh":
case ".BVH":
return <SkeletonSelectable {...props} />;

case ".material":
return <MaterialSelectable {...props} />;

Expand Down
6 changes: 5 additions & 1 deletion editor/src/editor/layout/assets-browser/items/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { toast } from "sonner";
import { VscJson } from "react-icons/vsc";
import { ImFinder } from "react-icons/im";
import { BiSolidFileCss } from "react-icons/bi";
import { GiCeilingLight } from "react-icons/gi";
import { GiCeilingLight, GiSkeletonInside } from "react-icons/gi";
import { GrStatusUnknown } from "react-icons/gr";
import { BsFiletypeMp3, BsFiletypeWav } from "react-icons/bs";
import { AiFillFileMarkdown, AiOutlineClose } from "react-icons/ai";
Expand Down Expand Up @@ -455,6 +455,10 @@ export class AssetsBrowserItem extends Component<IAssetsBrowserItemProps, IAsset
case ".bjseditor":
return <SiBabylondotjs size="64px" />;

case ".bvh":
case ".BVH":
return <GiSkeletonInside size="64px" />;

case ".ies":
return <GiCeilingLight size="64px" />;

Expand Down
87 changes: 87 additions & 0 deletions editor/src/editor/layout/assets-browser/items/skeleton-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { ReactNode } from "react";
import { extname, basename } from "path";

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

import { TransformNode, Tools, Debug, SceneLoader, Skeleton, Scene } from "babylonjs";

import { AssetsBrowserItem } from "./item";
import { ContextMenuItem } from "../../../../ui/shadcn/ui/context-menu";
import { UniqueNumber } from "../../../../tools/tools";

const DEFAULT_ANIMATION_FRAMES = 60;
const SKELETON_VIEWER_SCALE = 1;
const SKELETON_CONTAINER_SUFFIX = "_Container";
export const SKELETON_CONTAINER_TYPE = "SkeletonContainer";

export class AssetBrowserSkeletonItem extends AssetsBrowserItem {
/**
* @override
*/
protected getContextMenuContent(): ReactNode {
return (
<>
<ContextMenuItem className="flex items-center gap-2" onClick={() => this._handleLoadSkeletonToScene()}>
<GiSkeletonInside className="w-5 h-5" /> Load to Scene
</ContextMenuItem>
</>
);
}

/**
* @override
*/
protected getIcon(): ReactNode {
return <GiSkeletonInside size="64px" />;
}

private async _handleLoadSkeletonToScene(): Promise<void> {
const scene = this.props.editor.layout.preview.scene;
try {
const result = await SceneLoader.ImportMeshAsync("", "", this.props.absolutePath, scene);

const skeleton = result.skeletons[0];

if (skeleton) {
// Define the skeleton name from the file name
const extension = extname(this.props.absolutePath).toLowerCase();
if (extension === ".bvh") {
const skeletonName = basename(this.props.absolutePath, extension);
skeleton.name = skeletonName;
}

this._createSkeletonContainer(skeleton, scene);
this.props.editor.layout.graph.refresh();
}
} catch (error) {
this.props.editor.layout.console.error(`Failed to load BVH file: ${error}`);
}
}

/**
* Creates a skeleton container with viewer and animation
*/
private _createSkeletonContainer(skeleton: Skeleton, scene: Scene): void {
const skeletonContainer = new TransformNode(`${skeleton.name}${SKELETON_CONTAINER_SUFFIX}`, scene);
skeletonContainer.id = Tools.RandomId();
skeletonContainer.uniqueId = UniqueNumber.Get();

const viewer = new Debug.SkeletonViewer(skeleton, null, scene, false, SKELETON_VIEWER_SCALE, {
displayMode: Debug.SkeletonViewer.DISPLAY_SPHERE_AND_SPURS,
});
viewer.isEnabled = true;

// Store the skeleton reference and viewer in the container's metadata for easy access
skeletonContainer.metadata = {
...skeletonContainer.metadata,
skeleton: skeleton,
viewer: viewer,
type: SKELETON_CONTAINER_TYPE,
};

const highestFrame = skeleton.bones[0]?.animations[0]?.getHighestFrame() ?? DEFAULT_ANIMATION_FRAMES;
scene.beginAnimation(skeleton, 0, highestFrame, true);

this.props.editor.layout.console.log(`Loaded skeleton: ${skeleton.name} with ${skeleton.bones.length} bones`);
}
}
160 changes: 153 additions & 7 deletions editor/src/editor/layout/graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Button, Tree, TreeNodeInfo } from "@blueprintjs/core";

import { FaLink } from "react-icons/fa6";
import { IoMdCube } from "react-icons/io";
import { GiSparkles } from "react-icons/gi";
import { GiSparkles, GiSkeletonInside } from "react-icons/gi";
import { BsSoundwave } from "react-icons/bs";
import { AiOutlinePlus } from "react-icons/ai";
import { HiSpeakerWave } from "react-icons/hi2";
Expand All @@ -12,9 +12,10 @@ import { MdOutlineQuestionMark } from "react-icons/md";
import { HiOutlineCubeTransparent } from "react-icons/hi";
import { IoCheckmark, IoSparklesSharp } from "react-icons/io5";
import { SiAdobeindesign, SiBabylondotjs } from "react-icons/si";
import { PiBoneLight } from "react-icons/pi";

import { AdvancedDynamicTexture } from "babylonjs-gui";
import { BaseTexture, Node, Scene, Sound, Tools, IParticleSystem, ParticleSystem } from "babylonjs";
import { BaseTexture, Node, Scene, Sound, Tools, IParticleSystem, ParticleSystem, Skeleton } from "babylonjs";

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

Expand Down Expand Up @@ -47,12 +48,13 @@ import {
isCamera,
isCollisionInstancedMesh,
isCollisionMesh,
isEditorCamera,
isInstancedMesh,
isLight,
isMesh,
isNode,
isTransformNode,
isSkeleton,
isBone,
} from "../../tools/guards/nodes";
import {
onNodeModifiedObservable,
Expand All @@ -69,6 +71,7 @@ import { EditorGraphContextMenu } from "./graph/graph";
import { getMeshCommands } from "../dialogs/command-palette/mesh";
import { getLightCommands } from "../dialogs/command-palette/light";
import { getCameraCommands } from "../dialogs/command-palette/camera";
import { SKELETON_CONTAINER_TYPE } from "./assets-browser/items/skeleton-item";

export interface IEditorGraphProps {
/**
Expand Down Expand Up @@ -262,8 +265,19 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
if (this.state.showOnlyDecals) {
nodes.push(...scene.meshes.filter((mesh) => mesh.metadata?.decal).map((mesh) => this._parseSceneNode(mesh, true)));
}
} else {
nodes = scene.rootNodes.filter((n) => !isEditorCamera(n)).map((n) => this._parseSceneNode(n));
}

// Add skeleton containers (TransformNodes that contain skeletons) to avoid duplication
const skeletonContainers = scene.transformNodes.filter((transformNode) => {
return transformNode.metadata?.type === SKELETON_CONTAINER_TYPE;
});

// Add skeleton containers to the graph
if (skeletonContainers.length > 0) {
const containerNodes = skeletonContainers.map((container) => {
return this._parseSkeletonContainerNode(container);
});
nodes.push(...containerNodes);
}

const guiNode = this._parseGuiNode(scene);
Expand Down Expand Up @@ -295,8 +309,25 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
* become unselected to have only the given node selected. All parents are expanded.
* @param node defines the reference tot the node to select in the graph.
*/
public setSelectedNode(node: Node | Sound | IParticleSystem): void {
let source = isSound(node) ? node["_connectedTransformNode"] : isAnyParticleSystem(node) ? node.emitter : node;
public setSelectedNode(node: Node | Sound | IParticleSystem | Skeleton): void {
let source: Node | null = null;

if (isSound(node)) {
source = node["_connectedTransformNode"];
} else if (isAnyParticleSystem(node)) {
if (isNode(node.emitter)) {
source = node.emitter;
}
} else if (isSkeleton(node)) {
// For skeletons, we don't have a parent to expand, just select the skeleton
this._forEachNode(this.state.nodes, (n) => {
n.isSelected = n.nodeData === node;
});
this.setState({ nodes: this.state.nodes });
return;
} else {
source = node;
}

if (!source) {
return;
Expand Down Expand Up @@ -586,6 +617,66 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
return rootSoundNode;
}

private _parseSkeletonNode(skeleton: Skeleton): TreeNodeInfo | null {
if (!skeleton.name.toLowerCase().includes(this.state.search.toLowerCase())) {
return null;
}

const info = {
id: skeleton.id,
nodeData: skeleton,
isSelected: false,
childNodes: [],
hasCaret: false,
icon: this._getSkeletonIconComponent(skeleton),
label: this._getNodeLabelComponent(skeleton, skeleton.name),
} as TreeNodeInfo;

if (skeleton.bones.length > 0) {
info.childNodes = skeleton.bones.map((bone) => this._parseBoneNode(bone)).filter((b) => b !== null) as TreeNodeInfo[];
info.hasCaret = true;
}

this._forEachNode(this.state.nodes, (n) => {
if (n.id === info.id) {
info.isSelected = n.isSelected;
info.isExpanded = n.isExpanded;
}
});

return info;
}

private _parseBoneNode(bone: any): TreeNodeInfo | null {
if (!bone.name.toLowerCase().includes(this.state.search.toLowerCase())) {
return null;
}

const info = {
id: bone.id,
nodeData: bone,
isSelected: false,
childNodes: [],
hasCaret: false,
icon: this._getBoneIconComponent(bone),
label: this._getNodeLabelComponent(bone, bone.name),
} as TreeNodeInfo;

if (bone.children && bone.children.length > 0) {
info.childNodes = bone.children.map((childBone: any) => this._parseBoneNode(childBone)).filter((b) => b !== null) as TreeNodeInfo[];
info.hasCaret = true;
}

this._forEachNode(this.state.nodes, (n) => {
if (n.id === info.id) {
info.isSelected = n.isSelected;
info.isExpanded = n.isExpanded;
}
});

return info;
}

private _getSoundNode(sound: Sound): TreeNodeInfo {
const info = {
nodeData: sound,
Expand Down Expand Up @@ -674,6 +765,38 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
return rootGuiNode;
}

private _parseSkeletonContainerNode(container: Node): TreeNodeInfo {
const info = {
id: container.id,
nodeData: container,
isSelected: false,
childNodes: [],
hasCaret: false,
icon: this._getIcon(container),
label: this._getNodeLabelComponent(container, container.name),
} as TreeNodeInfo;

if (container.metadata?.type === SKELETON_CONTAINER_TYPE && container.metadata?.skeleton) {
const skeletonNode = this._parseSkeletonNode(container.metadata.skeleton);
if (skeletonNode) {
info.childNodes!.push(skeletonNode);
}
}

if (info.childNodes && info.childNodes.length > 0) {
info.hasCaret = true;
}

this._forEachNode(this.state.nodes, (n) => {
if (n.id === info.id) {
info.isSelected = n.isSelected;
info.isExpanded = n.isExpanded;
}
});

return info;
}

private _parseSceneNode(node: Node, noChildren?: boolean): TreeNodeInfo | null {
if ((isMesh(node) && (node._masterMesh || !isNodeVisibleInGraph(node))) || isCollisionMesh(node) || isCollisionInstancedMesh(node)) {
return null;
Expand Down Expand Up @@ -745,6 +868,13 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
info.childNodes?.push(this._getParticleSystemNode(particleSystem));
}
});

if (node.skeleton) {
const skeletonNode = this._parseSkeletonNode(node.skeleton);
if (skeletonNode) {
info.childNodes?.push(skeletonNode);
}
}
}

if (info.childNodes?.length) {
Expand Down Expand Up @@ -818,6 +948,14 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
);
}

private _getSkeletonIconComponent(skeleton: Skeleton): ReactNode {
return <div className="cursor-pointer opacity-100">{this._getIcon(skeleton)}</div>;
}

private _getBoneIconComponent(bone: any): ReactNode {
return <div className="cursor-pointer opacity-100">{this._getIcon(bone)}</div>;
}

private _getIcon(object: any): ReactNode {
if (isTransformNode(object)) {
return <HiOutlineCubeTransparent className="w-4 h-4" />;
Expand Down Expand Up @@ -855,6 +993,14 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
return <GiSparkles className="w-4 h-4" />;
}

if (isSkeleton(object)) {
return <GiSkeletonInside className="w-4 h-4" />;
}

if (isBone(object)) {
return <PiBoneLight className="w-4 h-4" />;
}

return <MdOutlineQuestionMark className="w-4 h-4" />;
}

Expand Down
Loading