feat(editor): edit hierarchical map nodes

This commit is contained in:
Tom Boullay
2026-05-27 08:30:54 +02:00
parent ab100c683f
commit c2b16434fb
16 changed files with 740 additions and 64 deletions
+94 -5
View File
@@ -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}`);
+18 -1
View File
@@ -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} />