Repository: yaptide/ui
Pull request #2063: Implement item list that supports nested objects
Author: login: kmichalikk
Labels: name: Webassembly
description: Bachelor 2025
Created: 2025-05-07T16:14:20Z | Merged: 2025-05-15T14:29:12Z

This PR rewrites SidebarTree with support for nested structures. The new component is intended to work with nested and flat lists and fully replace its predecessor.


---------------------------

This pull request introduces several changes to enhance the functionality of the simulation editor, improve the sidebar component, and refactor code for better maintainability. The highlights include adding support for child elements in `SimulationMesh`, replacing the `SidebarTree` component with the new `SidebarTreeList`, and introducing nesting and sorting capabilities in the sidebar. Additionally, unused imports and components were removed, and some styles were updated.

### Simulation Editor Enhancements:
* Added support for child elements in `SimulationMesh` by introducing the `children` property in the `SimulationMeshJSON` type and updating the `toSerialized` and `fromSerialized` methods to handle nested structures. (`src/ThreeEditor/Simulation/Base/SimulationMesh.ts`, [[1]](diffhunk://#diff-e462dbb56fbbe32adb3c97546bd98705eca2722066646a461ed019a339b56857R16) [[2]](diffhunk://#diff-e462dbb56fbbe32adb3c97546bd98705eca2722066646a461ed019a339b56857L67-R80)
* Exported the `figureLoader` function from `FigureManager` to handle deserialization of nested child elements. (`src/ThreeEditor/Simulation/Figures/FigureManager.ts`, [src/ThreeEditor/Simulation/Figures/FigureManager.tsL20-R20](diffhunk://#diff-48f01223024ac3fb108f55ac83eb6b04418bb524f5c7d492b3d97c225e64a8bdL20-R20))

### Sidebar Component Refactor:
* Replaced the `SidebarTree` component with `SidebarTreeList`, which introduces improved handling of drag-and-drop functionality, nesting, and sorting capabilities. (`src/ThreeEditor/components/Sidebar/EditorSidebar.tsx`, [[1]](diffhunk://#diff-0be9ec953422f723f0fc73dafd27d63aab12c91cfb9a8f4e7f9b6dd6dc409911L10) [[2]](diffhunk://#diff-0be9ec953422f723f0fc73dafd27d63aab12c91cfb9a8f4e7f9b6dd6dc409911L29-R29) [[3]](diffhunk://#diff-0be9ec953422f723f0fc73dafd27d63aab12c91cfb9a8f4e7f9b6dd6dc409911L277-R279) [[4]](diffhunk://#diff-0be9ec953422f723f0fc73dafd27d63aab12c91cfb9a8f4e7f9b6dd6dc409911L346-R354)
* Removed the `SidebarTree` component and its associated styles, consolidating functionality into `SidebarTreeList`. (`src/ThreeEditor/components/Sidebar/SidebarTree/SidebarTree.tsx`, [[1]](diffhunk://#diff-89bea4d76e08882dda476de9c0bff49cac17293ed680c447294418414c9ce1fcL1-L188); `src/ThreeEditor/components/Sidebar/SidebarTree/SidebarTree.style.css`, [[2]](diffhunk://#diff-d8516fa084ecd767037d7e472a88c9471a106f15983781188a292b3c5f4ee29eL1-L10)
* Added new styles for `SidebarTreeList` to support drop targets and improved visual hierarchy. (`src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeList.style.css`, [src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeList.style.cssR1-R19](diffhunk://#diff-a54721a936ab7bf2a54f8656d9a1c84d2296e2b1eec0c5ad11f24cc6ace1b27cR1-R19))

### UI Improvements:
* Updated the `InputLabel` and `Select` components in the sidebar to use Material-UI's `sx` prop for dynamic styling based on the theme palette. (`src/ThreeEditor/components/Sidebar/EditorSidebar.tsx`, [src/ThreeEditor/components/Sidebar/EditorSidebar.tsxL127-R129](diffhunk://#diff-0be9ec953422f723f0fc73dafd27d63aab12c91cfb9a8f4e7f9b6dd6dc409911L127-R129))

### Code Cleanup:
* Removed unused imports and components, such as `Menu` and `SelectPropertyField`, from the sidebar codebase. (`src/ThreeEditor/components/Sidebar/EditorSidebar.tsx`, [[1]](diffhunk://#diff-0be9ec953422f723f0fc73dafd27d63aab12c91cfb9a8f4e7f9b6dd6dc409911L10) [[2]](diffhunk://#diff-0be9ec953422f723f0fc73dafd27d63aab12c91cfb9a8f4e7f9b6dd6dc409911L29-R29)


Diff:
diff --git a/src/App.tsx b/src/App.tsx
index 4a5663b49..fa5eac097 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -72,7 +72,8 @@ function App() {
 				},
 				dimensions: {
 					navDrawerWidth: 160
-				}
+				},
+				cssVariables: true
 			}),
 		[prefersDarkMode]
 	);
diff --git a/src/ThreeEditor/Simulation/Base/SimulationMesh.ts b/src/ThreeEditor/Simulation/Base/SimulationMesh.ts
index 4d071d344..15467f3aa 100644
--- a/src/ThreeEditor/Simulation/Base/SimulationMesh.ts
+++ b/src/ThreeEditor/Simulation/Base/SimulationMesh.ts
@@ -4,6 +4,7 @@ import { SimulationPropertiesType } from '../../../types/SimulationProperties';
 import { AdditionalGeometryDataType, getGeometryData } from '../../../util/AdditionalGeometryData';
 import { SerializableState } from '../../js/EditorJson';
 import { YaptideEditor } from '../../js/YaptideEditor';
+import { figureLoader } from '../Figures/FigureManager';
 import { SimulationSceneChild, SimulationSceneContainer } from './SimulationContainer';
 import { SimulationElement, SimulationElementJSON } from './SimulationElement';
 
@@ -12,6 +13,7 @@ export type SimulationMeshJSON = Omit<
 		geometryData: AdditionalGeometryDataType;
 		colorHex: number;
 		visible: boolean;
+		children?: SimulationMeshJSON[];
 	},
 	never
 >;
@@ -64,11 +66,18 @@ export abstract class SimulationMesh<
 			uuid,
 			visible,
 			geometryData,
-			colorHex
+			colorHex,
+			children: this.children.map(c => (c as SimulationMesh).toSerialized())
 		};
 	}
 
 	fromSerialized(json: SimulationMeshJSON) {
+		const loader = figureLoader(this.editor);
+
+		if (json.children && json.children.length > 0) {
+			json.children.forEach(childJson => this.add(loader(childJson)));
+		}
+
 		this.name = json.name;
 		this.uuid = json.uuid;
 		this.visible = json.visible;
diff --git a/src/ThreeEditor/Simulation/Figures/FigureManager.ts b/src/ThreeEditor/Simulation/Figures/FigureManager.ts
index 820abfd95..6cd8aa9a3 100644
--- a/src/ThreeEditor/Simulation/Figures/FigureManager.ts
+++ b/src/ThreeEditor/Simulation/Figures/FigureManager.ts
@@ -17,7 +17,7 @@ type FigureManagerJSON = Omit<
 	never
 >;
 
-const figureLoader = (editor: YaptideEditor) => (json: SimulationMeshJSON) => {
+export const figureLoader = (editor: YaptideEditor) => (json: SimulationMeshJSON) => {
 	switch (json.type) {
 		case 'BoxFigure':
 			return new BoxFigure(editor).fromSerialized(json);
diff --git a/src/ThreeEditor/components/Sidebar/EditorSidebar.tsx b/src/ThreeEditor/components/Sidebar/EditorSidebar.tsx
index 8638d041a..7f9cbf16c 100644
--- a/src/ThreeEditor/components/Sidebar/EditorSidebar.tsx
+++ b/src/ThreeEditor/components/Sidebar/EditorSidebar.tsx
@@ -7,7 +7,6 @@ import {
 	Divider,
 	FormControl,
 	InputLabel,
-	Menu,
 	Select,
 	Stack,
 	Tab,
@@ -26,9 +25,8 @@ import { TabPanel } from '../../../WrapperApp/components/Panels/TabPanel';
 import { Context } from '../../js/EditorContext';
 import { YaptideEditor } from '../../js/YaptideEditor';
 import { PhysicConfiguration } from './properties/category/PhysicConfiguration';
-import { SelectPropertyField } from './properties/fields/PropertyField';
 import { PropertiesPanel } from './properties/PropertiesPanel';
-import { SidebarTree } from './SidebarTree/SidebarTree';
+import { SidebarTreeList } from './SidebarTreeList/SidebarTreeList';
 import { EditorSidebarTabTree } from './tabs/EditorSidebarTabTree';
 
 interface EditorSidebarProps {
@@ -124,9 +122,11 @@ export function EditorSidebar(props: EditorSidebarProps) {
 				<FormControl
 					variant='filled'
 					fullWidth>
-					<InputLabel style={{ color: '#dfc19b' }}>Simulator</InputLabel>
+					<InputLabel sx={{ color: ({ palette }) => palette.primary.main }}>
+						Simulator
+					</InputLabel>
 					<Select
-						style={{ color: '#dfc19b' }}
+						sx={{ color: ({ palette }) => palette.primary.main }}
 						value={simulator}
 						label='Simulator'
 						onChange={e => {
@@ -274,9 +274,9 @@ function getGeometryTabElements(simulator: SimulatorType, btnProps: any, editor:
 	const commonElements = [
 		{
 			title: 'Figures',
-			add: btnProps['Figures'],
+			add: btnProps['CFGFigures'],
 			tree: (
-				<SidebarTree
+				<SidebarTreeList
 					editor={editor}
 					sources={[editor.figureManager.figureContainer]}
 				/>
@@ -287,13 +287,13 @@ function getGeometryTabElements(simulator: SimulatorType, btnProps: any, editor:
 			add: btnProps['Zones'],
 			tree: (
 				<>
-					<SidebarTree
+					<SidebarTreeList
 						editor={editor}
 						sources={[editor.zoneManager.worldZone]}
 						dragDisabled
 					/>
 					<Divider sx={{ marginBottom: t => t.spacing(1) }} />
-					<SidebarTree
+					<SidebarTreeList
 						editor={editor}
 						sources={[editor.zoneManager.zoneContainer]}
 					/>
@@ -304,7 +304,7 @@ function getGeometryTabElements(simulator: SimulatorType, btnProps: any, editor:
 			title: 'Detectors',
 			add: btnProps['Detectors'],
 			tree: (
-				<SidebarTree
+				<SidebarTreeList
 					editor={editor}
 					sources={[editor.detectorManager.detectorContainer]}
 				/>
@@ -321,19 +321,21 @@ function getGeometryTabElements(simulator: SimulatorType, btnProps: any, editor:
 				<>
 					{editor.specialComponentsManager.CTCubeContainer.children.length > 0 ? (
 						<>
-							<SidebarTree
+							<SidebarTreeList
 								editor={editor}
 								sources={[editor.specialComponentsManager.CTCubeContainer]}
 								dragDisabled
+								sortingDisabled
 							/>
 
 							<Divider sx={{ marginBottom: t => t.spacing(1) }} />
 						</>
 					) : undefined}
-					<SidebarTree
+					<SidebarTreeList
 						editor={editor}
 						sources={[editor.specialComponentsManager.beamModulatorContainer]}
 						dragDisabled
+						sortingDisabled
 					/>
 				</>
 			)
@@ -343,12 +345,13 @@ function getGeometryTabElements(simulator: SimulatorType, btnProps: any, editor:
 
 	const geant4Elements = [
 		{
-			title: 'Geant 4 Placeholder',
-			add: btnProps['Filters'], // just for mockup
+			title: 'Hierarchy',
+			add: btnProps['NestedFigures'],
 			tree: (
-				<SidebarTree
+				<SidebarTreeList
 					editor={editor}
 					sources={[editor.figureManager.figureContainer]}
+					nestingAllowed={true}
 				/>
 			)
 		}
@@ -374,7 +377,7 @@ function getScoringTabElements(simulator: SimulatorType, btnProps: any, editor:
 			title: 'Filters',
 			add: btnProps['Filters'],
 			tree: (
-				<SidebarTree
+				<SidebarTreeList
 					editor={editor}
 					sources={[editor.scoringManager.filterContainer]}
 				/>
@@ -384,7 +387,7 @@ function getScoringTabElements(simulator: SimulatorType, btnProps: any, editor:
 			title: 'Outputs',
 			add: btnProps['Outputs'],
 			tree: (
-				<SidebarTree
+				<SidebarTreeList
 					editor={editor}
 					sources={[editor.scoringManager.outputContainer]}
 				/>
@@ -399,7 +402,7 @@ function getScoringTabElements(simulator: SimulatorType, btnProps: any, editor:
 			title: 'Geant 4 Placeholder',
 			add: btnProps['Filters'], // just for mockup
 			tree: (
-				<SidebarTree
+				<SidebarTreeList
 					editor={editor}
 					sources={[editor.figureManager.figureContainer]}
 				/>
diff --git a/src/ThreeEditor/components/Sidebar/SidebarTree/SidebarTree.style.css b/src/ThreeEditor/components/Sidebar/SidebarTree/SidebarTree.style.css
deleted file mode 100644
index f7134a189..000000000
--- a/src/ThreeEditor/components/Sidebar/SidebarTree/SidebarTree.style.css
+++ /dev/null
@@ -1,10 +0,0 @@
-.editor-sidebar-tree :where(ul, li),
-.editor-sidebar-tree:where(ul, li) {
-	list-style: none;
-	padding: 0;
-	margin: 0;
-}
-
-.editor-sidebar-tree {
-	padding-bottom: 10px;
-}
diff --git a/src/ThreeEditor/components/Sidebar/SidebarTree/SidebarTree.tsx b/src/ThreeEditor/components/Sidebar/SidebarTree/SidebarTree.tsx
deleted file mode 100644
index ad2ce905c..000000000
--- a/src/ThreeEditor/components/Sidebar/SidebarTree/SidebarTree.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-import './SidebarTree.style.css';
-
-import { Tree, TreeMethods } from '@minoru/react-dnd-treeview';
-import { Divider } from '@mui/material';
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Object3D } from 'three';
-import { generateUUID } from 'three/src/math/MathUtils.js';
-
-import { hasVisibleChildren } from '../../../../util/hooks/useKeyboardEditorControls';
-import { ChangeObjectOrderCommand } from '../../../js/commands/ChangeObjectOrderCommand';
-import { YaptideEditor } from '../../../js/YaptideEditor';
-import {
-	isSimulationSceneContainer,
-	SimulationSceneChild
-} from '../../../Simulation/Base/SimulationContainer';
-import { isSimulationElement } from '../../../Simulation/Base/SimulationElement';
-import { SidebarTreeItem, TreeItem } from './SidebarTreeItem';
-
-type TreeSource = SimulationSceneChild[];
-
-const validateElement = (object: Object3D): object is SimulationSceneChild => {
-	return isSimulationElement(object) || isSimulationSceneContainer(object);
-};
-
-export function SidebarTree(props: {
-	editor: YaptideEditor;
-	sources: TreeSource;
-	dragDisabled?: boolean;
-}) {
-	const { editor, sources } = props;
-
-	const treeRef = useRef<TreeMethods>(null);
-	const treeId = useMemo(() => generateUUID(), []);
-
-	/**
-	 * Build tree options recursively
-	 *
-	 * Provided object is added to the tree if it is not a container
-	 * or if it is a container but its flattenOnOutliner flag is false
-	 *
-	 * Then function runs recursively on all children of the provided object
-	 * if it is a container and has visible children
-	 *
-	 * All elements that don't pass a validation with {@link validateElement} are filtered out
-	 */
-	const buildOptionsRecursively = useCallback(
-		(object: SimulationSceneChild, index: number, parent: number = 0): TreeItem[] => {
-			let nextParent = object.id;
-			const elementToTreeItem = (
-				object: SimulationSceneChild,
-				parent: number,
-				index: number
-			): TreeItem => {
-				const { id, name: text } = object;
-
-				return {
-					id,
-					parent,
-					droppable: true,
-					text,
-					data: {
-						object,
-						treeId,
-						index
-					}
-				};
-			};
-
-			const children: TreeItem[] = [];
-
-			if (!isSimulationSceneContainer(object) || !object.flattenOnOutliner)
-				children.push(elementToTreeItem(object, parent, index));
-			else nextParent = parent;
-
-			if (object.children.length > 0) {
-				const { children: items } = object;
-
-				if (hasVisibleChildren(object))
-					children.push(
-						...items
-							.filter(validateElement)
-							.flatMap((child, idx) =>
-								buildOptionsRecursively(child, idx, nextParent)
-							)
-					);
-			}
-
-			return children;
-		},
-		[treeId]
-	);
-
-	const [treeData, setTreeData] = useState<TreeItem[]>([]);
-
-	const refreshTreeData = useCallback(() => {
-		const options = sources.flatMap((source, idx) => buildOptionsRecursively(source, idx));
-		setTreeData(options);
-	}, [buildOptionsRecursively, sources]);
-
-	const handleSelected = (object: Object3D | null) => {
-		if (!object) return;
-
-		objectRefs.current
-			.get(object.uuid)
-			?.scrollIntoView({ behavior: 'smooth', block: 'center' });
-	};
-
-	useEffect(() => {
-		refreshTreeData();
-
-		editor.signals.editorCleared.add(refreshTreeData);
-		editor.signals.objectChanged.add(refreshTreeData);
-		editor.signals.objectAdded.add(refreshTreeData);
-		editor.signals.objectRemoved.add(refreshTreeData);
-		editor.signals.objectSelected.add(handleSelected);
-
-		return () => {
-			editor.signals.editorCleared.remove(refreshTreeData);
-			editor.signals.objectChanged.remove(refreshTreeData);
-			editor.signals.objectAdded.remove(refreshTreeData);
-			editor.signals.objectRemoved.remove(refreshTreeData);
-			editor.signals.objectSelected.remove(handleSelected);
-		};
-	}, [buildOptionsRecursively, editor, refreshTreeData]);
-
-	const objectRefs = useRef<Map<string, HTMLDivElement>>(new Map());
-
-	const canDrop = (source: TreeItem | undefined, dropTargetId: string | number): boolean => {
-		if (!source) return false;
-
-		if (source.data?.treeId !== treeId) return false;
-
-		return source.parent === dropTargetId; // only allow drop on same parent
-	};
-
-	return (
-		<Tree
-			classes={{
-				root: 'editor-sidebar-tree'
-			}}
-			ref={treeRef}
-			tree={treeData}
-			rootId={0}
-			onDrop={(_tree, { dragSource, relativeIndex }) => {
-				if (relativeIndex === undefined)
-					return console.warn(
-						'relativeIndex is undefined. Probably you need disable sort option.'
-					);
-
-				if (dragSource?.data) {
-					if (
-						relativeIndex === dragSource.data.index ||
-						relativeIndex - 1 === dragSource.data.index
-					)
-						return; // no change needed
-
-					let newPosition =
-						relativeIndex > (dragSource.data.index ?? 0)
-							? relativeIndex - 1
-							: relativeIndex;
-
-					editor.execute(
-						new ChangeObjectOrderCommand(editor, dragSource.data.object, newPosition)
-					);
-				}
-			}}
-			canDrop={(_, { dragSource, dropTargetId }) => {
-				return canDrop(dragSource, dropTargetId);
-			}}
-			canDrag={node => !props.dragDisabled}
-			sort={false}
-			insertDroppableFirst={false}
-			dropTargetOffset={5}
-			placeholderRender={() => <Divider sx={{ borderBottomWidth: t => t.spacing(1) }} />}
-			render={(node, { depth, isOpen, onToggle }) => (
-				<SidebarTreeItem
-					treeRef={treeRef.current}
-					node={node}
-					depth={depth}
-					isOpen={isOpen}
-					onToggle={onToggle}
-					objectRefs={objectRefs}
-					editor={editor}
-				/>
-			)}
-		/>
-	);
-}
diff --git a/src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeList.style.css b/src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeList.style.css
new file mode 100644
index 000000000..fd1630167
--- /dev/null
+++ b/src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeList.style.css
@@ -0,0 +1,19 @@
+.editor-sidebar-tree :where(ul, li),
+.editor-sidebar-tree:where(ul, li) {
+	list-style: none;
+	padding: 0;
+	margin: 0;
+}
+
+.editor-sidebar-tree {
+	padding-bottom: 10px;
+}
+
+.editor-sidebar-drop-target {
+	background-color: var(--mui-palette-grey-800);
+}
+
+.editor-sidebar-drop-target-divider {
+	border-bottom: 2px solid var(--mui-palette-primary-main);
+	margin: 0.2rem;
+}
diff --git a/src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeList.tsx b/src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeList.tsx
new file mode 100644
index 000000000..8bffab4ff
--- /dev/null
+++ b/src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeList.tsx
@@ -0,0 +1,161 @@
+import './SidebarTreeList.style.css';
+
+import { DropOptions, Tree } from '@minoru/react-dnd-treeview';
+import { Divider } from '@mui/material';
+import { useCallback, useEffect, useMemo } from 'react';
+import { Object3D } from 'three';
+import { generateUUID } from 'three/src/math/MathUtils.js';
+
+import { MoveObjectInTreeCommand } from '../../../js/commands/MoveObjectInTreeCommand';
+import { YaptideEditor } from '../../../js/YaptideEditor';
+import { SimulationSceneChild } from '../../../Simulation/Base/SimulationContainer';
+import { SidebarTreeListItem, TreeItem, TreeItemData } from './SidebarTreeListItem';
+import { useGenerateTreeData } from './useGenerateTreeData';
+
+type TreeSource = SimulationSceneChild[];
+
+function handleSelected(object: Object3D | null) {
+	if (!object) {
+		return;
+	}
+
+	document.getElementById(object.uuid)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+}
+
+export function SidebarTreeList(props: {
+	editor: YaptideEditor;
+	sources: TreeSource;
+	dragDisabled?: boolean;
+	nestingAllowed?: boolean;
+	sortingDisabled?: boolean;
+}) {
+	const { editor, sources, nestingAllowed, sortingDisabled } = props;
+
+	const treeId = useMemo(() => generateUUID(), []);
+	const [treeData, refreshTreeData] = useGenerateTreeData(treeId, sources);
+
+	useEffect(() => {
+		refreshTreeData();
+
+		editor.signals.editorCleared.add(refreshTreeData);
+		editor.signals.objectChanged.add(refreshTreeData);
+		editor.signals.objectAdded.add(refreshTreeData);
+		editor.signals.objectRemoved.add(refreshTreeData);
+		editor.signals.objectSelected.add(handleSelected);
+
+		return () => {
+			editor.signals.editorCleared.remove(refreshTreeData);
+			editor.signals.objectChanged.remove(refreshTreeData);
+			editor.signals.objectAdded.remove(refreshTreeData);
+			editor.signals.objectRemoved.remove(refreshTreeData);
+			editor.signals.objectSelected.remove(handleSelected);
+		};
+	}, [editor, refreshTreeData]);
+
+	const handleDrop = useCallback(
+		(
+			tree: TreeItem[],
+			dragDestination: {
+				dragSource?: TreeItem;
+				dropTarget?: TreeItem;
+				relativeIndex?: number;
+			}
+		) => {
+			const { dragSource, dropTarget, relativeIndex } = dragDestination;
+
+			if (relativeIndex === undefined) {
+				return console.warn(
+					'relativeIndex is undefined; check if sort option is set to false.'
+				);
+			}
+
+			if (!dragSource?.data) {
+				return;
+			}
+
+			const findObjectById = (id: string | number) => {
+				for (const node of tree) {
+					if (node.id === id) {
+						return node;
+					}
+				}
+
+				return null;
+			};
+
+			const dragObject = findObjectById(dragSource.id)!;
+			let newParent = dropTarget ? findObjectById(dropTarget.id)!.data!.object : null;
+
+			if (
+				dragObject.parent !== dragSource.parent ||
+				(relativeIndex !== dragSource.data.index &&
+					relativeIndex - 1 !== dragSource.data.index)
+			) {
+				editor.execute(
+					new MoveObjectInTreeCommand(
+						editor,
+						dragSource.data.object,
+						relativeIndex > (dragSource.data.index ?? 0)
+							? relativeIndex - 1
+							: relativeIndex,
+						newParent
+					)
+				);
+			}
+		},
+		[editor]
+	);
+
+	const canDrop = useCallback(
+		(_: any, options: DropOptions<TreeItemData>): boolean | void => {
+			const { dragSource, dropTargetId } = options;
+
+			if (sortingDisabled) {
+				return false;
+			}
+
+			if (dragSource?.parent === dropTargetId) {
+				// allow for reordering within current parent
+				return true;
+			}
+
+			if (
+				!dragSource ||
+				dragSource.data?.treeId !== treeId ||
+				(dragSource?.parent !== dropTargetId && !nestingAllowed)
+			) {
+				// sanity checks and obvious violations
+				return false;
+			}
+
+			return; // let the default library logic handle the rest, i.e. moving subtree into itself
+		},
+		[treeId, nestingAllowed, sortingDisabled]
+	);
+
+	return (
+		<Tree
+			classes={{
+				root: 'editor-sidebar-tree',
+				dropTarget: 'editor-sidebar-drop-target'
+			}}
+			tree={treeData}
+			rootId={0}
+			sort={!!sortingDisabled}
+			onDrop={handleDrop}
+			canDrop={canDrop}
+			dropTargetOffset={5}
+			placeholderRender={() => <Divider className={'editor-sidebar-drop-target-divider'} />}
+			render={(node, { depth, isOpen, onToggle, hasChild }) => (
+				<SidebarTreeListItem
+					node={node}
+					depth={depth}
+					hasChild={hasChild}
+					isOpen={isOpen}
+					onToggle={onToggle}
+					editor={editor}
+				/>
+			)}
+		/>
+	);
+}
diff --git a/src/ThreeEditor/components/Sidebar/SidebarTree/SidebarTreeItem.tsx b/src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeListItem.tsx
similarity index 54%
rename from src/ThreeEditor/components/Sidebar/SidebarTree/SidebarTreeItem.tsx
rename to src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeListItem.tsx
index b4e9947ea..d4a8d6464 100644
--- a/src/ThreeEditor/components/Sidebar/SidebarTree/SidebarTreeItem.tsx
+++ b/src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeListItem.tsx
@@ -1,35 +1,29 @@
-import { NodeModel, TreeMethods } from '@minoru/react-dnd-treeview/dist/types';
+import { NodeModel } from '@minoru/react-dnd-treeview/dist/types';
 import ChevronRightIcon from '@mui/icons-material/ChevronRight';
 import VisibilityIcon from '@mui/icons-material/Visibility';
 import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
-import { Checkbox, Menu, MenuItem, TextField, Typography } from '@mui/material';
-import Box from '@mui/material/Box';
-import { Stack } from '@mui/system';
+import { Box, Checkbox, Icon, Menu, Stack, TextField, Typography } from '@mui/material';
 import { bindContextMenu, bindMenu, usePopupState } from 'material-ui-popup-state/hooks';
-import { JSX, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import { Object3D } from 'three';
-import * as THREE from 'three';
 
 import { SimulationPropertiesType } from '../../../../types/SimulationProperties';
 import { useSignal, useSmartWatchEditorState } from '../../../../util/hooks/signals';
-import {
-	canChangeName,
-	getRemoveCommand,
-	hasVisibleChildren,
-	isRemovable
-} from '../../../../util/hooks/useKeyboardEditorControls';
-import { AddQuantityCommand } from '../../../js/commands/AddQuantityCommand';
-import { canBeDuplicated, getDuplicateCommand } from '../../../js/commands/DuplicateObjectCommand';
+import { canChangeName, isRemovable } from '../../../../util/hooks/useKeyboardEditorControls';
+import { canBeDuplicated } from '../../../js/commands/DuplicateObjectCommand';
 import { SetValueCommand } from '../../../js/commands/SetValueCommand';
 import { YaptideEditor } from '../../../js/YaptideEditor';
-import { isSimulationElement, SimulationElement } from '../../../Simulation/Base/SimulationElement';
+import { SimulationElement } from '../../../Simulation/Base/SimulationElement';
 import { isOutput } from '../../../Simulation/Scoring/ScoringOutput';
+import { AddQuantityAction, DeleteAction, DuplicateAction, RenameAction } from './contextActions';
 
-export type TreeItem = NodeModel<{
+export type TreeItemData = {
 	object: Object3D | SimulationElement;
 	treeId: string;
 	index?: number;
-}>;
+};
+
+export type TreeItem = NodeModel<TreeItemData>;
 
 function isHidable(object: Object3D | SimulationPropertiesType) {
 	if ('notHidable' in object) {
@@ -39,87 +33,63 @@ function isHidable(object: Object3D | SimulationPropertiesType) {
 	return true;
 }
 
-export function SidebarTreeItem(props: {
-	treeRef: TreeMethods | null;
-	objectRefs: MutableRefObject<Map<string, HTMLDivElement>>;
-	node: NodeModel<{
-		object: Object3D | SimulationElement;
-	}>;
+export function SidebarTreeListItem(props: {
 	depth: number;
+	hasChild: boolean;
 	isOpen: boolean;
 	onToggle: () => void;
 	editor: YaptideEditor;
+	node: NodeModel<{
+		object: Object3D | SimulationElement;
+	}>;
 }) {
-	const { objectRefs, node, depth, isOpen, onToggle, editor, treeRef } = props;
-	const inputRef = useRef<HTMLInputElement>(null);
+	const { depth, hasChild, isOpen, onToggle, editor, node } = props;
 
+	const inputRef = useRef<HTMLInputElement>(null);
 	const object = node.data!.object;
 
-	const { state: watchedObject } = useSmartWatchEditorState(editor, object);
-
 	const [mode, setMode] = useState<'view' | 'edit'>('view');
-
 	const popupState = usePopupState({ variant: 'popover', popupId: 'sidebarTreeItemMenu' });
 
-	const contextOptions = (() => {
-		const options: JSX.Element[] = [];
-
-		if (canBeDuplicated(object)) {
-			options.push(
-				<MenuItem
-					key={'duplicate'}
-					onClick={() => {
-						if (!isSimulationElement(object)) return;
-						const command = getDuplicateCommand(editor, object);
-						editor.execute(command);
-						popupState.close();
-					}}>
-					Duplicate
-				</MenuItem>
-			);
-		}
-
-		if (canChangeName(object)) {
-			options.push(
-				<MenuItem
-					key={'rename'}
-					onClick={() => {
-						popupState.close();
-						setMode('edit');
-					}}>
-					Rename
-				</MenuItem>
-			);
-		}
-
-		if (isOutput(object))
-			options.push(
-				<MenuItem
-					key={'addQuantity'}
-					onClick={() => {
-						editor.execute(new AddQuantityCommand(editor, object));
-
-						if (!isOpen) onToggle();
-						popupState.close();
-					}}>
-					Add Quantity
-				</MenuItem>
-			);
-
-		if (isRemovable(object))
-			options.push(
-				<MenuItem
-					key={'delete'}
-					onClick={() => {
-						editor.execute(getRemoveCommand(editor, object));
-						popupState.close();
-					}}>
-					Delete
-				</MenuItem>
-			);
+	const { state: watchedObject } = useSmartWatchEditorState(editor, object);
 
-		return options;
-	})();
+	const actions = [
+		canBeDuplicated(object) && (
+			<DuplicateAction
+				key={'duplicate'}
+				object={object}
+				editor={editor}
+				popupState={popupState}
+			/>
+		),
+		canChangeName(object) && (
+			<RenameAction
+				key={'rename'}
+				object={object}
+				editor={editor}
+				popupState={popupState}
+				setMode={setMode}
+			/>
+		),
+		isOutput(object) && (
+			<AddQuantityAction
+				key={'addQuantity'}
+				object={object}
+				editor={editor}
+				popupState={popupState}
+				isOpen={isOpen}
+				onToggle={onToggle}
+			/>
+		),
+		isRemovable(object) && (
+			<DeleteAction
+				key={'delete'}
+				object={object}
+				editor={editor}
+				popupState={popupState}
+			/>
+		)
+	];
 
 	useEffect(() => {
 		if (mode === 'edit') {
@@ -127,50 +97,34 @@ export function SidebarTreeItem(props: {
 		}
 	}, [mode]);
 
-	const onObjectAdded = useCallback(
-		(newObject: Object3D) => {
-			if (object.getObjectByName(newObject.name) === newObject) {
-				treeRef?.open(node.id);
-			}
-		},
-		[node.id, object, treeRef]
+	// handle rename signal dispatch when F2 is clicked
+	useSignal(
+		editor,
+		'requestRenameAction',
+		useCallback(
+			(objectToRename: Object3D) => {
+				if (objectToRename === object) {
+					setMode('edit');
+				}
+			},
+			[object]
+		)
 	);
 
-	useSignal(editor, 'objectAdded', onObjectAdded);
-
-	const onRequestRenameAction = useCallback(
-		(objectToRename: Object3D) => {
-			if (objectToRename === object) {
-				setMode('edit');
-			}
-		},
-		[object]
-	);
-
-	useSignal(editor, 'requestRenameAction', onRequestRenameAction);
-
 	return (
 		<>
 			<Box
-				ref={(ref: HTMLDivElement | null) => {
-					if (ref) {
-						objectRefs.current.set(object.uuid, ref);
-					} else {
-						objectRefs.current.delete(object.uuid);
-					}
-				}}
+				id={object.uuid}
 				sx={{
-					'marginLeft': ({ spacing }) => spacing(depth * 2.5),
-
+					'marginLeft': ({ spacing }) => spacing(depth * 1.5),
 					'display': 'flex',
 					'flexDirection': 'row',
 					'alignItems': 'center',
-					'paddingLeft': ({ spacing }) => spacing(0.5),
 					':hover': {
 						backgroundColor: ({ palette }) => palette.action.hover
 					}
 				}}>
-				{hasVisibleChildren(object) && (
+				{hasChild ? (
 					<ChevronRightIcon
 						onClick={onToggle}
 						sx={{
@@ -178,6 +132,8 @@ export function SidebarTreeItem(props: {
 							transition: 'transform 0.2s'
 						}}
 					/>
+				) : (
+					<Icon />
 				)}
 				<Stack
 					direction='row'
@@ -234,7 +190,6 @@ export function SidebarTreeItem(props: {
 									new SetValueCommand(editor, object, 'visible', value)
 								);
 							}}
-							disabled={object.parent?.visible === false}
 							icon={<VisibilityOffIcon />}
 							checkedIcon={<VisibilityIcon />}
 						/>
@@ -242,12 +197,12 @@ export function SidebarTreeItem(props: {
 				</Stack>
 			</Box>
 
-			{contextOptions.length > 0 && (
+			{actions.length > 0 && (
 				<Menu
 					{...bindMenu(popupState)}
 					anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
 					transformOrigin={{ vertical: 'top', horizontal: 'left' }}>
-					{contextOptions}
+					{actions}
 				</Menu>
 			)}
 		</>
diff --git a/src/ThreeEditor/components/Sidebar/SidebarTreeList/contextActions.tsx b/src/ThreeEditor/components/Sidebar/SidebarTreeList/contextActions.tsx
new file mode 100644
index 000000000..4cd21028b
--- /dev/null
+++ b/src/ThreeEditor/components/Sidebar/SidebarTreeList/contextActions.tsx
@@ -0,0 +1,90 @@
+import { MenuItem } from '@mui/material';
+import { PopupState } from 'material-ui-popup-state/hooks';
+import { Object3D } from 'three';
+
+import { getRemoveCommand } from '../../../../util/hooks/useKeyboardEditorControls';
+import { AddQuantityCommand } from '../../../js/commands/AddQuantityCommand';
+import { getDuplicateCommand } from '../../../js/commands/DuplicateObjectCommand';
+import { YaptideEditor } from '../../../js/YaptideEditor';
+import { isSimulationElement, SimulationElement } from '../../../Simulation/Base/SimulationElement';
+import { ScoringOutput } from '../../../Simulation/Scoring/ScoringOutput';
+
+export type CommonContextActionProps = {
+	object: Object3D | SimulationElement;
+	editor: YaptideEditor;
+	popupState: PopupState;
+};
+
+export type DuplicateActionProps = CommonContextActionProps;
+
+export function DuplicateAction(props: DuplicateActionProps) {
+	const { object, editor, popupState } = props;
+
+	return (
+		<MenuItem
+			onClick={() => {
+				if (!isSimulationElement(object)) return;
+				const command = getDuplicateCommand(editor, object);
+				editor.execute(command);
+				popupState.close();
+			}}>
+			Duplicate
+		</MenuItem>
+	);
+}
+
+export type RenameActionProps = CommonContextActionProps & {
+	setMode: (mode: 'view' | 'edit') => void;
+};
+
+export function RenameAction(props: RenameActionProps) {
+	const { popupState, setMode } = props;
+
+	return (
+		<MenuItem
+			onClick={() => {
+				popupState.close();
+				setMode('edit');
+			}}>
+			Rename
+		</MenuItem>
+	);
+}
+
+export type AddQuantityActionProps = CommonContextActionProps & {
+	object: ScoringOutput;
+	isOpen: boolean;
+	onToggle: () => void;
+};
+
+export function AddQuantityAction(props: AddQuantityActionProps) {
+	const { object, editor, popupState, isOpen, onToggle } = props;
+
+	return (
+		<MenuItem
+			onClick={() => {
+				editor.execute(new AddQuantityCommand(editor, object));
+
+				if (!isOpen) onToggle();
+				popupState.close();
+			}}>
+			Add Quantity
+		</MenuItem>
+	);
+}
+
+export type DeleteActionProps = CommonContextActionProps;
+
+export function DeleteAction(props: DeleteActionProps) {
+	const { object, editor, popupState } = props;
+
+	return (
+		<MenuItem
+			onClick={() => {
+				editor.execute(getRemoveCommand(editor, object));
+				popupState.close();
+			}}>
+			Delete
+		</MenuItem>
+	);
+}
diff --git a/src/ThreeEditor/components/Sidebar/SidebarTreeList/useGenerateTreeData.ts b/src/ThreeEditor/components/Sidebar/SidebarTreeList/useGenerateTreeData.ts
new file mode 100644
index 000000000..bafae4da7
--- /dev/null
+++ b/src/ThreeEditor/components/Sidebar/SidebarTreeList/useGenerateTreeData.ts
@@ -0,0 +1,81 @@
+import { useCallback, useState } from 'react';
+import { Object3D } from 'three';
+
+import { hasVisibleChildren } from '../../../../util/hooks/useKeyboardEditorControls';
+import {
+	isSimulationSceneContainer,
+	SimulationSceneChild
+} from '../../../Simulation/Base/SimulationContainer';
+import { isSimulationElement } from '../../../Simulation/Base/SimulationElement';
+import { TreeItem } from './SidebarTreeListItem';
+
+function elementToTreeItem(
+	object: SimulationSceneChild,
+	parent: number,
+	index: number,
+	treeId: string
+): TreeItem {
+	const { id, name: text } = object;
+
+	return {
+		id,
+		parent,
+		droppable: true,
+		text,
+		data: { object, treeId, index }
+	};
+}
+
+function validateElement(object: Object3D): object is SimulationSceneChild {
+	return isSimulationElement(object) || isSimulationSceneContainer(object);
+}
+
+/**
+ * Build tree options recursively
+ *
+ * Provided object is added to the tree if it is not a container
+ * or if it is a container but its flattenOnOutliner flag is false
+ *
+ * Then function runs recursively on all children of the provided object
+ * if it is a container and has visible children
+ *
+ * All elements that don't pass a validation with {@link validateElement} are filtered out
+ */
+function buildOptionsRecursively(
+	object: SimulationSceneChild,
+	index: number,
+	treeId: string,
+	parent: number = 0
+): TreeItem[] {
+	const children: TreeItem[] = [];
+
+	if (!isSimulationSceneContainer(object) || !object.flattenOnOutliner) {
+		children.push(elementToTreeItem(object, parent, index, treeId));
+		parent = object.id;
+	}
+
+	if (object.children.length > 0 && hasVisibleChildren(object)) {
+		const itemChildren = object.children
+			.filter(validateElement)
+			.flatMap((child, idx) => buildOptionsRecursively(child, idx, treeId, parent));
+		children.push(...itemChildren);
+	}
+
+	return children;
+}
+
+export function useGenerateTreeData(
+	treeId: string,
+	sources: SimulationSceneChild[]
+): [TreeItem[], () => void] {
+	const [treeData, setTreeData] = useState<TreeItem[]>([]);
+
+	const refreshTreeData = useCallback(() => {
+		const options = sources.flatMap((source, idx) =>
+			buildOptionsRecursively(source, idx, treeId)
+		);
+		setTreeData(options);
+	}, [buildOptionsRecursively, sources]);
+
+	return [treeData, refreshTreeData];
+}
diff --git a/src/ThreeEditor/js/commands/ChangeObjectOrderCommand.ts b/src/ThreeEditor/js/commands/ChangeObjectOrderCommand.ts
deleted file mode 100644
index e98994b7d..000000000
--- a/src/ThreeEditor/js/commands/ChangeObjectOrderCommand.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import * as THREE from 'three';
-
-import { Command, CommandJSON } from '../Command';
-import { YaptideEditor } from '../YaptideEditor.js';
-
-interface ChangeObjectOrderCommandJSON extends CommandJSON {
-	oldIndex: number;
-	newIndex: number;
-	objectUuid: string;
-	selectedUuid: string;
-}
-
-/**
- * @param editor Editor
- * @param object THREE.Object3D
- * @param newIndex number
- * @constructor
- */
-export class ChangeObjectOrderCommand extends Command {
-	object: THREE.Object3D;
-	parent: THREE.Object3D;
-	newIndex: number;
-	oldIndex: number;
-	oldSelect: THREE.Object3D | null;
-	constructor(editor: YaptideEditor, object: THREE.Object3D, newIndex: number) {
-		super(editor);
-
-		if (!object.parent) throw new Error('The object does not have a parent.');
-
-		this.type = 'ChangeObjectOrderCommand';
-		this.object = object;
-		this.newIndex = newIndex;
-		this.parent = object.parent;
-		this.oldIndex = object.parent.children.indexOf(object);
-		this.oldSelect = editor.selected;
-
-		if (this.parent.children[this.newIndex] === undefined)
-			throw new Error('The new index is not valid.');
-
-		if (this.oldIndex === -1) throw new Error('The object is not a child of the given parent.'); // should never happen.
-	}
-
-	execute() {
-		this.changeOrderOfObject(this.newIndex, this.oldIndex);
-		this.editor.select(this.object);
-	}
-
-	undo() {
-		this.changeOrderOfObject(this.oldIndex, this.newIndex);
-		this.editor.select(this.oldSelect);
-	}
-
-	private changeOrderOfObject(newIndex: number, oldIndex: number) {
-		if (oldIndex === newIndex)
-			return console.warn('ChangeObjectOrderCommand: oldIndex and newIndex are the same.');
-
-		const element = this.parent.children.splice(oldIndex, 1)[0];
-
-		if (element !== this.object) throw new Error('Object not in expected position.');
-
-		this.parent.children.splice(newIndex, 0, this.object);
-
-		this.editor.signals.objectChanged.dispatch(this.parent, 'children');
-		this.editor.signals.sceneGraphChanged.dispatch();
-	}
-
-	toSerialized() {
-		const output: ChangeObjectOrderCommandJSON = {
-			...super.toSerialized(),
-			objectUuid: this.object.uuid,
-			oldIndex: this.oldIndex,
-			newIndex: this.newIndex,
-			selectedUuid: this.editor.selected ? this.editor.selected.uuid : ''
-		};
-
-		return output;
-	}
-
-	fromSerialized(json: ChangeObjectOrderCommandJSON) {
-		super.fromSerialized(json);
-
-		const found = this.editor.objectByUuid(json.objectUuid);
-
-		if (!found) throw new Error('The object was not found in the scene.');
-
-		this.object = found;
-		this.oldIndex = json.oldIndex;
-		this.newIndex = json.newIndex;
-
-		this.oldSelect = this.editor.objectByUuid(json.selectedUuid) ?? null;
-	}
-}
diff --git a/src/ThreeEditor/js/commands/MoveObjectInTreeCommand.ts b/src/ThreeEditor/js/commands/MoveObjectInTreeCommand.ts
new file mode 100644
index 000000000..748955159
--- /dev/null
+++ b/src/ThreeEditor/js/commands/MoveObjectInTreeCommand.ts
@@ -0,0 +1,165 @@
+import * as THREE from 'three';
+
+import { OneSlotContainer } from '../../Simulation/Base/SimulationContainer';
+import { SimulationElement } from '../../Simulation/Base/SimulationElement';
+import { SimulationMesh } from '../../Simulation/Base/SimulationMesh';
+import { SimulationZone } from '../../Simulation/Base/SimulationZone';
+import { Detector } from '../../Simulation/Detectors/Detector';
+import { ScoringFilter } from '../../Simulation/Scoring/ScoringFilter';
+import { ScoringOutput } from '../../Simulation/Scoring/ScoringOutput';
+import { ScoringQuantity } from '../../Simulation/Scoring/ScoringQuantity';
+import { Command, CommandJSON } from '../Command';
+import { YaptideEditor } from '../YaptideEditor.js';
+
+interface MoveObjectInTreeCommandJSON extends CommandJSON {
+	oldIndex: number;
+	newIndex: number;
+	oldParentUuid: string;
+	newParentUuid: string;
+	objectUuid: string;
+	selectedUuid: string;
+}
+
+export class MoveObjectInTreeCommand extends Command {
+	object: THREE.Object3D;
+	oldParent: THREE.Object3D;
+	newParent: THREE.Object3D;
+	newIndex: number;
+	oldIndex: number;
+	oldSelect: THREE.Object3D | null;
+	constructor(
+		editor: YaptideEditor,
+		object: THREE.Object3D,
+		newIndex: number,
+		newParent: THREE.Object3D | null
+	) {
+		super(editor);
+
+		if (!object.parent) {
+			throw new Error('The object does not have a parent.');
+		}
+
+		this.type = 'MoveObjectInTreeCommand';
+		this.object = object;
+		this.oldParent = object.parent;
+		this.newParent = newParent ?? this.resolveBaseParent();
+
+		this.newIndex = newIndex;
+
+		if (this.newParent.children.length < this.newIndex) {
+			throw new Error('The new index is not valid.');
+		}
+
+		this.oldIndex = this.oldParent.children.indexOf(object);
+
+		if (this.oldIndex === -1) {
+			throw new Error('The object is not a child of the given parent.');
+		}
+
+		this.oldSelect = editor.selected;
+	}
+
+	execute() {
+		this.doExecute(this.newIndex, this.newParent, this.oldParent);
+		this.editor.select(this.object);
+	}
+
+	undo() {
+		this.doExecute(this.oldIndex, this.oldParent, this.newParent);
+		this.editor.select(this.oldSelect);
+	}
+
+	private doExecute(newIndex: number, newParent: THREE.Object3D, oldParent: THREE.Object3D) {
+		if (oldParent !== newParent) {
+			newParent.attach(this.object);
+		}
+
+		let currentIndex = 0;
+
+		for (let i = 0; i < newParent.children.length; i++) {
+			if (newParent.children[i].uuid === this.object.uuid) {
+				currentIndex = i;
+
+				break;
+			}
+		}
+
+		const element = newParent.children.splice(currentIndex, 1)[0];
+
+		if (element !== this.object) {
+			throw new Error('Object not in expected position.');
+		}
+
+		newParent.children.splice(newIndex, 0, this.object);
+
+		this.editor.signals.objectChanged.dispatch(oldParent, 'children');
+
+		if (oldParent !== newParent) {
+			this.editor.signals.objectChanged.dispatch(newParent, 'children');
+		}
+
+		this.editor.signals.sceneGraphChanged.dispatch();
+	}
+
+	private resolveBaseParent(): THREE.Object3D {
+		switch (true) {
+			case this.object instanceof SimulationZone:
+				return this.editor.zoneManager.zoneContainer;
+			case this.object instanceof Detector:
+				return this.editor.detectorManager.detectorContainer;
+			case this.object instanceof ScoringFilter:
+				return this.editor.scoringManager.filterContainer;
+			case this.object instanceof ScoringOutput:
+				return this.editor.scoringManager.outputContainer;
+			case this.object instanceof SimulationMesh:
+				return this.editor.figureManager.figureContainer;
+			default:
+				throw new Error('Invalid object');
+		}
+	}
+
+	toSerialized() {
+		const output: MoveObjectInTreeCommandJSON = {
+			...super.toSerialized(),
+			objectUuid: this.object.uuid,
+			oldParentUuid: this.oldParent.uuid,
+			newParentUuid: this.newParent.uuid,
+			oldIndex: this.oldIndex,
+			newIndex: this.newIndex,
+			selectedUuid: this.editor.selected ? this.editor.selected.uuid : ''
+		};
+
+		return output;
+	}
+
+	fromSerialized(json: MoveObjectInTreeCommandJSON) {
+		super.fromSerialized(json);
+
+		const found = this.editor.objectByUuid(json.objectUuid);
+
+		if (!found) {
+			throw new Error('The object was not found in the scene.');
+		}
+
+		this.object = found;
+		this.oldIndex = json.oldIndex;
+		this.newIndex = json.newIndex;
+
+		const oldParentSearch = this.editor.objectByUuid(json.oldParentUuid);
+
+		if (!oldParentSearch) {
+			throw new Error('The old parent object was not found in the scene.');
+		}
+
+		const newParentSearch = this.editor.objectByUuid(json.newParentUuid);
+
+		if (!newParentSearch) {
+			throw new Error('The new parent object was not found in the scene.');
+		}
+
+		this.oldParent = oldParentSearch;
+		this.newParent = newParentSearch;
+
+		this.oldSelect = this.editor.objectByUuid(json.selectedUuid) ?? null;
+	}
+}
diff --git a/src/util/Ui/CommandButtonProps.ts b/src/util/Ui/CommandButtonProps.ts
index 973f8fa80..64af526bc 100644
--- a/src/util/Ui/CommandButtonProps.ts
+++ b/src/util/Ui/CommandButtonProps.ts
@@ -42,7 +42,40 @@ export const createCommandButtonProps = (
  */
 export const getAddElementButtonProps = (editor: YaptideEditor): GroupedCommandButtonProps => {
 	const commandFactory = new ObjectManagementFactory(editor);
-	const figuresTuple: CommandButtonTuple[] = [
+	const cfgFiguresTuple: CommandButtonTuple[] = [
+		[
+			'Box',
+			() => {
+				return commandFactory.createAddCommand<'figure', BasicFigure>(
+					'figure',
+					new BoxFigure(editor),
+					editor.figureManager
+				);
+			}
+		],
+		[
+			'Cylinder',
+			() => {
+				return commandFactory.createAddCommand<'figure', BasicFigure>(
+					'figure',
+					new CylinderFigure(editor),
+					editor.figureManager
+				);
+			}
+		],
+		[
+			'Sphere',
+			() => {
+				return commandFactory.createAddCommand<'figure', BasicFigure>(
+					'figure',
+					new SphereFigure(editor),
+					editor.figureManager
+				);
+			}
+		]
+	];
+
+	const nestedFiguresTuple: CommandButtonTuple[] = [
 		[
 			'Box',
 			() => {
@@ -181,7 +214,8 @@ export const getAddElementButtonProps = (editor: YaptideEditor): GroupedCommandB
 	];
 
 	return {
-		'Figures': createCommandButtonProps(editor, figuresTuple),
+		'NestedFigures': createCommandButtonProps(editor, nestedFiguresTuple),
+		'CFGFigures': createCommandButtonProps(editor, cfgFiguresTuple),
 		'Zones': createCommandButtonProps(editor, zonesTuple),
 		'Detectors': createCommandButtonProps(editor, detectorsTuple),
 		'Special Components': createCommandButtonProps(editor, specialComponentsTuple),
@@ -200,7 +234,14 @@ type CommandButtonTuple =
 	| [string, (() => Command) | undefined]
 	| [string, (() => Command) | undefined, boolean];
 
-type ManagerName = 'Figures' | 'Zones' | 'Detectors' | 'Special Components' | 'Filters' | 'Outputs';
+type ManagerName =
+	| 'NestedFigures'
+	| 'CFGFigures'
+	| 'Zones'
+	| 'Detectors'
+	| 'Special Components'
+	| 'Filters'
+	| 'Outputs';
 
 type GroupedCommandButtonProps = Record<ManagerName, CommandButtonProps[]>;
 //----------------------------------------------------------------------------------------------//


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refactors the SidebarTree component into a new SidebarTreeList component that supports both nested and flat lists while also introducing GEANT4-related features. Key changes include refactoring command button properties to differentiate between flat and nested figures, adding GEANT4 simulator support in request types and example configurations, and removing the old SidebarTree component in favor of the new SidebarTreeList component.

### Reviewed Changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File                                                                              | Description                                                                   |
| --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| src/util/Ui/CommandButtonProps.ts                                                 | Updated command button tuples with distinct keys for nested and flat figures. |
| src/types/RequestTypes.ts                                                         | Added GEANT4 options to SimulatorType and updated SimulatorNames map.         |
| src/examples/exampleMap.json                                                      | Included a new GEANT4 example configuration.                                  |
| src/ThreeEditor/js/commands/MoveObjectInTreeCommand.ts                            | Introduced a new command to handle moving objects within the tree.            |
| src/ThreeEditor/js/commands/ChangeObjectOrderCommand.ts                           | Removed redundant ChangeObjectOrderCommand.                                  |
| src/ThreeEditor/components/Sidebar/SidebarTreeList/useGenerateTreeData.ts           | Added a hook to generate tree data from simulation scene objects.             |
| src/ThreeEditor/components/Sidebar/SidebarTreeList/contextActions.tsx              | Added various context menu actions for tree list items.                       |
| src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeListItem.tsx         | Updated tree item layout and actions; adjusted indentation and component usage.|
| src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeList.tsx             | Introduced the new tree list component that replaces the old SidebarTree.     |
| src/ThreeEditor/components/Sidebar/EditorSidebar.tsx                             | Updated imports and integration for the new SidebarTreeList component.        |
| src/ThreeEditor/Simulation/Scoring/ScoringOutputTypes.ts                          | Added GEANT4-specific scoring options configuration.                          |
| src/ThreeEditor/Simulation/Figures/FigureManager.ts                               | Changed figureLoader export for broader usage in figure deserialization.      |
| src/ThreeEditor/Simulation/Base/SimulationMesh.ts                                | Updated serialization/deserialization to include children via figureLoader.   |
| src/App.tsx                                                                       | Enabled additional CSS variables via the theme configuration.               |
| public/examples/ex8.json                                                          | Added a new example JSON configuration for GEANT4 projects.                   |
</details>



<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**src/util/Ui/CommandButtonProps.ts:45**
* [nitpick] The new variable names 'cfgFiguresTuple' and 'nestedFiguresTuple' differ from the previous 'figuresTuple'. Consider using more descriptive names that clearly indicate their purpose (e.g. 'flatFiguresTuple' vs. 'nestedFiguresTuple') to improve clarity.
```
const cfgFiguresTuple: CommandButtonTuple[] = [
```
**src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeListItem.tsx:119**
* [nitpick] The indentation factor has been reduced from 2.5 to 1.5; please verify that this change aligns with the intended UI design and provides a clear visual hierarchy.
```
marginLeft: ({ spacing }) => spacing(depth * 1.5),
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T18:02:23Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This pull request implements a rewrite of the SidebarTree component to support nested structures and replaces the flat list version with a new, more flexible SidebarTreeList. Key changes include:
- Updating command button properties and manager types to include new categories for nested figures.
- Introducing a new MoveObjectInTreeCommand while removing the deprecated ChangeObjectOrderCommand.
- Refactoring the sidebar tree components (SidebarTreeList, SidebarTreeListItem, etc.) and updating styling and editor sidebar integrations.
- Adjusting figure management to export figureLoader for proper deserialization of nested SimulationMesh children.

### Reviewed Changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| src/util/Ui/CommandButtonProps.ts | Updated tuple variables and ManagerName type to support nested and CFG figures. |
| src/ThreeEditor/js/commands/MoveObjectInTreeCommand.ts | Added new command for moving objects within the nested tree. |
| src/ThreeEditor/js/commands/ChangeObjectOrderCommand.ts | Removed obsolete command handling. |
| src/ThreeEditor/components/Sidebar/SidebarTreeList/useGenerateTreeData.ts | New hook for generating tree data recursively. |
| src/ThreeEditor/components/Sidebar/SidebarTreeList/contextActions.tsx | New context actions for sidebar item interactions. |
| src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeListItem.tsx | Refactored tree item component with updated styling and actions. |
| src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeList.tsx | Introduced new configurable tree list component. |
| src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeList.style.css | New styles for the sidebar tree list. |
| src/ThreeEditor/components/Sidebar/EditorSidebar.tsx | Updated to integrate the new SidebarTreeList component. |
| src/ThreeEditor/Simulation/Figures/FigureManager.ts | Figure loader now exported for use in SimulationMesh deserialization. |
| src/ThreeEditor/Simulation/Base/SimulationMesh.ts | Enhanced deserialization by recursively processing child meshes. |
| src/App.tsx | Minor styling update by enabling CSS variables. |
</details>



<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**src/util/Ui/CommandButtonProps.ts:45**
* [nitpick] Consider clarifying the naming for 'cfgFiguresTuple' to clearly differentiate it from the nested figures tuple; a more descriptive name would improve readability.
```
const cfgFiguresTuple: CommandButtonTuple[] = [
```
**src/ThreeEditor/components/Sidebar/SidebarTreeList/SidebarTreeListItem.tsx:119**
* [nitpick] Ensure that the updated marginLeft calculation (depth * 1.5) consistently matches the overall design system for proper indentation in the tree view.
```
'marginLeft': ({ spacing }) => spacing(depth * 1.5),
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T12:49:28Z
user: login: grzanka
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:10:31Z
user: login: grzanka
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:28:50Z

Review comments:
diff_hunk: @@ -64,11 +66,18 @@ export abstract class SimulationMesh<
 			uuid,
 			visible,
 			geometryData,
-			colorHex
+			colorHex,
+			children: this.children.map(c => (c as SimulationMesh).toSerialized())
 		};
 	}
 
 	fromSerialized(json: SimulationMeshJSON) {
+		const loader = figureLoader(this.editor);
+
+		if (json.children && json.children.length > 0) {
+			json.children.map(childJson => this.add(loader(childJson)));
path: src/ThreeEditor/Simulation/Base/SimulationMesh.ts
user: login: Copilot
body: [nitpick] Using Array.map for side-effects is unconventional; consider using forEach to make it clear that the purpose is to execute a side effect rather than transform the array.
```suggestion
			json.children.forEach(childJson => this.add(loader(childJson)));
```
created_at: 2025-05-07T18:02:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,167 @@
+import * as THREE from 'three';
+
+import { OneSlotContainer } from '../../Simulation/Base/SimulationContainer';
+import { SimulationElement } from '../../Simulation/Base/SimulationElement';
+import { SimulationMesh } from '../../Simulation/Base/SimulationMesh';
+import { SimulationZone } from '../../Simulation/Base/SimulationZone';
+import { Detector } from '../../Simulation/Detectors/Detector';
+import { ScoringFilter } from '../../Simulation/Scoring/ScoringFilter';
+import { ScoringOutput } from '../../Simulation/Scoring/ScoringOutput';
+import { ScoringQuantity } from '../../Simulation/Scoring/ScoringQuantity';
+import { Command, CommandJSON } from '../Command';
+import { YaptideEditor } from '../YaptideEditor.js';
+
+interface MoveObjectInTreeCommandJSON extends CommandJSON {
+	oldIndex: number;
+	newIndex: number;
+	oldParentUuid: string;
+	newParentUuid: string;
+	objectUuid: string;
+	selectedUuid: string;
+}
+
+export class MoveObjectInTreeCommand extends Command {
+	object: THREE.Object3D;
+	oldParent: THREE.Object3D;
+	newParent: THREE.Object3D;
+	newIndex: number;
+	oldIndex: number;
+	oldSelect: THREE.Object3D | null;
+	constructor(
+		editor: YaptideEditor,
+		object: THREE.Object3D,
+		newIndex: number,
+		newParent: THREE.Object3D | null
+	) {
+		super(editor);
+
+		if (!object.parent) {
+			throw new Error('The object does not have a parent.');
+		}
+
+		this.type = 'MoveObjectInTreeCommand';
+		this.object = object;
+		this.oldParent = object.parent;
+		this.newParent = newParent ?? this.resolveBaseParent();
+
+		this.newIndex = newIndex;
+
+		if (this.newParent.children.length < this.newIndex) {
+			throw new Error('The new index is not valid.');
+		}
+
+		this.oldIndex = this.oldParent.children.indexOf(object);
+
+		if (this.oldIndex === -1) {
+			throw new Error('The object is not a child of the given parent.');
+		}
+
+		this.oldSelect = editor.selected;
+	}
+
+	execute() {
+		this.doExecute(this.newIndex, this.newParent, this.oldParent);
+		this.editor.select(this.object);
+	}
+
+	undo() {
+		this.doExecute(this.oldIndex, this.oldParent, this.newParent);
+		this.editor.select(this.oldSelect);
+	}
+
+	private doExecute(newIndex: number, newParent: THREE.Object3D, oldParent: THREE.Object3D) {
+		if (oldParent !== newParent) {
+			newParent.attach(this.object);
+		}
+
+		let currentIndex = 0;
+
+		for (let i = 0; i < newParent.children.length; i++) {
+			if (newParent.children[i].uuid === this.object.uuid) {
+				currentIndex = i;
+
+				break;
+			}
+		}
+
+		const element = newParent.children.splice(currentIndex, 1)[0];
+
+		if (element !== this.object) {
+			throw new Error('Object not in expected position.');
+		}
+
+		newParent.children.splice(newIndex, 0, this.object);
+
+		this.editor.signals.objectChanged.dispatch(oldParent, 'children');
+
+		if (oldParent !== newParent) {
+			this.editor.signals.objectChanged.dispatch(newParent, 'children');
+		}
+
+		this.editor.signals.sceneGraphChanged.dispatch();
+	}
+
+	private resolveBaseParent(): THREE.Object3D {
+		console.log(this.object);
path: src/ThreeEditor/js/commands/MoveObjectInTreeCommand.ts
user: login: grzanka
body: ```suggestion
```

do we need this ?
created_at: 2025-05-15T14:10:31Z
author_association: CONTRIBUTOR

PR comments:
user: login: grzanka
created_at: 2025-05-13T09:06:13Z
body: @kmichalikk some conflicts appeared
author_association: CONTRIBUTOR
user: login: grzanka
created_at: 2025-05-13T20:20:33Z
body: @kmichalikk https://github.com/yaptide/ui/pull/2039 was merged just now, so please fix the conflicts
author_association: CONTRIBUTOR
user: login: kmichalikk
created_at: 2025-05-13T20:47:45Z
body: I fixed them but the PR is not ready yet, I'm currently trying to refactor the component to be more universal
author_association: CONTRIBUTOR
user: login: kmichalikk
created_at: 2025-05-13T21:41:09Z
body: For now, I removed the special case for ScoringInput which turned out to be easy

the rest works okay, some elements could be abstracted further but we'll do it only when it's needed
author_association: CONTRIBUTOR