feat(editor): edit hierarchical map nodes
This commit is contained in:
@@ -8,9 +8,11 @@ import {
|
||||
Lock,
|
||||
MousePointer2,
|
||||
Move3D,
|
||||
Plus,
|
||||
Redo2,
|
||||
RotateCw,
|
||||
Save,
|
||||
Trash2,
|
||||
Undo2,
|
||||
Unlock,
|
||||
X,
|
||||
@@ -19,18 +21,27 @@ import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinemati
|
||||
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
||||
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||
import type { MapNode, TransformMode } from "@/types/editor/editor";
|
||||
import type { EditableMapNode, TransformMode } from "@/types/editor/editor";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface EditorControlsProps {
|
||||
transformMode: TransformMode;
|
||||
onTransformModeChange: (mode: TransformMode) => void;
|
||||
selectedNodeIndex: number | null;
|
||||
mapNodes: MapNode[];
|
||||
mapNodes: EditableMapNode[];
|
||||
nodesCount: number;
|
||||
selectedNodeName: string | null;
|
||||
selectedNodeScale: Vector3Tuple | null;
|
||||
isSelectionLocked: boolean;
|
||||
onSelectionLockToggle: () => void;
|
||||
onClearSelection: () => void;
|
||||
snapToTerrain: boolean;
|
||||
onSnapToTerrainToggle: () => void;
|
||||
newNodeName: string;
|
||||
onNewNodeNameChange: (value: string) => void;
|
||||
onAddNode: () => void;
|
||||
onDeleteSelectedNode: () => void;
|
||||
onSelectedScaleChange: (axis: 0 | 1 | 2, value: number) => void;
|
||||
undoCount: number;
|
||||
redoCount: number;
|
||||
onUndo: () => void;
|
||||
@@ -90,9 +101,17 @@ export function EditorControls({
|
||||
mapNodes,
|
||||
nodesCount,
|
||||
selectedNodeName,
|
||||
selectedNodeScale,
|
||||
isSelectionLocked,
|
||||
onSelectionLockToggle,
|
||||
onClearSelection,
|
||||
snapToTerrain,
|
||||
onSnapToTerrainToggle,
|
||||
newNodeName,
|
||||
onNewNodeNameChange,
|
||||
onAddNode,
|
||||
onDeleteSelectedNode,
|
||||
onSelectedScaleChange,
|
||||
undoCount,
|
||||
redoCount,
|
||||
onUndo,
|
||||
@@ -181,6 +200,15 @@ export function EditorControls({
|
||||
<span>{redoCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="editor-checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={snapToTerrain}
|
||||
onChange={onSnapToTerrainToggle}
|
||||
/>
|
||||
<span>Snap terrain on move</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -204,6 +232,14 @@ export function EditorControls({
|
||||
</span>
|
||||
</div>
|
||||
<div className="editor-selected-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteSelectedNode}
|
||||
aria-label="Delete selected node"
|
||||
title="Delete selected node"
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectionLockToggle}
|
||||
@@ -230,6 +266,26 @@ export function EditorControls({
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{selectedNodeScale ? (
|
||||
<div className="editor-scale-fields">
|
||||
{selectedNodeScale.map((value, axis) => (
|
||||
<label key={axis}>
|
||||
<span>{["X", "Y", "Z"][axis]}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={Number(value.toFixed(4))}
|
||||
onChange={(event) =>
|
||||
onSelectedScaleChange(
|
||||
axis as 0 | 1 | 2,
|
||||
Number(event.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="editor-no-selection">
|
||||
@@ -239,6 +295,32 @@ export function EditorControls({
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="add-node-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="add-node-heading">Add Node</h3>
|
||||
</div>
|
||||
|
||||
<div className="editor-add-node-row">
|
||||
<input
|
||||
type="text"
|
||||
value={newNodeName}
|
||||
onChange={(event) => onNewNodeNameChange(event.target.value)}
|
||||
placeholder="model-folder-name"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="editor-action-button"
|
||||
onClick={onAddNode}
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
Add cube
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="view-heading"
|
||||
@@ -341,7 +423,7 @@ interface JsonPreview {
|
||||
}
|
||||
|
||||
function getJsonPreview(
|
||||
mapNodes: MapNode[],
|
||||
mapNodes: EditableMapNode[],
|
||||
selectedNodeIndex: number | null,
|
||||
): JsonPreview {
|
||||
const { lines, ranges } = formatMapNodesWithRanges(mapNodes);
|
||||
@@ -370,7 +452,7 @@ function getJsonPreview(
|
||||
};
|
||||
}
|
||||
|
||||
function formatMapNodesWithRanges(mapNodes: MapNode[]): {
|
||||
function formatMapNodesWithRanges(mapNodes: EditableMapNode[]): {
|
||||
lines: string[];
|
||||
ranges: Array<{ start: number; end: number }>;
|
||||
} {
|
||||
@@ -378,7 +460,14 @@ function formatMapNodesWithRanges(mapNodes: MapNode[]): {
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
|
||||
mapNodes.forEach((node, index) => {
|
||||
const objectLines = JSON.stringify(node, null, 2)
|
||||
const serializableNode = {
|
||||
name: node.name,
|
||||
position: node.position,
|
||||
rotation: node.rotation,
|
||||
scale: node.scale,
|
||||
type: node.type,
|
||||
};
|
||||
const objectLines = JSON.stringify(serializableNode, null, 2)
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as THREE from "three";
|
||||
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
||||
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
||||
|
||||
interface EditorMapProps {
|
||||
@@ -15,6 +16,7 @@ interface EditorMapProps {
|
||||
hoveredNodeIndex: number | null;
|
||||
onHoverNode: (index: number | null) => void;
|
||||
transformMode: TransformMode;
|
||||
snapToTerrain: boolean;
|
||||
onTransformStart: () => void;
|
||||
onTransformEnd: () => void;
|
||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||
@@ -138,11 +140,13 @@ export function EditorMap({
|
||||
hoveredNodeIndex,
|
||||
onHoverNode,
|
||||
transformMode,
|
||||
snapToTerrain,
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
onNodeTransform,
|
||||
}: EditorMapProps): React.JSX.Element {
|
||||
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
|
||||
const handleTransformMouseDown = () => {
|
||||
onTransformStart();
|
||||
@@ -154,9 +158,22 @@ export function EditorMap({
|
||||
if (!obj) return;
|
||||
const node = sceneData.mapNodes[selectedNodeIndex];
|
||||
if (node) {
|
||||
const terrainY = snapToTerrain
|
||||
? terrainHeight.getHeight(obj.position.x, obj.position.z)
|
||||
: null;
|
||||
if (terrainY !== null && transformMode === "translate") {
|
||||
obj.position.y = terrainY;
|
||||
}
|
||||
|
||||
const updatedNode: MapNode = {
|
||||
...node,
|
||||
position: [obj.position.x, obj.position.y, obj.position.z],
|
||||
position: [
|
||||
obj.position.x,
|
||||
terrainY !== null && transformMode === "translate"
|
||||
? terrainY
|
||||
: 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],
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useThree } from "@react-three/fiber";
|
||||
import gsap from "gsap";
|
||||
import * as THREE from "three";
|
||||
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,6 +22,7 @@ interface EditorSceneProps {
|
||||
hoveredNodeIndex: number | null;
|
||||
onHoverNode: (index: number | null) => void;
|
||||
transformMode: TransformMode;
|
||||
snapToTerrain: boolean;
|
||||
onTransformModeChange: (mode: TransformMode) => void;
|
||||
onTransformStart: () => void;
|
||||
onTransformEnd: () => void;
|
||||
@@ -40,6 +42,7 @@ export function EditorScene({
|
||||
hoveredNodeIndex,
|
||||
onHoverNode,
|
||||
transformMode,
|
||||
snapToTerrain,
|
||||
onTransformModeChange,
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
@@ -126,11 +129,14 @@ export function EditorScene({
|
||||
hoveredNodeIndex={hoveredNodeIndex}
|
||||
onHoverNode={onHoverNode}
|
||||
transformMode={transformMode}
|
||||
snapToTerrain={snapToTerrain}
|
||||
onTransformStart={onTransformStart}
|
||||
onTransformEnd={onTransformEnd}
|
||||
onNodeTransform={onNodeTransform}
|
||||
/>
|
||||
|
||||
<TerrainModel />
|
||||
|
||||
<ambientLight intensity={0.6} />
|
||||
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
|
||||
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
|
||||
|
||||
Reference in New Issue
Block a user