feat(editor): add multi-selection transforms

This commit is contained in:
Tom Boullay
2026-05-28 00:29:00 +02:00
parent 81cd935bba
commit 65651405b6
6 changed files with 233 additions and 46 deletions
+10 -2
View File
@@ -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({
<Box size={17} aria-hidden="true" />
<div>
<strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
{selectionCount > 1
? `${selectionCount} selected nodes`
: selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong>
<span>
Index {selectedNodeIndex + 1} of {nodesCount}
{selectionCount > 1
? `Primary index ${selectedNodeIndex + 1} of ${nodesCount}`
: `Index ${selectedNodeIndex + 1} of ${nodesCount}`}
</span>
</div>
<div className="editor-selected-actions">
+159 -29
View File
@@ -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<MouseEvent>) => void;
onContextMenu: (event: ThreeEvent<MouseEvent>) => void;
onPointerEnter: (event: ThreeEvent<PointerEvent>) => void;
onPointerLeave: (event: ThreeEvent<PointerEvent>) => void;
}
interface TransformSnapshot {
groupMatrix: THREE.Matrix4;
objects: Map<number, THREE.Matrix4>;
}
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<Map<number, THREE.Object3D>>(new Map());
const transformGroupRef = useRef<THREE.Group>(null);
const transformSnapshotRef = useRef<TransformSnapshot | null>(null);
const terrainHeight = useTerrainHeightSampler();
const handleTransformMouseDown = () => {
onTransformStart();
};
const selectedIndexSet = new Set(selectedNodeIndexes);
const isMultiSelection = selectedNodeIndexes.length > 1;
const handleTransformMouseUp = () => {
syncSelectedObjectTransform();
onTransformEnd();
};
const getTransformObject = useCallback(() => {
if (isMultiSelection) {
return transformGroupRef.current;
}
if (selectedNodeIndex !== null) {
return objectsMapRef.current.get(selectedNodeIndex) ?? null;
}
return null;
}, [isMultiSelection, selectedNodeIndex]);
const prepareTransformGroup = useCallback(() => {
if (!isMultiSelection || !transformGroupRef.current) return;
const selectedObjects = selectedNodeIndexes
.map((index) => objectsMapRef.current.get(index))
.filter((object): object is THREE.Object3D => Boolean(object));
if (selectedObjects.length === 0) return;
TEMP_BOX.makeEmpty();
for (const object of selectedObjects) {
object.updateWorldMatrix(true, false);
TEMP_BOX.expandByPoint(object.getWorldPosition(TEMP_CENTER));
}
TEMP_BOX.getCenter(TEMP_CENTER);
transformGroupRef.current.position.copy(TEMP_CENTER);
transformGroupRef.current.rotation.set(0, 0, 0);
transformGroupRef.current.scale.set(1, 1, 1);
transformGroupRef.current.updateMatrixWorld(true);
}, [isMultiSelection, selectedNodeIndexes]);
const createTransformSnapshot = useCallback((): TransformSnapshot | null => {
const transformGroup = transformGroupRef.current;
if (!isMultiSelection || !transformGroup) return null;
const objects = new Map<number, THREE.Matrix4>();
for (const index of selectedNodeIndexes) {
const object = objectsMapRef.current.get(index);
if (!object) continue;
object.updateMatrixWorld(true);
objects.set(index, object.matrix.clone());
}
transformGroup.updateMatrixWorld(true);
return {
groupMatrix: transformGroup.matrix.clone(),
objects,
};
}, [isMultiSelection, selectedNodeIndexes]);
const syncSelectedObjectTransform = () => {
if (isMultiSelection) {
const transformGroup = transformGroupRef.current;
const snapshot = transformSnapshotRef.current;
if (!transformGroup || !snapshot) return;
transformGroup.updateMatrix();
TEMP_INVERSE_GROUP_MATRIX.copy(snapshot.groupMatrix).invert();
TEMP_DELTA_MATRIX.multiplyMatrices(
transformGroup.matrix,
TEMP_INVERSE_GROUP_MATRIX,
);
for (const [index, startMatrix] of snapshot.objects) {
const obj = objectsMapRef.current.get(index);
const node = sceneData.mapNodes[index];
if (!obj || !node) continue;
const nextMatrix = TEMP_DELTA_MATRIX.clone().multiply(startMatrix);
nextMatrix.decompose(TEMP_POSITION, TEMP_QUATERNION, TEMP_SCALE);
obj.position.copy(TEMP_POSITION);
obj.quaternion.copy(TEMP_QUATERNION);
obj.scale.copy(TEMP_SCALE);
const terrainY = snapToTerrain
? terrainHeight.getHeight(obj.position.x, obj.position.z)
: null;
if (terrainY !== null && transformMode === "translate") {
obj.position.y = terrainY;
}
onNodeTransform(index, {
...node,
position: [obj.position.x, obj.position.y, obj.position.z],
rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z],
scale: [obj.scale.x, obj.scale.y, obj.scale.z],
});
}
return;
}
if (selectedNodeIndex !== null) {
const obj = objectsMapRef.current.get(selectedNodeIndex);
if (!obj) return;
@@ -194,25 +312,30 @@ export function EditorMap({
}
};
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
null,
);
const handleTransformMouseDown = () => {
prepareTransformGroup();
transformSnapshotRef.current = createTransformSnapshot();
onTransformStart();
};
const handleTransformMouseUp = () => {
syncSelectedObjectTransform();
transformSnapshotRef.current = null;
prepareTransformGroup();
onTransformEnd();
};
const terrainNode = getTerrainMapNode(sceneData.mapNodes);
const terrainNodeIndex = terrainNode
? sceneData.mapNodes.indexOf(terrainNode)
: -1;
const selectedNode =
selectedNodeIndex !== null ? sceneData.mapNodes[selectedNodeIndex] : null;
const selectedModelName = selectedNode?.name ?? null;
useLayoutEffect(() => {
prepareTransformGroup();
}, [prepareTransformGroup]);
useEffect(() => {
if (selectedNodeIndex !== null) {
const obj = objectsMapRef.current.get(selectedNodeIndex);
setSelectedObject(obj || null);
} else {
setSelectedObject(null);
}
}, [selectedNodeIndex]);
// TransformControls needs the current Three object; editor refs are managed outside React rendering.
// eslint-disable-next-line react-hooks/refs
const selectedObject = getTransformObject();
return (
<>
@@ -236,11 +359,12 @@ export function EditorMap({
<EditorTerrainNode
index={terrainNodeIndex}
node={terrainNode}
isSelected={selectedNodeIndex === terrainNodeIndex}
isSelected={selectedIndexSet.has(terrainNodeIndex)}
isHovered={hoveredNodeIndex === terrainNodeIndex}
lockTerrainSelection={lockTerrainSelection}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
@@ -250,10 +374,6 @@ export function EditorMap({
return null;
}
if (selectedModelName && node.name !== selectedModelName) {
return null;
}
const modelUrl = sceneData.models.get(node.name);
if (modelUrl) {
@@ -263,10 +383,11 @@ export function EditorMap({
index={index}
node={node}
modelUrl={modelUrl}
isSelected={selectedNodeIndex === index}
isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
@@ -277,10 +398,11 @@ export function EditorMap({
key={index}
index={index}
node={node}
isSelected={selectedNodeIndex === index}
isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
@@ -289,6 +411,8 @@ export function EditorMap({
})}
</group>
<group ref={transformGroupRef} />
{selectedObject && (
<TransformControls
object={selectedObject}
@@ -310,6 +434,7 @@ function EditorModelNode({
isHovered,
objectsMapRef,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps & {
@@ -329,6 +454,7 @@ function EditorModelNode({
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
);
@@ -403,6 +529,7 @@ function EditorTerrainNode({
lockTerrainSelection,
objectsMapRef,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps & { lockTerrainSelection: boolean }) {
@@ -410,6 +537,7 @@ function EditorTerrainNode({
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
);
@@ -435,6 +563,7 @@ function EditorFallbackNode({
isHovered,
objectsMapRef,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps) {
@@ -442,6 +571,7 @@ function EditorFallbackNode({
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
);
+6 -3
View File
@@ -5,7 +5,6 @@ import gsap from "gsap";
import * as THREE from "three";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
import { EditorMap } from "@/components/editor/scene/EditorMap";
import { TerrainModel } from "@/components/three/world/TerrainModel";
import { FlyController } from "@/controls/editor/FlyController";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
@@ -21,7 +20,9 @@ export interface EditorCinematicPreviewRequest {
interface EditorSceneProps {
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;
@@ -44,7 +45,9 @@ interface EditorSceneProps {
export function EditorScene({
sceneData,
selectedNodeIndex,
selectedNodeIndexes,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
hoveredNodeIndex,
onHoverNode,
@@ -209,7 +212,9 @@ export function EditorScene({
<EditorMap
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
@@ -221,8 +226,6 @@ export function EditorScene({
onNodeTransform={onNodeTransform}
/>
<TerrainModel />
<ambientLight intensity={0.6} />
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
+41 -1
View File
@@ -313,6 +313,7 @@ export function EditorPage(): React.JSX.Element {
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
null,
);
const [selectedNodeIndexes, setSelectedNodeIndexes] = useState<number[]>([]);
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
const [transformMode, setTransformMode] =
useState<TransformMode>("translate");
@@ -370,13 +371,31 @@ export function EditorPage(): React.JSX.Element {
const handleSelectNode = useCallback((index: number | null) => {
setSelectedNodeIndex(index);
setSelectedNodeIndexes(index === null ? [] : [index]);
if (index !== null) {
setCameraViewMode("object");
}
}, []);
const handleToggleNodeSelection = useCallback((index: number) => {
setSelectedNodeIndexes((currentIndexes) => {
const isSelected = currentIndexes.includes(index);
const nextIndexes = isSelected
? currentIndexes.filter((item) => item !== index)
: [...currentIndexes, index];
setSelectedNodeIndex(nextIndexes.at(-1) ?? null);
if (nextIndexes.length > 0) {
setCameraViewMode("object");
}
return nextIndexes;
});
}, []);
const handleClearSelection = useCallback(() => {
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
}, []);
const handleSelectionLockToggle = useCallback(() => {
@@ -401,7 +420,21 @@ export function EditorPage(): React.JSX.Element {
if (currentIndex === null) return null;
const selectedNode = sceneData?.mapNodes[currentIndex];
return selectedNode?.name === "terrain" ? null : currentIndex;
if (selectedNode?.name === "terrain") {
setSelectedNodeIndexes((indexes) =>
indexes.filter(
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
),
);
return null;
}
setSelectedNodeIndexes((indexes) =>
indexes.filter(
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
),
);
return currentIndex;
});
},
[sceneData],
@@ -544,12 +577,14 @@ export function EditorPage(): React.JSX.Element {
const newNode = createNewMapNode(newNodeName);
const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)];
setSelectedNodeIndex(mapNodes.length - 1);
setSelectedNodeIndexes([mapNodes.length - 1]);
return { ...prev, mapNodes };
}
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
const nextSceneData = updateSceneDataTree(prev, mapTree);
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
setSelectedNodeIndexes([nextSceneData.mapNodes.length - 1]);
return nextSceneData;
});
}, [newNodeName, setSceneData]);
@@ -563,6 +598,7 @@ export function EditorPage(): React.JSX.Element {
if (!currentNode) return prev;
if (!prev.mapTree || !currentNode.sourcePath) {
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
return {
...prev,
mapNodes: prev.mapNodes.filter(
@@ -576,6 +612,7 @@ export function EditorPage(): React.JSX.Element {
currentNode.sourcePath,
);
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
return updateSceneDataTree(prev, mapTree);
});
}, [selectedNodeIndex, setSceneData]);
@@ -660,7 +697,9 @@ export function EditorPage(): React.JSX.Element {
<EditorScene
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={handleSelectNode}
onToggleNodeSelection={handleToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
@@ -689,6 +728,7 @@ export function EditorPage(): React.JSX.Element {
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
mapNodes={sceneData.mapNodes}
nodesCount={sceneData.mapNodes.length}
selectedNodeName={