diff --git a/docs/technical/editor.md b/docs/technical/editor.md
index 0587e12..08c1084 100644
--- a/docs/technical/editor.md
+++ b/docs/technical/editor.md
@@ -52,7 +52,7 @@ src/
## Responsibilities
-`src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as selected object, hovered object, transform mode, selection lock, player-mode toggle, cinematic preview requests, and editor scene loading state.
+`src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as primary selected object, selected object indexes, hovered object, transform mode, selection lock, player-mode toggle, cinematic preview requests, and editor scene loading state.
`src/hooks/editor/useEditorSceneData.ts` loads the default map data and handles folder uploads.
@@ -60,7 +60,7 @@ src/
`src/components/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, keyboard shortcuts, and `EditorMap`.
-`src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls.
+`src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls. For multi-selection, it attaches `TransformControls` to a temporary group centered on the selected nodes, then decomposes the group delta back into each selected node transform.
`src/components/editor/EditorControls.tsx` renders the HTML control panel outside the canvas. The panel is organized into top-level `details` groups: `Editor`, `Cinematics`, `Dialogues`, and `SRT`.
@@ -115,11 +115,12 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
4. If `/map.json` is missing, the page displays a folder-upload flow.
5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
-7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, and the cinematic/dialogue/SRT editors.
+7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, multi-selection status, and the cinematic/dialogue/SRT editors.
## Controls
- Click: select a node.
+- `Shift` + right click: add or remove a node from the multi-selection.
- `Esc`: clear selection.
- Click empty space: clear selection.
- Selection lock button: prevent object clicks, empty-space clicks, and `Esc` from changing the current selection.
@@ -128,6 +129,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
- `R`: rotate mode.
- `S`: scale mode.
- Snap terrain on move: enabled by default and applied while translating an object.
+- Multi-selection transforms use a temporary centered group and write the resulting position, rotation, and scale back to every selected map node.
- Lock terrain: enabled by default so terrain remains visible but ignores selection clicks.
- Camera action: centers on the selected object or resets to the editor home view.
- Add node: creates a fallback cube under `blocking` using the requested model folder name.
diff --git a/docs/user/editor.md b/docs/user/editor.md
index 0c1a230..67ee333 100644
--- a/docs/user/editor.md
+++ b/docs/user/editor.md
@@ -45,14 +45,15 @@ Only the `Editor` group is open by default. Open the other groups when you need
1. Open `/editor` in the local app.
2. Click an object in the scene to select it.
-3. Choose a transform mode: translate, rotate, or scale.
-4. Drag the transform gizmo in the 3D view.
-5. Keep `Snap terrain on move` enabled when placing objects on the terrain.
-6. Use `Center on object` or `Reset camera` from the `View` section when navigating large maps.
-7. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
-8. Check the JSON inspector if you need exact values.
-9. Use undo or redo if the transform is not correct.
-10. Export the JSON or save it to the dev server.
+3. Use `Shift + right click` on other objects to add or remove them from the current multi-selection.
+4. Choose a transform mode: translate, rotate, or scale.
+5. Drag the transform gizmo in the 3D view. With multiple objects selected, the gizmo transforms the selected group and writes each object transform back to `map.json`.
+6. Keep `Snap terrain on move` enabled when placing objects on the terrain.
+7. Use `Center on object` or `Reset camera` from the `View` section when navigating large maps.
+8. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
+9. Check the JSON inspector if you need exact values.
+10. Use undo or redo if the transform is not correct.
+11. Export the JSON or save it to the dev server.
## Adding And Deleting Nodes
@@ -70,6 +71,7 @@ Use the trash button in `Selection` to delete the selected node from the map tre
| Action | Input |
| -------------------- | -------------------------- |
| Select object | Click object |
+| Toggle multi-select | `Shift` + right click |
| Deselect | `Esc` or click empty space |
| Lock selection | `Lock` button in Selection |
| Clear selection | `X` button in Selection |
@@ -87,6 +89,8 @@ Use the trash button in `Selection` to delete the selected node from the map tre
The `Selection` section shows the selected object name and its index in `public/map.json`.
- Click an object to select it.
+- Use `Shift + right click` on objects to add or remove them from a multi-selection.
+- When several objects are selected, the gizmo appears on the selection group and applies translate, rotate, or scale to each selected node.
- Click empty space or press `Esc` to clear the selection.
- Use the `X` button to clear the selection explicitly.
- Use the `Lock` button to protect the current selection while editing.
diff --git a/src/components/editor/EditorControls.tsx b/src/components/editor/EditorControls.tsx
index 8d1e090..7080eec 100644
--- a/src/components/editor/EditorControls.tsx
+++ b/src/components/editor/EditorControls.tsx
@@ -29,6 +29,7 @@ interface EditorControlsProps {
transformMode: TransformMode;
onTransformModeChange: (mode: TransformMode) => void;
selectedNodeIndex: number | null;
+ selectedNodeIndexes: number[];
mapNodes: MapNode[];
nodesCount: number;
selectedNodeName: string | null;
@@ -66,6 +67,7 @@ const TRANSFORM_OPTIONS = [
const EDITOR_SHORTCUTS = [
["Click", "Select object"],
+ ["Shift + Right click", "Toggle multi-selection"],
["T / R / S", "Transform mode"],
["Ctrl Z / Y", "Undo / redo"],
["Esc", "Deselect"],
@@ -103,6 +105,7 @@ export function EditorControls({
transformMode,
onTransformModeChange,
selectedNodeIndex,
+ selectedNodeIndexes,
mapNodes,
nodesCount,
selectedNodeName,
@@ -135,6 +138,7 @@ export function EditorControls({
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
const selectedNode =
selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null;
+ const selectionCount = selectedNodeIndexes.length;
const transformValues = getTransformValues(selectedNode ?? null);
return (
@@ -240,10 +244,14 @@ export function EditorControls({
- {selectedNodeName || `Node ${selectedNodeIndex + 1}`}
+ {selectionCount > 1
+ ? `${selectionCount} selected nodes`
+ : selectedNodeName || `Node ${selectedNodeIndex + 1}`}
- Index {selectedNodeIndex + 1} of {nodesCount}
+ {selectionCount > 1
+ ? `Primary index ${selectedNodeIndex + 1} of ${nodesCount}`
+ : `Index ${selectedNodeIndex + 1} of ${nodesCount}`}
diff --git a/src/components/editor/scene/EditorMap.tsx b/src/components/editor/scene/EditorMap.tsx
index 18456d1..23ed38a 100644
--- a/src/components/editor/scene/EditorMap.tsx
+++ b/src/components/editor/scene/EditorMap.tsx
@@ -1,4 +1,4 @@
-import { useRef, useEffect, useState } from "react";
+import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { Grid, TransformControls } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three";
@@ -16,7 +16,9 @@ import {
interface EditorMapProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
+ selectedNodeIndexes: number[];
onSelectNode: (index: number | null) => void;
+ onToggleNodeSelection: (index: number) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
@@ -37,16 +39,31 @@ interface EditorNodeCommonProps {
isHovered: boolean;
objectsMapRef: EditorNodeObjectRef;
onSelectNode: (index: number | null) => void;
+ onToggleNodeSelection: (index: number) => void;
isSelectionLocked: boolean;
onHoverNode: (index: number | null) => void;
}
interface EditorNodePointerHandlers {
onClick: (event: ThreeEvent
) => void;
+ onContextMenu: (event: ThreeEvent) => void;
onPointerEnter: (event: ThreeEvent) => void;
onPointerLeave: (event: ThreeEvent) => void;
}
+interface TransformSnapshot {
+ groupMatrix: THREE.Matrix4;
+ objects: Map;
+}
+
+const TEMP_BOX = new THREE.Box3();
+const TEMP_CENTER = new THREE.Vector3();
+const TEMP_DELTA_MATRIX = new THREE.Matrix4();
+const TEMP_INVERSE_GROUP_MATRIX = new THREE.Matrix4();
+const TEMP_POSITION = new THREE.Vector3();
+const TEMP_QUATERNION = new THREE.Quaternion();
+const TEMP_SCALE = new THREE.Vector3();
+
function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
object.position.set(...node.position);
object.rotation.set(...node.rotation);
@@ -118,6 +135,7 @@ function getNodeHighlightColor(
function createEditorNodePointerHandlers(
index: number,
onSelectNode: (index: number | null) => void,
+ onToggleNodeSelection: (index: number) => void,
isSelectionLocked: boolean,
onHoverNode: (index: number | null) => void,
): EditorNodePointerHandlers {
@@ -127,6 +145,12 @@ function createEditorNodePointerHandlers(
if (isSelectionLocked) return;
onSelectNode(index);
},
+ onContextMenu: (event) => {
+ event.stopPropagation();
+ event.nativeEvent.preventDefault();
+ if (!event.nativeEvent.shiftKey || isSelectionLocked) return;
+ onToggleNodeSelection(index);
+ },
onPointerEnter: (event) => {
event.stopPropagation();
onHoverNode(index);
@@ -141,7 +165,9 @@ function createEditorNodePointerHandlers(
export function EditorMap({
sceneData,
selectedNodeIndex,
+ selectedNodeIndexes,
onSelectNode,
+ onToggleNodeSelection,
isSelectionLocked,
hoveredNodeIndex,
onHoverNode,
@@ -153,18 +179,110 @@ export function EditorMap({
onNodeTransform,
}: EditorMapProps): React.JSX.Element {
const objectsMapRef = useRef