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
+5 -3
View File
@@ -52,7 +52,7 @@ src/
## Responsibilities ## 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. `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/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`. `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. 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. 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`. 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 ## Controls
- Click: select a node. - Click: select a node.
- `Shift` + right click: add or remove a node from the multi-selection.
- `Esc`: clear selection. - `Esc`: clear selection.
- Click empty space: clear selection. - Click empty space: clear selection.
- Selection lock button: prevent object clicks, empty-space clicks, and `Esc` from changing the current 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. - `R`: rotate mode.
- `S`: scale mode. - `S`: scale mode.
- Snap terrain on move: enabled by default and applied while translating an object. - 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. - 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. - 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. - Add node: creates a fallback cube under `blocking` using the requested model folder name.
+12 -8
View File
@@ -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. 1. Open `/editor` in the local app.
2. Click an object in the scene to select it. 2. Click an object in the scene to select it.
3. Choose a transform mode: translate, rotate, or scale. 3. Use `Shift + right click` on other objects to add or remove them from the current multi-selection.
4. Drag the transform gizmo in the 3D view. 4. Choose a transform mode: translate, rotate, or scale.
5. Keep `Snap terrain on move` enabled when placing objects on the terrain. 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. Use `Center on object` or `Reset camera` from the `View` section when navigating large maps. 6. Keep `Snap terrain on move` enabled when placing objects on the terrain.
7. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough. 7. Use `Center on object` or `Reset camera` from the `View` section when navigating large maps.
8. Check the JSON inspector if you need exact values. 8. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
9. Use undo or redo if the transform is not correct. 9. Check the JSON inspector if you need exact values.
10. Export the JSON or save it to the dev server. 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 ## 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 | | Action | Input |
| -------------------- | -------------------------- | | -------------------- | -------------------------- |
| Select object | Click object | | Select object | Click object |
| Toggle multi-select | `Shift` + right click |
| Deselect | `Esc` or click empty space | | Deselect | `Esc` or click empty space |
| Lock selection | `Lock` button in Selection | | Lock selection | `Lock` button in Selection |
| Clear selection | `X` 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`. The `Selection` section shows the selected object name and its index in `public/map.json`.
- Click an object to select it. - 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. - Click empty space or press `Esc` to clear the selection.
- Use the `X` button to clear the selection explicitly. - Use the `X` button to clear the selection explicitly.
- Use the `Lock` button to protect the current selection while editing. - Use the `Lock` button to protect the current selection while editing.
+10 -2
View File
@@ -29,6 +29,7 @@ interface EditorControlsProps {
transformMode: TransformMode; transformMode: TransformMode;
onTransformModeChange: (mode: TransformMode) => void; onTransformModeChange: (mode: TransformMode) => void;
selectedNodeIndex: number | null; selectedNodeIndex: number | null;
selectedNodeIndexes: number[];
mapNodes: MapNode[]; mapNodes: MapNode[];
nodesCount: number; nodesCount: number;
selectedNodeName: string | null; selectedNodeName: string | null;
@@ -66,6 +67,7 @@ const TRANSFORM_OPTIONS = [
const EDITOR_SHORTCUTS = [ const EDITOR_SHORTCUTS = [
["Click", "Select object"], ["Click", "Select object"],
["Shift + Right click", "Toggle multi-selection"],
["T / R / S", "Transform mode"], ["T / R / S", "Transform mode"],
["Ctrl Z / Y", "Undo / redo"], ["Ctrl Z / Y", "Undo / redo"],
["Esc", "Deselect"], ["Esc", "Deselect"],
@@ -103,6 +105,7 @@ export function EditorControls({
transformMode, transformMode,
onTransformModeChange, onTransformModeChange,
selectedNodeIndex, selectedNodeIndex,
selectedNodeIndexes,
mapNodes, mapNodes,
nodesCount, nodesCount,
selectedNodeName, selectedNodeName,
@@ -135,6 +138,7 @@ export function EditorControls({
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex); const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
const selectedNode = const selectedNode =
selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null; selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null;
const selectionCount = selectedNodeIndexes.length;
const transformValues = getTransformValues(selectedNode ?? null); const transformValues = getTransformValues(selectedNode ?? null);
return ( return (
@@ -240,10 +244,14 @@ export function EditorControls({
<Box size={17} aria-hidden="true" /> <Box size={17} aria-hidden="true" />
<div> <div>
<strong> <strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`} {selectionCount > 1
? `${selectionCount} selected nodes`
: selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong> </strong>
<span> <span>
Index {selectedNodeIndex + 1} of {nodesCount} {selectionCount > 1
? `Primary index ${selectedNodeIndex + 1} of ${nodesCount}`
: `Index ${selectedNodeIndex + 1} of ${nodesCount}`}
</span> </span>
</div> </div>
<div className="editor-selected-actions"> <div className="editor-selected-actions">
+158 -28
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 { Grid, TransformControls } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber"; import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three"; import * as THREE from "three";
@@ -16,7 +16,9 @@ import {
interface EditorMapProps { interface EditorMapProps {
sceneData: SceneData; sceneData: SceneData;
selectedNodeIndex: number | null; selectedNodeIndex: number | null;
selectedNodeIndexes: number[];
onSelectNode: (index: number | null) => void; onSelectNode: (index: number | null) => void;
onToggleNodeSelection: (index: number) => void;
isSelectionLocked: boolean; isSelectionLocked: boolean;
hoveredNodeIndex: number | null; hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
@@ -37,16 +39,31 @@ interface EditorNodeCommonProps {
isHovered: boolean; isHovered: boolean;
objectsMapRef: EditorNodeObjectRef; objectsMapRef: EditorNodeObjectRef;
onSelectNode: (index: number | null) => void; onSelectNode: (index: number | null) => void;
onToggleNodeSelection: (index: number) => void;
isSelectionLocked: boolean; isSelectionLocked: boolean;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
} }
interface EditorNodePointerHandlers { interface EditorNodePointerHandlers {
onClick: (event: ThreeEvent<MouseEvent>) => void; onClick: (event: ThreeEvent<MouseEvent>) => void;
onContextMenu: (event: ThreeEvent<MouseEvent>) => void;
onPointerEnter: (event: ThreeEvent<PointerEvent>) => void; onPointerEnter: (event: ThreeEvent<PointerEvent>) => void;
onPointerLeave: (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 { function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
object.position.set(...node.position); object.position.set(...node.position);
object.rotation.set(...node.rotation); object.rotation.set(...node.rotation);
@@ -118,6 +135,7 @@ function getNodeHighlightColor(
function createEditorNodePointerHandlers( function createEditorNodePointerHandlers(
index: number, index: number,
onSelectNode: (index: number | null) => void, onSelectNode: (index: number | null) => void,
onToggleNodeSelection: (index: number) => void,
isSelectionLocked: boolean, isSelectionLocked: boolean,
onHoverNode: (index: number | null) => void, onHoverNode: (index: number | null) => void,
): EditorNodePointerHandlers { ): EditorNodePointerHandlers {
@@ -127,6 +145,12 @@ function createEditorNodePointerHandlers(
if (isSelectionLocked) return; if (isSelectionLocked) return;
onSelectNode(index); onSelectNode(index);
}, },
onContextMenu: (event) => {
event.stopPropagation();
event.nativeEvent.preventDefault();
if (!event.nativeEvent.shiftKey || isSelectionLocked) return;
onToggleNodeSelection(index);
},
onPointerEnter: (event) => { onPointerEnter: (event) => {
event.stopPropagation(); event.stopPropagation();
onHoverNode(index); onHoverNode(index);
@@ -141,7 +165,9 @@ function createEditorNodePointerHandlers(
export function EditorMap({ export function EditorMap({
sceneData, sceneData,
selectedNodeIndex, selectedNodeIndex,
selectedNodeIndexes,
onSelectNode, onSelectNode,
onToggleNodeSelection,
isSelectionLocked, isSelectionLocked,
hoveredNodeIndex, hoveredNodeIndex,
onHoverNode, onHoverNode,
@@ -153,18 +179,110 @@ export function EditorMap({
onNodeTransform, onNodeTransform,
}: EditorMapProps): React.JSX.Element { }: EditorMapProps): React.JSX.Element {
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map()); 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 terrainHeight = useTerrainHeightSampler();
const handleTransformMouseDown = () => { const selectedIndexSet = new Set(selectedNodeIndexes);
onTransformStart(); const isMultiSelection = selectedNodeIndexes.length > 1;
};
const handleTransformMouseUp = () => { const getTransformObject = useCallback(() => {
syncSelectedObjectTransform(); if (isMultiSelection) {
onTransformEnd(); 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 = () => { 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) { if (selectedNodeIndex !== null) {
const obj = objectsMapRef.current.get(selectedNodeIndex); const obj = objectsMapRef.current.get(selectedNodeIndex);
if (!obj) return; if (!obj) return;
@@ -194,25 +312,30 @@ export function EditorMap({
} }
}; };
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>( const handleTransformMouseDown = () => {
null, prepareTransformGroup();
); transformSnapshotRef.current = createTransformSnapshot();
onTransformStart();
};
const handleTransformMouseUp = () => {
syncSelectedObjectTransform();
transformSnapshotRef.current = null;
prepareTransformGroup();
onTransformEnd();
};
const terrainNode = getTerrainMapNode(sceneData.mapNodes); const terrainNode = getTerrainMapNode(sceneData.mapNodes);
const terrainNodeIndex = terrainNode const terrainNodeIndex = terrainNode
? sceneData.mapNodes.indexOf(terrainNode) ? sceneData.mapNodes.indexOf(terrainNode)
: -1; : -1;
const selectedNode = useLayoutEffect(() => {
selectedNodeIndex !== null ? sceneData.mapNodes[selectedNodeIndex] : null; prepareTransformGroup();
const selectedModelName = selectedNode?.name ?? null; }, [prepareTransformGroup]);
useEffect(() => { // TransformControls needs the current Three object; editor refs are managed outside React rendering.
if (selectedNodeIndex !== null) { // eslint-disable-next-line react-hooks/refs
const obj = objectsMapRef.current.get(selectedNodeIndex); const selectedObject = getTransformObject();
setSelectedObject(obj || null);
} else {
setSelectedObject(null);
}
}, [selectedNodeIndex]);
return ( return (
<> <>
@@ -236,11 +359,12 @@ export function EditorMap({
<EditorTerrainNode <EditorTerrainNode
index={terrainNodeIndex} index={terrainNodeIndex}
node={terrainNode} node={terrainNode}
isSelected={selectedNodeIndex === terrainNodeIndex} isSelected={selectedIndexSet.has(terrainNodeIndex)}
isHovered={hoveredNodeIndex === terrainNodeIndex} isHovered={hoveredNodeIndex === terrainNodeIndex}
lockTerrainSelection={lockTerrainSelection} lockTerrainSelection={lockTerrainSelection}
objectsMapRef={objectsMapRef} objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked} isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
/> />
@@ -250,10 +374,6 @@ export function EditorMap({
return null; return null;
} }
if (selectedModelName && node.name !== selectedModelName) {
return null;
}
const modelUrl = sceneData.models.get(node.name); const modelUrl = sceneData.models.get(node.name);
if (modelUrl) { if (modelUrl) {
@@ -263,10 +383,11 @@ export function EditorMap({
index={index} index={index}
node={node} node={node}
modelUrl={modelUrl} modelUrl={modelUrl}
isSelected={selectedNodeIndex === index} isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index} isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef} objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked} isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
/> />
@@ -277,10 +398,11 @@ export function EditorMap({
key={index} key={index}
index={index} index={index}
node={node} node={node}
isSelected={selectedNodeIndex === index} isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index} isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef} objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked} isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
/> />
@@ -289,6 +411,8 @@ export function EditorMap({
})} })}
</group> </group>
<group ref={transformGroupRef} />
{selectedObject && ( {selectedObject && (
<TransformControls <TransformControls
object={selectedObject} object={selectedObject}
@@ -310,6 +434,7 @@ function EditorModelNode({
isHovered, isHovered,
objectsMapRef, objectsMapRef,
onSelectNode, onSelectNode,
onToggleNodeSelection,
isSelectionLocked, isSelectionLocked,
onHoverNode, onHoverNode,
}: EditorNodeCommonProps & { }: EditorNodeCommonProps & {
@@ -329,6 +454,7 @@ function EditorModelNode({
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
index, index,
onSelectNode, onSelectNode,
onToggleNodeSelection,
isSelectionLocked, isSelectionLocked,
onHoverNode, onHoverNode,
); );
@@ -403,6 +529,7 @@ function EditorTerrainNode({
lockTerrainSelection, lockTerrainSelection,
objectsMapRef, objectsMapRef,
onSelectNode, onSelectNode,
onToggleNodeSelection,
isSelectionLocked, isSelectionLocked,
onHoverNode, onHoverNode,
}: EditorNodeCommonProps & { lockTerrainSelection: boolean }) { }: EditorNodeCommonProps & { lockTerrainSelection: boolean }) {
@@ -410,6 +537,7 @@ function EditorTerrainNode({
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
index, index,
onSelectNode, onSelectNode,
onToggleNodeSelection,
isSelectionLocked, isSelectionLocked,
onHoverNode, onHoverNode,
); );
@@ -435,6 +563,7 @@ function EditorFallbackNode({
isHovered, isHovered,
objectsMapRef, objectsMapRef,
onSelectNode, onSelectNode,
onToggleNodeSelection,
isSelectionLocked, isSelectionLocked,
onHoverNode, onHoverNode,
}: EditorNodeCommonProps) { }: EditorNodeCommonProps) {
@@ -442,6 +571,7 @@ function EditorFallbackNode({
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
index, index,
onSelectNode, onSelectNode,
onToggleNodeSelection,
isSelectionLocked, isSelectionLocked,
onHoverNode, onHoverNode,
); );
+6 -3
View File
@@ -5,7 +5,6 @@ import gsap from "gsap";
import * as THREE from "three"; import * as THREE from "three";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
import { EditorMap } from "@/components/editor/scene/EditorMap"; import { EditorMap } from "@/components/editor/scene/EditorMap";
import { TerrainModel } from "@/components/three/world/TerrainModel";
import { FlyController } from "@/controls/editor/FlyController"; import { FlyController } from "@/controls/editor/FlyController";
import type { CinematicDefinition } from "@/types/cinematics/cinematics"; import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor"; import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
@@ -21,7 +20,9 @@ export interface EditorCinematicPreviewRequest {
interface EditorSceneProps { interface EditorSceneProps {
sceneData: SceneData; sceneData: SceneData;
selectedNodeIndex: number | null; selectedNodeIndex: number | null;
selectedNodeIndexes: number[];
onSelectNode: (index: number | null) => void; onSelectNode: (index: number | null) => void;
onToggleNodeSelection: (index: number) => void;
isSelectionLocked: boolean; isSelectionLocked: boolean;
hoveredNodeIndex: number | null; hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
@@ -44,7 +45,9 @@ interface EditorSceneProps {
export function EditorScene({ export function EditorScene({
sceneData, sceneData,
selectedNodeIndex, selectedNodeIndex,
selectedNodeIndexes,
onSelectNode, onSelectNode,
onToggleNodeSelection,
isSelectionLocked, isSelectionLocked,
hoveredNodeIndex, hoveredNodeIndex,
onHoverNode, onHoverNode,
@@ -209,7 +212,9 @@ export function EditorScene({
<EditorMap <EditorMap
sceneData={sceneData} sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex} selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked} isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex} hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
@@ -221,8 +226,6 @@ export function EditorScene({
onNodeTransform={onNodeTransform} onNodeTransform={onNodeTransform}
/> />
<TerrainModel />
<ambientLight intensity={0.6} /> <ambientLight intensity={0.6} />
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow /> <directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
<directionalLight position={[-10, 10, -10]} intensity={0.5} /> <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>( const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
null, null,
); );
const [selectedNodeIndexes, setSelectedNodeIndexes] = useState<number[]>([]);
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null); const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
const [transformMode, setTransformMode] = const [transformMode, setTransformMode] =
useState<TransformMode>("translate"); useState<TransformMode>("translate");
@@ -370,13 +371,31 @@ export function EditorPage(): React.JSX.Element {
const handleSelectNode = useCallback((index: number | null) => { const handleSelectNode = useCallback((index: number | null) => {
setSelectedNodeIndex(index); setSelectedNodeIndex(index);
setSelectedNodeIndexes(index === null ? [] : [index]);
if (index !== null) { if (index !== null) {
setCameraViewMode("object"); 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(() => { const handleClearSelection = useCallback(() => {
setSelectedNodeIndex(null); setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
}, []); }, []);
const handleSelectionLockToggle = useCallback(() => { const handleSelectionLockToggle = useCallback(() => {
@@ -401,7 +420,21 @@ export function EditorPage(): React.JSX.Element {
if (currentIndex === null) return null; if (currentIndex === null) return null;
const selectedNode = sceneData?.mapNodes[currentIndex]; 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], [sceneData],
@@ -544,12 +577,14 @@ export function EditorPage(): React.JSX.Element {
const newNode = createNewMapNode(newNodeName); const newNode = createNewMapNode(newNodeName);
const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)]; const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)];
setSelectedNodeIndex(mapNodes.length - 1); setSelectedNodeIndex(mapNodes.length - 1);
setSelectedNodeIndexes([mapNodes.length - 1]);
return { ...prev, mapNodes }; return { ...prev, mapNodes };
} }
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName)); const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
const nextSceneData = updateSceneDataTree(prev, mapTree); const nextSceneData = updateSceneDataTree(prev, mapTree);
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1); setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
setSelectedNodeIndexes([nextSceneData.mapNodes.length - 1]);
return nextSceneData; return nextSceneData;
}); });
}, [newNodeName, setSceneData]); }, [newNodeName, setSceneData]);
@@ -563,6 +598,7 @@ export function EditorPage(): React.JSX.Element {
if (!currentNode) return prev; if (!currentNode) return prev;
if (!prev.mapTree || !currentNode.sourcePath) { if (!prev.mapTree || !currentNode.sourcePath) {
setSelectedNodeIndex(null); setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
return { return {
...prev, ...prev,
mapNodes: prev.mapNodes.filter( mapNodes: prev.mapNodes.filter(
@@ -576,6 +612,7 @@ export function EditorPage(): React.JSX.Element {
currentNode.sourcePath, currentNode.sourcePath,
); );
setSelectedNodeIndex(null); setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
return updateSceneDataTree(prev, mapTree); return updateSceneDataTree(prev, mapTree);
}); });
}, [selectedNodeIndex, setSceneData]); }, [selectedNodeIndex, setSceneData]);
@@ -660,7 +697,9 @@ export function EditorPage(): React.JSX.Element {
<EditorScene <EditorScene
sceneData={sceneData!} sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex} selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={handleSelectNode} onSelectNode={handleSelectNode}
onToggleNodeSelection={handleToggleNodeSelection}
isSelectionLocked={isSelectionLocked} isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex} hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode} onHoverNode={handleHoverNode}
@@ -689,6 +728,7 @@ export function EditorPage(): React.JSX.Element {
transformMode={transformMode} transformMode={transformMode}
onTransformModeChange={handleTransformModeChange} onTransformModeChange={handleTransformModeChange}
selectedNodeIndex={selectedNodeIndex} selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
mapNodes={sceneData.mapNodes} mapNodes={sceneData.mapNodes}
nodesCount={sceneData.mapNodes.length} nodesCount={sceneData.mapNodes.length}
selectedNodeName={ selectedNodeName={