Skip to content

Commit 12f15a4

Browse files
Julien Moreau-Mathisjulien-moreau
authored andcommitted
feat: add support of clone (keeping hierarchy) in graph component
1 parent 8a44256 commit 12f15a4

File tree

16 files changed

+537
-110
lines changed

16 files changed

+537
-110
lines changed

editor/src/editor/layout/animation.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ export class EditorAnimation extends Component<IEditorAnimationProps, IEditorAni
120120
* @param object defines the reference to the object that has been selected somewhere in the graph or the preview.
121121
*/
122122
public setEditedObject(object: unknown): void {
123+
if (!object) {
124+
return this.setState({ animatable: null });
125+
}
126+
123127
if (isNode(object) || isScene(object) || isAnyParticleSystem(object)) {
124128
if (!object.animations) {
125129
object.animations = [];

editor/src/editor/layout/graph.tsx

Lines changed: 72 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import {
3131
} from "../../ui/shadcn/ui/context-menu";
3232

3333
import { isSound } from "../../tools/guards/sound";
34+
import { cloneNode } from "../../tools/node/clone";
35+
import { registerUndoRedo } from "../../tools/undoredo";
3436
import { isDomTextInputFocused } from "../../tools/dom";
3537
import { isSceneLinkNode } from "../../tools/guards/scene";
3638
import { updateAllLights } from "../../tools/light/shadows";
@@ -356,92 +358,88 @@ export class EditorGraph extends Component<IEditorGraphProps, IEditorGraphState>
356358
}
357359

358360
const newNodes: (Node | ParticleSystem)[] = [];
361+
const nodesToCopy = this._objectsToCopy.map((n) => n.nodeData);
359362

360-
this._objectsToCopy.forEach((treeNode) => {
361-
const object = treeNode.nodeData;
363+
registerUndoRedo({
364+
executeRedo: true,
365+
action: () => {
366+
this.refresh();
362367

363-
let node: Node | ParticleSystem | null = null;
368+
waitNextAnimationFrame().then(() => {
369+
const firstNode = newNodes[0] ?? null;
370+
if (firstNode) {
371+
this.props.editor.layout.graph.setSelectedNode(firstNode);
364372

365-
if (isAbstractMesh(object)) {
366-
const suffix = "(Instanced Mesh)";
367-
const name = isInstancedMesh(object) ? object.name : `${object.name.replace(` ${suffix}`, "")} ${suffix}`;
368-
369-
const instance = (node = object.createInstance(name));
370-
instance.position.copyFrom(object.position);
371-
instance.rotation.copyFrom(object.rotation);
372-
instance.scaling.copyFrom(object.scaling);
373-
instance.rotationQuaternion = object.rotationQuaternion?.clone() ?? null;
374-
instance.parent = object.parent;
375-
376-
const collisionMesh = getCollisionMeshFor(instance.sourceMesh);
377-
collisionMesh?.updateInstances(instance.sourceMesh);
378-
}
379-
380-
if (isLight(object)) {
381-
const suffix = "(Clone)";
382-
const name = `${object.name.replace(` ${suffix}`, "")} ${suffix}`;
383-
384-
node = object.clone(name);
385-
if (node) {
386-
node.parent = object.parent;
387-
}
388-
}
389-
390-
if (isCamera(object)) {
391-
const suffix = "(Clone)";
392-
const name = `${object.name.replace(` ${suffix}`, "")} ${suffix}`;
393-
394-
node = object.clone(name);
395-
node.parent = object.parent;
396-
}
397-
398-
if (isTransformNode(object)) {
399-
const suffix = "(Clone)";
400-
const name = `${object.name.replace(` ${suffix}`, "")} ${suffix}`;
401-
402-
node = object.clone(name, null, true);
403-
if (node) {
404-
node.parent = object.parent;
405-
}
406-
}
407-
408-
if (isParticleSystem(object) && isAbstractMesh(parent)) {
409-
const suffix = "(Clone)";
410-
const name = `${object.name.replace(` ${suffix}`, "")} ${suffix}`;
373+
if (isNode(firstNode)) {
374+
this.props.editor.layout.preview.gizmo.setAttachedNode(firstNode);
375+
}
376+
}
411377

412-
node = object.clone(name, parent, false);
413-
}
378+
this.props.editor.layout.inspector.setEditedObject(firstNode);
379+
this.props.editor.layout.animations.setEditedObject(firstNode);
380+
});
381+
},
382+
undo: () => {
383+
newNodes.forEach((node) => {
384+
node.dispose(false, false);
385+
});
386+
newNodes.splice(0, newNodes.length);
387+
},
388+
redo: () => {
389+
nodesToCopy.forEach((object) => {
390+
let node: Node | ParticleSystem | null = null;
391+
392+
defer: {
393+
if (isAbstractMesh(object)) {
394+
const suffix = "(Instanced Mesh)";
395+
const name = isInstancedMesh(object) ? object.name : `${object.name.replace(` ${suffix}`, "")} ${suffix}`;
396+
397+
const instance = (node = object.createInstance(name));
398+
instance.position.copyFrom(object.position);
399+
instance.rotation.copyFrom(object.rotation);
400+
instance.scaling.copyFrom(object.scaling);
401+
instance.rotationQuaternion = object.rotationQuaternion?.clone() ?? null;
402+
instance.parent = object.parent;
403+
404+
const collisionMesh = getCollisionMeshFor(instance.sourceMesh);
405+
collisionMesh?.updateInstances(instance.sourceMesh);
406+
407+
break defer;
408+
}
414409

415-
if (node) {
416-
node.id = Tools.RandomId();
417-
node.uniqueId = UniqueNumber.Get();
410+
if (isParticleSystem(object) && isAbstractMesh(parent)) {
411+
const suffix = "(Clone)";
412+
const name = `${object.name.replace(` ${suffix}`, "")} ${suffix}`;
418413

419-
if (parent && isNode(node)) {
420-
node.parent = parent;
421-
}
414+
node = object.clone(name, parent, false);
422415

423-
if (isAbstractMesh(node)) {
424-
this.props.editor.layout.preview.scene.lights
425-
.map((light) => light.getShadowGenerator())
426-
.forEach((generator) => generator?.getShadowMap()?.renderList?.push(node));
427-
}
416+
break defer;
417+
}
428418

429-
newNodes.push(node);
430-
}
431-
});
419+
if (isNode(object)) {
420+
node = cloneNode(this.props.editor, object);
421+
break defer;
422+
}
423+
}
432424

433-
this.refresh();
425+
if (node) {
426+
node.id = Tools.RandomId();
427+
node.uniqueId = UniqueNumber.Get();
434428

435-
waitNextAnimationFrame().then(() => {
436-
const firstNode = newNodes[0];
429+
if (parent && isNode(node)) {
430+
node.parent = parent;
431+
}
437432

438-
this.props.editor.layout.graph.setSelectedNode(firstNode);
439-
this.props.editor.layout.inspector.setEditedObject(firstNode);
433+
if (isAbstractMesh(node)) {
434+
this.props.editor.layout.preview.scene.lights
435+
.map((light) => light.getShadowGenerator())
436+
.forEach((generator) => generator?.getShadowMap()?.renderList?.push(node));
437+
}
440438

441-
if (isNode(firstNode)) {
442-
this.props.editor.layout.animations.setEditedObject(firstNode);
443-
this.props.editor.layout.preview.gizmo.setAttachedNode(firstNode);
444-
}
439+
newNodes.push(node);
440+
}
441+
});
442+
},
445443
});
446444
}
447445

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

Lines changed: 98 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { platform } from "os";
22

3-
import { Mesh, Tools, SubMesh } from "babylonjs";
4-
53
import { Component, PropsWithChildren, ReactNode } from "react";
64

75
import { AiOutlinePlus, AiOutlineClose } from "react-icons/ai";
86

7+
import { Mesh, SubMesh, Node, InstancedMesh } from "babylonjs";
8+
99
import {
1010
ContextMenu,
1111
ContextMenuContent,
@@ -19,21 +19,27 @@ import {
1919
ContextMenuCheckboxItem,
2020
} from "../../../ui/shadcn/ui/context-menu";
2121

22+
import { showConfirm } from "../../../ui/dialog";
23+
import { Separator } from "../../../ui/shadcn/ui/separator";
2224
import { SceneAssetBrowserDialogMode, showAssetBrowserDialog } from "../../../ui/scene-asset-browser";
2325

2426
import { getMeshCommands } from "../../dialogs/command-palette/mesh";
2527
import { getLightCommands } from "../../dialogs/command-palette/light";
2628

2729
import { isSound } from "../../../tools/guards/sound";
30+
import { cloneNode, ICloneNodeOptions } from "../../../tools/node/clone";
2831
import { reloadSound } from "../../../tools/sound/tools";
2932
import { registerUndoRedo } from "../../../tools/undoredo";
33+
import { waitNextAnimationFrame } from "../../../tools/tools";
34+
import { createMeshInstance } from "../../../tools/mesh/instance";
3035
import { isScene, isSceneLinkNode } from "../../../tools/guards/scene";
31-
import { UniqueNumber, waitNextAnimationFrame } from "../../../tools/tools";
3236
import { isAbstractMesh, isMesh, isNode } from "../../../tools/guards/nodes";
3337
import { isNodeLocked, isNodeSerializable, setNodeLocked, setNodeSerializable } from "../../../tools/node/metadata";
3438

3539
import { addGPUParticleSystem, addParticleSystem } from "../../../project/add/particles";
3640

41+
import { EditorInspectorSwitchField } from "../inspector/fields/switch";
42+
3743
import { Editor } from "../../main";
3844

3945
import { removeNodes } from "./remove";
@@ -65,6 +71,10 @@ export class EditorGraphContextMenu extends Component<IEditorGraphContextMenuPro
6571

6672
{!isScene(this.props.object) && !isSound(this.props.object) && (
6773
<>
74+
<ContextMenuItem onClick={() => this._cloneNode(this.props.object)}>Clone</ContextMenuItem>
75+
76+
<ContextMenuSeparator />
77+
6878
<ContextMenuItem onClick={() => this.props.editor.layout.graph.copySelectedNodes()}>
6979
Copy <ContextMenuShortcut>{platform() === "darwin" ? "⌘+C" : "CTRL+C"}</ContextMenuShortcut>
7080
</ContextMenuItem>
@@ -179,8 +189,10 @@ export class EditorGraphContextMenu extends Component<IEditorGraphContextMenuPro
179189
{isMesh(this.props.object) && (
180190
<>
181191
<ContextMenuSeparator />
192+
182193
<ContextMenuItem onClick={() => this._createMeshInstance(this.props.object)}>Create Instance</ContextMenuItem>
183194

195+
<ContextMenuSeparator />
184196
<ContextMenuItem onClick={() => this._updateMeshGeometry(this.props.object)}>Update Geometry...</ContextMenuItem>
185197
</>
186198
)}
@@ -189,32 +201,92 @@ export class EditorGraphContextMenu extends Component<IEditorGraphContextMenuPro
189201
}
190202

191203
private _createMeshInstance(mesh: Mesh): void {
192-
const instance = mesh.createInstance(`${mesh.name} (Mesh Instance)`);
193-
instance.id = Tools.RandomId();
194-
instance.uniqueId = UniqueNumber.Get();
195-
instance.parent = mesh.parent;
196-
instance.position.copyFrom(mesh.position);
197-
instance.rotation.copyFrom(mesh.rotation);
198-
instance.scaling.copyFrom(mesh.scaling);
199-
instance.rotationQuaternion = mesh.rotationQuaternion?.clone() ?? null;
200-
instance.isVisible = mesh.isVisible;
201-
instance.setEnabled(mesh.isEnabled());
202-
203-
const lights = this.props.editor.layout.preview.scene.lights;
204-
const shadowMaps = lights.map((light) => light.getShadowGenerator()?.getShadowMap()).filter((s) => s);
205-
206-
shadowMaps.forEach((shadowMap) => {
207-
if (shadowMap?.renderList?.includes(mesh)) {
208-
shadowMap.renderList.push(instance);
209-
}
204+
let instance: InstancedMesh | null = null;
205+
206+
registerUndoRedo({
207+
executeRedo: true,
208+
action: () => {
209+
this.props.editor.layout.graph.refresh();
210+
211+
waitNextAnimationFrame().then(() => {
212+
if (instance) {
213+
this.props.editor.layout.graph.setSelectedNode(instance);
214+
this.props.editor.layout.animations.setEditedObject(instance);
215+
}
216+
217+
this.props.editor.layout.inspector.setEditedObject(instance);
218+
this.props.editor.layout.preview.gizmo.setAttachedNode(instance);
219+
});
220+
},
221+
undo: () => {
222+
instance?.dispose(false, false);
223+
instance = null;
224+
},
225+
redo: () => {
226+
instance = createMeshInstance(this.props.editor, mesh);
227+
},
210228
});
229+
}
230+
231+
private async _cloneNode(node: any): Promise<void> {
232+
let clone: Node | null = null;
233+
234+
const cloneOptions: ICloneNodeOptions = {
235+
shareGeometry: true,
236+
shareSkeleton: true,
237+
cloneMaterial: true,
238+
cloneThinInstances: true,
239+
};
240+
241+
const allNodes = isNode(node) ? [node, ...node.getDescendants(false)] : [node];
242+
if (allNodes.find((node) => isMesh(node))) {
243+
const result = await showConfirm(
244+
"Clone options",
245+
<div className="flex flex-col gap-2">
246+
<Separator />
247+
248+
<div className="text-muted font-semibold">Options for meshes</div>
249+
250+
<div className="flex flex-col">
251+
<EditorInspectorSwitchField object={cloneOptions} property="shareGeometry" label="Share Geometry" />
252+
<EditorInspectorSwitchField object={cloneOptions} property="shareSkeleton" label="Share Skeleton" />
253+
<EditorInspectorSwitchField object={cloneOptions} property="cloneMaterial" label="Clone Material" />
254+
<EditorInspectorSwitchField object={cloneOptions} property="cloneThinInstances" label="Clone Thin Instances" />
255+
</div>
256+
</div>,
257+
{
258+
asChild: true,
259+
confirmText: "Clone",
260+
}
261+
);
262+
263+
if (!result) {
264+
return;
265+
}
266+
}
267+
268+
registerUndoRedo({
269+
executeRedo: true,
270+
action: () => {
271+
this.props.editor.layout.graph.refresh();
211272

212-
this.props.editor.layout.graph.refresh();
273+
waitNextAnimationFrame().then(() => {
274+
if (clone) {
275+
this.props.editor.layout.graph.setSelectedNode(clone);
276+
this.props.editor.layout.animations.setEditedObject(clone);
277+
}
213278

214-
waitNextAnimationFrame().then(() => {
215-
this.props.editor.layout.graph.setSelectedNode(instance);
216-
this.props.editor.layout.inspector.setEditedObject(instance);
217-
this.props.editor.layout.preview.gizmo.setAttachedNode(instance);
279+
this.props.editor.layout.inspector.setEditedObject(clone);
280+
this.props.editor.layout.preview.gizmo.setAttachedNode(clone);
281+
});
282+
},
283+
undo: () => {
284+
clone?.dispose(false, false);
285+
clone = null;
286+
},
287+
redo: () => {
288+
clone = cloneNode(this.props.editor, node, cloneOptions);
289+
},
218290
});
219291
}
220292

editor/src/editor/layout/inspector/fields/switch.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState } from "react";
2-
import { Switch } from "@blueprintjs/core";
2+
3+
import { Switch } from "../../../../ui/shadcn/ui/switch";
34

45
import { registerSimpleUndoRedo } from "../../../../tools/undoredo";
56
import { getInspectorPropertyValue, setInspectorEffectivePropertyValue } from "../../../../tools/property";
@@ -34,8 +35,8 @@ export function EditorInspectorSwitchField(props: IEditorInspectorSwitchFieldPro
3435
>
3536
<div className="w-full text-ellipsis overflow-hidden whitespace-nowrap">{props.label}</div>
3637

37-
<div className="flex justify-end w-14 my-auto">
38-
<Switch className="mt-2" checked={value} onChange={() => {}} onClick={(ev) => ev.stopPropagation()} />
38+
<div className="flex justify-end w-14 py-2">
39+
<Switch checked={value} onChange={() => {}} onClick={(ev) => ev.stopPropagation()} />
3940
</div>
4041
</div>
4142
);

editor/src/editor/layout/inspector/material/grid.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import { Component, ReactNode } from "react";
33
import { AbstractMesh } from "babylonjs";
44
import { GridMaterial } from "babylonjs-materials";
55

6+
import { EditorInspectorColorField } from "../fields/color";
67
import { EditorInspectorStringField } from "../fields/string";
78
import { EditorInspectorSwitchField } from "../fields/switch";
89
import { EditorInspectorNumberField } from "../fields/number";
910
import { EditorInspectorVectorField } from "../fields/vector";
1011
import { EditorInspectorSectionField } from "../fields/section";
11-
import { EditorInspectorColorField } from "../fields/color";
1212

1313
import { EditorMaterialInspectorUtilsComponent } from "./utils";
1414

0 commit comments

Comments
 (0)