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