feat(editor): add multi-selection transforms
This commit is contained in:
@@ -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.
|
||||
|
||||
+12
-8
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user