Skip to content

Commit b482ee1

Browse files
authored
feat: Add context menu item to export scene and nodes (#604)
1 parent 717d538 commit b482ee1

File tree

2 files changed

+276
-0
lines changed

2 files changed

+276
-0
lines changed
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { Node, SceneSerializer } from "babylonjs";
2+
3+
import { saveSingleFileDialog } from "../../../tools/dialog";
4+
import { writeJSON } from "fs-extra";
5+
import { toast } from "sonner";
6+
7+
import { Editor } from "../../main";
8+
import filenamify from "filenamify/filenamify";
9+
10+
const JSON_CONFIG = {
11+
spaces: 4,
12+
encoding: "utf8",
13+
};
14+
/**
15+
* Exports the entire scene to a .babylon file.
16+
* @param editor defines the reference to the editor used to get the scene.
17+
*/
18+
export async function exportScene(editor: Editor): Promise<void> {
19+
const filePath = saveSingleFileDialog({
20+
title: "Export Scene",
21+
filters: [{ name: "Babylon Scene Files", extensions: ["babylon"] }],
22+
defaultPath: "scene.babylon",
23+
});
24+
25+
if (!filePath) {
26+
return;
27+
}
28+
29+
try {
30+
const scene = editor.layout.preview.scene;
31+
const data = await SceneSerializer.SerializeAsync(scene);
32+
33+
await writeJSON(filePath, data, JSON_CONFIG);
34+
35+
editor.layout.console.log(`Scene exported successfully to ${filePath}`);
36+
toast.success(`Scene exported successfully to ${filePath}`);
37+
} catch (e) {
38+
if (e instanceof Error) {
39+
editor.layout.console.error(`Error exporting scene: ${e.message}`);
40+
toast.error("Error exporting scene");
41+
}
42+
}
43+
}
44+
45+
/**
46+
* Exports a specific node and all its children to a .babylon file.
47+
* @param editor defines the reference to the editor used to get the scene.
48+
* @param node defines the node to export along with its descendants.
49+
*/
50+
export async function exportNode(editor: Editor, node: Node): Promise<void> {
51+
const filePath = saveSingleFileDialog({
52+
title: "Export Node",
53+
filters: [{ name: "Babylon Scene Files", extensions: ["babylon"] }],
54+
defaultPath: `${filenamify(node.name)}.babylon`,
55+
});
56+
57+
if (!filePath) {
58+
return;
59+
}
60+
61+
try {
62+
const scene = editor.layout.preview.scene;
63+
64+
// Get all nodes that should be included (selected node + descendants)
65+
const nodesToInclude = new Set<Node>();
66+
nodesToInclude.add(node);
67+
68+
// Add all descendants
69+
const descendants = node.getDescendants(false);
70+
descendants.forEach((descendant) => nodesToInclude.add(descendant));
71+
72+
// Store original doNotSerialize values
73+
const originalDoNotSerialize = new Map<Node, boolean>();
74+
75+
exportMeshes(scene, nodesToInclude, originalDoNotSerialize);
76+
exportLights(scene, nodesToInclude, originalDoNotSerialize);
77+
exportCameras(scene, nodesToInclude, originalDoNotSerialize);
78+
exportTransformNodes(scene, nodesToInclude, originalDoNotSerialize);
79+
80+
const originalParticleSystems = exportParticleSystems(scene, nodesToInclude);
81+
const originalSoundTracks = exportSounds(scene, nodesToInclude);
82+
83+
// Serialize the filtered scene
84+
const data = await SceneSerializer.SerializeAsync(scene);
85+
86+
// Restore original scene state
87+
restoreSceneState(scene, originalDoNotSerialize, {
88+
originalParticleSystems,
89+
originalSoundTracks
90+
});
91+
92+
await writeJSON(filePath, data, JSON_CONFIG);
93+
94+
editor.layout.console.log(`Node exported successfully to ${filePath}`);
95+
toast.success(`Node exported successfully to ${filePath}`);
96+
} catch (e) {
97+
if (e instanceof Error) {
98+
editor.layout.console.error(`Error exporting node: ${e.message}`);
99+
toast.error("Error exporting node");
100+
}
101+
}
102+
}
103+
104+
/**
105+
* Restores the original scene state after export.
106+
* @param scene The scene to restore
107+
* @param originalDoNotSerialize Map containing original serialization states
108+
* @param exportData Data needed for restoration
109+
*/
110+
function restoreSceneState(
111+
scene: any,
112+
originalDoNotSerialize: Map<Node, boolean>,
113+
exportData: { originalParticleSystems: any[]; originalSoundTracks: any[] }
114+
): void {
115+
// Restore original doNotSerialize values
116+
originalDoNotSerialize.forEach((value, node) => {
117+
node.doNotSerialize = value;
118+
});
119+
120+
// Restore particle systems
121+
scene.particleSystems.length = 0;
122+
exportData.originalParticleSystems.forEach((ps: any) =>
123+
scene.particleSystems.push(ps)
124+
);
125+
126+
// Restore soundtracks
127+
scene.soundTracks = exportData.originalSoundTracks;
128+
}
129+
130+
/**
131+
* Configures mesh nodes for export.
132+
* @param scene The scene containing the meshes
133+
* @param nodesToInclude Set of nodes to include in the export
134+
* @param originalDoNotSerialize Map to store original serialization state
135+
*/
136+
function exportMeshes(
137+
scene: any,
138+
nodesToInclude: Set<Node>,
139+
originalDoNotSerialize: Map<Node, boolean>
140+
): void {
141+
scene.meshes.forEach((mesh: Node) => {
142+
originalDoNotSerialize.set(mesh, mesh.doNotSerialize);
143+
mesh.doNotSerialize = !nodesToInclude.has(mesh);
144+
});
145+
}
146+
147+
/**
148+
* Configures light nodes for export.
149+
* @param scene The scene containing the lights
150+
* @param nodesToInclude Set of nodes to include in the export
151+
* @param originalDoNotSerialize Map to store original serialization state
152+
*/
153+
function exportLights(
154+
scene: any,
155+
nodesToInclude: Set<Node>,
156+
originalDoNotSerialize: Map<Node, boolean>
157+
): void {
158+
scene.lights.forEach((light: Node) => {
159+
originalDoNotSerialize.set(light, light.doNotSerialize);
160+
light.doNotSerialize = !nodesToInclude.has(light);
161+
});
162+
}
163+
164+
/**
165+
* Configures camera nodes for export.
166+
* @param scene The scene containing the cameras
167+
* @param nodesToInclude Set of nodes to include in the export
168+
* @param originalDoNotSerialize Map to store original serialization state
169+
*/
170+
function exportCameras(
171+
scene: any,
172+
nodesToInclude: Set<Node>,
173+
originalDoNotSerialize: Map<Node, boolean>
174+
): void {
175+
scene.cameras.forEach((camera: Node) => {
176+
originalDoNotSerialize.set(camera, camera.doNotSerialize);
177+
camera.doNotSerialize = !nodesToInclude.has(camera);
178+
});
179+
}
180+
181+
/**
182+
* Configures transform nodes for export.
183+
* @param scene The scene containing the transform nodes
184+
* @param nodesToInclude Set of nodes to include in the export
185+
* @param originalDoNotSerialize Map to store original serialization state
186+
*/
187+
function exportTransformNodes(
188+
scene: any,
189+
nodesToInclude: Set<Node>,
190+
originalDoNotSerialize: Map<Node, boolean>
191+
): void {
192+
scene.transformNodes.forEach((transformNode: Node) => {
193+
originalDoNotSerialize.set(transformNode, transformNode.doNotSerialize);
194+
transformNode.doNotSerialize = !nodesToInclude.has(transformNode);
195+
});
196+
}
197+
198+
/**
199+
* Filters particle systems for export based on their attached nodes.
200+
* @param scene The scene containing the particle systems
201+
* @param nodesToInclude Set of nodes to include in the export
202+
* @returns The original array of particle systems (for restoration)
203+
*/
204+
function exportParticleSystems(scene: any, nodesToInclude: Set<Node>): Array<any> {
205+
// Save original particle systems
206+
const originalParticleSystems = scene.particleSystems.slice();
207+
208+
// Filter particle systems to only include those attached to our nodes
209+
const particlesToKeep = originalParticleSystems.filter((ps: any) => {
210+
const emitter = ps.emitter;
211+
return emitter && nodesToInclude.has(emitter as Node);
212+
});
213+
214+
// Replace the scene's particle systems with only those we want to include
215+
scene.particleSystems.length = 0;
216+
particlesToKeep.forEach((ps: any) => scene.particleSystems.push(ps));
217+
218+
return originalParticleSystems;
219+
}
220+
221+
/**
222+
* Filters sounds for export based on their attached nodes.
223+
* @param scene The scene containing the sounds
224+
* @param nodesToInclude Set of nodes to include in the export
225+
* @returns The original array of soundtracks (for restoration)
226+
*/
227+
function exportSounds(scene: any, nodesToInclude: Set<Node>): Array<any> {
228+
// Handle sounds - filter out sounds not attached to our nodes
229+
let originalSoundTracks: any[] = [];
230+
231+
if (scene.soundTracks) {
232+
// Store original soundtracks to restore later
233+
originalSoundTracks = scene.soundTracks.slice();
234+
235+
// Filter each soundtrack to only include sounds attached to our nodes
236+
const filteredSoundTracks = scene.soundTracks.map((soundtrack: any) => {
237+
// Create a new sound collection with only the sounds attached to our nodes
238+
const filteredSoundCollection = soundtrack.soundCollection.filter((sound: any) => {
239+
if (sound.spatialSound && sound.metadata && sound.metadata.connectedMeshName) {
240+
// Check if the connected mesh name matches any of our nodes
241+
for (const meshNode of nodesToInclude) {
242+
if (meshNode.name === sound.metadata.connectedMeshName) {
243+
return true;
244+
}
245+
}
246+
}
247+
return false;
248+
});
249+
250+
// Replace the original sound collection with our filtered one
251+
soundtrack.soundCollection = filteredSoundCollection;
252+
return soundtrack;
253+
}).filter((st: any) => st.soundCollection.length > 0); // Remove empty soundtracks
254+
255+
// Replace scene soundtracks with our filtered ones
256+
scene.soundTracks = filteredSoundTracks;
257+
}
258+
259+
return originalSoundTracks;
260+
}

editor/src/editor/layout/graph/graph.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { EditorInspectorSwitchField } from "../inspector/fields/switch";
4747

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

50+
import { exportScene, exportNode } from "./export";
5051
import { removeNodes } from "./remove";
5152

5253
export interface IEditorGraphContextMenuProps extends PropsWithChildren {
@@ -95,11 +96,26 @@ export class EditorGraphContextMenu extends Component<IEditorGraphContextMenuPro
9596
<ContextMenuSeparator />
9697
</>
9798
)}
99+
100+
<ContextMenuSeparator />
101+
102+
{isNode(this.props.object) && !isScene(this.props.object) && (
103+
<ContextMenuItem onClick={() => exportNode(this.props.editor, this.props.object)}>Export Node (.babylon)</ContextMenuItem>
104+
)}
105+
106+
<ContextMenuSeparator />
98107
</>
99108
)}
100109

101110
{isSound(this.props.object) && <ContextMenuItem onClick={() => this._reloadSound()}>Reload</ContextMenuItem>}
102111

112+
{isScene(this.props.object) && (
113+
<>
114+
<ContextMenuItem onClick={() => exportScene(this.props.editor)}>Export Scene (.babylon)</ContextMenuItem>
115+
<ContextMenuSeparator />
116+
</>
117+
)}
118+
103119
{(isNode(this.props.object) || isScene(this.props.object)) && !isSceneLinkNode(this.props.object) && (
104120
<ContextMenuSub>
105121
<ContextMenuSubTrigger className="flex items-center gap-2">

0 commit comments

Comments
 (0)