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} />
+72 -3
View File
@@ -1,13 +1,76 @@
import { useCallback, useRef, useState } from "react";
import type { MapNode, SceneData } from "@/types/editor/editor";
import type {
HierarchicalMapNode,
MapNode,
SceneData,
} from "@/types/editor/editor";
interface ObjectTransform {
uuid: string;
path: number[];
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
scale: { x: number; y: number; z: number };
}
function cloneMapTree(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): HierarchicalMapNode | HierarchicalMapNode[] {
return JSON.parse(JSON.stringify(mapTree)) as
| HierarchicalMapNode
| HierarchicalMapNode[];
}
function updateTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
transform: ObjectTransform,
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1] ?? 0;
const isRootTarget = Array.isArray(nextTree)
? path.length === 1
: path.length === 0;
const updateNode = (node: HierarchicalMapNode): HierarchicalMapNode => ({
...node,
position: [
transform.position.x,
transform.position.y,
transform.position.z,
],
rotation: [
transform.rotation.x,
transform.rotation.y,
transform.rotation.z,
],
scale: [transform.scale.x, transform.scale.y, transform.scale.z],
});
if (isRootTarget) {
rootNodes[targetIndex] = updateNode(
rootNodes[targetIndex] as HierarchicalMapNode,
);
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
if (parent?.children?.[targetIndex]) {
parent.children[targetIndex] = updateNode(parent.children[targetIndex]);
}
return nextTree;
}
class HistoryManager {
private history: ObjectTransform[][] = [];
private currentIndex = -1;
@@ -81,13 +144,14 @@ export function useEditorHistory(
setSceneData((prev) => {
if (!prev) return null;
let mapTree = prev.mapTree;
const mapNodes = prev.mapNodes.map((node, index) => {
const transform = snapshot.find(
(item) => item.uuid === `node-${index}`,
);
if (!transform) return node;
return {
const nextNode = {
...node,
position: [
transform.position.x,
@@ -101,9 +165,13 @@ export function useEditorHistory(
],
scale: [transform.scale.x, transform.scale.y, transform.scale.z],
} satisfies MapNode;
mapTree = updateTreeNodeAtPath(mapTree, node.path, transform);
return nextNode;
});
return { ...prev, mapNodes };
return { ...prev, mapNodes, mapTree };
});
},
[setSceneData],
@@ -149,6 +217,7 @@ export function useEditorHistory(
function createSnapshot(sceneData: SceneData): ObjectTransform[] {
return sceneData.mapNodes.map((node, index) => ({
uuid: `node-${index}`,
path: node.path,
position: {
x: node.position[0],
y: node.position[1],
+10
View File
@@ -57,6 +57,16 @@ export function useTerrainSnappedPosition(
}, [position, terrainHeight]);
}
export function getObjectBottomOffset(
object: THREE.Object3D,
scale: Vector3Tuple = [1, 1, 1],
): number {
const bounds = new THREE.Box3().setFromObject(object);
if (bounds.isEmpty()) return 0;
return -bounds.min.y * scale[1];
}
export function normalizeMapScale(scale: Vector3Tuple): Vector3Tuple {
const [x, y, z] = scale;
const isUniform = Math.abs(x - y) < 0.001 && Math.abs(x - z) < 0.001;
+46
View File
@@ -1390,6 +1390,52 @@ canvas {
color: #050505;
}
.editor-scale-fields {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 2px;
}
.editor-scale-fields label,
.editor-checkbox-row,
.editor-add-node-row {
display: flex;
align-items: center;
gap: 8px;
}
.editor-scale-fields label {
flex-direction: column;
align-items: stretch;
}
.editor-scale-fields input,
.editor-add-node-row input {
width: 100%;
min-width: 0;
color: #f4f4f4;
background: #101010;
border: 1px solid #2a2a2a;
border-radius: 10px;
padding: 8px 9px;
font: inherit;
}
.editor-checkbox-row {
color: #d5d5d5;
font-size: 0.82rem;
}
.editor-add-node-row {
align-items: stretch;
}
.editor-add-node-row .editor-action-button {
white-space: nowrap;
}
.editor-selected-actions {
display: inline-flex;
gap: 6px;
+280 -5
View File
@@ -9,7 +9,13 @@ import { Subtitles } from "@/components/ui/Subtitles";
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor";
import type {
EditableMapNode,
HierarchicalMapNode,
MapNode,
SceneData,
TransformMode,
} from "@/types/editor/editor";
import {
INITIAL_SCENE_LOADING_STATE,
type SceneLoadingChangeHandler,
@@ -18,13 +24,200 @@ import {
import { logger } from "@/utils/core/Logger";
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
const DEFAULT_NEW_NODE_NAME = "new-model";
interface EditorSceneLoadingTrackerProps {
onLoadingStateChange: SceneLoadingChangeHandler;
}
function serializeMapNodes(sceneData: SceneData): string {
return JSON.stringify(sceneData.mapNodes, null, 2);
return JSON.stringify(sceneData.mapTree, null, 2);
}
function cloneMapTree(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): HierarchicalMapNode | HierarchicalMapNode[] {
return JSON.parse(JSON.stringify(mapTree)) as
| HierarchicalMapNode
| HierarchicalMapNode[];
}
function toEditableMapNode(
node: HierarchicalMapNode,
path: number[],
): EditableMapNode | null {
if (node.name === "terrain" || node.role === "group") return null;
return {
name: node.name,
path,
position: node.position,
rotation: node.rotation,
scale: node.scale,
type: node.type,
};
}
function collectEditableMapNodes(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): EditableMapNode[] {
const nodes: EditableMapNode[] = [];
function visit(node: HierarchicalMapNode, path: number[]): void {
const editableNode = toEditableMapNode(node, path);
if (editableNode) {
nodes.push(editableNode);
return;
}
node.children?.forEach((child, index) => visit(child, [...path, index]));
}
if (Array.isArray(mapTree)) {
mapTree.forEach((node, index) => visit(node, [index]));
} else {
visit(mapTree, []);
}
return nodes;
}
function updateTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
update: (node: HierarchicalMapNode) => HierarchicalMapNode,
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1] ?? 0;
const isRootTarget = Array.isArray(nextTree)
? path.length === 1
: path.length === 0;
if (isRootTarget) {
rootNodes[targetIndex] = update(
rootNodes[targetIndex] as HierarchicalMapNode,
);
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
if (!parent?.children?.[targetIndex]) return nextTree;
parent.children[targetIndex] = update(parent.children[targetIndex]);
return nextTree;
}
function removeTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1];
if (targetIndex === undefined) return nextTree;
if (Array.isArray(nextTree) && path.length === 1) {
nextTree.splice(targetIndex, 1);
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
parent?.children?.splice(targetIndex, 1);
return nextTree;
}
function addTreeNode(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
node: HierarchicalMapNode,
): HierarchicalMapNode | HierarchicalMapNode[] {
const blockingPath = findNodePathByName(mapTree, "blocking");
if (!blockingPath) return mapTree;
return updateTreeNodeAtPath(mapTree, blockingPath, (blockingNode) => ({
...blockingNode,
children: [...(blockingNode.children ?? []), node],
}));
}
function updateSceneDataTree(
sceneData: SceneData,
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): SceneData {
return {
...sceneData,
mapNodes: collectEditableMapNodes(mapTree),
mapTree,
};
}
function findNodePathByName(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
name: string,
): number[] | null {
function visit(node: HierarchicalMapNode, path: number[]): number[] | null {
if (node.name === name) return path;
for (let index = 0; index < (node.children?.length ?? 0); index++) {
const child = node.children?.[index];
if (!child) continue;
const result = visit(child, [...path, index]);
if (result) return result;
}
return null;
}
if (Array.isArray(mapTree)) {
for (let index = 0; index < mapTree.length; index++) {
const node = mapTree[index];
if (!node) continue;
const result = visit(node, [index]);
if (result) return result;
}
return null;
}
return visit(mapTree, []);
}
function createNewMapNode(name: string): HierarchicalMapNode {
const safeName = name.trim() || DEFAULT_NEW_NODE_NAME;
return {
name: safeName,
type: "Object3D",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
children: [
{
name: safeName,
type: "Mesh",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
],
};
}
function EditorSceneLoadingTracker({
@@ -69,6 +262,8 @@ export function EditorPage(): React.JSX.Element {
useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false);
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
const [snapToTerrain, setSnapToTerrain] = useState(true);
const [newNodeName, setNewNodeName] = useState(DEFAULT_NEW_NODE_NAME);
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
{
...INITIAL_SCENE_LOADING_STATE,
@@ -122,6 +317,14 @@ export function EditorPage(): React.JSX.Element {
setIsSelectionLocked((locked) => !locked);
}, []);
const handleSnapToTerrainToggle = useCallback(() => {
setSnapToTerrain((enabled) => !enabled);
}, []);
const handleNewNodeNameChange = useCallback((value: string) => {
setNewNodeName(value);
}, []);
const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index);
}, []);
@@ -186,14 +389,73 @@ export function EditorPage(): React.JSX.Element {
(nodeIndex: number, updatedNode: MapNode) => {
setSceneData((prev) => {
if (!prev) return null;
const newMapNodes = [...prev.mapNodes];
newMapNodes[nodeIndex] = updatedNode;
return { ...prev, mapNodes: newMapNodes };
const currentNode = prev.mapNodes[nodeIndex];
if (!currentNode) return prev;
const mapTree = updateTreeNodeAtPath(
prev.mapTree,
currentNode.path,
(node) => ({
...node,
position: updatedNode.position,
rotation: updatedNode.rotation,
scale: updatedNode.scale,
}),
);
return updateSceneDataTree(prev, mapTree);
});
},
[setSceneData],
);
const handleSelectedScaleChange = useCallback(
(axis: 0 | 1 | 2, value: number) => {
if (selectedNodeIndex === null || Number.isNaN(value)) return;
setSceneData((prev) => {
if (!prev) return null;
const currentNode = prev.mapNodes[selectedNodeIndex];
if (!currentNode) return prev;
const nextScale = [...currentNode.scale] as [number, number, number];
nextScale[axis] = value;
const mapTree = updateTreeNodeAtPath(
prev.mapTree,
currentNode.path,
(node) => ({ ...node, scale: nextScale }),
);
return updateSceneDataTree(prev, mapTree);
});
},
[selectedNodeIndex, setSceneData],
);
const handleAddNode = useCallback(() => {
setSceneData((prev) => {
if (!prev) return null;
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
const nextSceneData = updateSceneDataTree(prev, mapTree);
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
return nextSceneData;
});
}, [newNodeName, setSceneData]);
const handleDeleteSelectedNode = useCallback(() => {
if (selectedNodeIndex === null) return;
setSceneData((prev) => {
if (!prev) return null;
const currentNode = prev.mapNodes[selectedNodeIndex];
if (!currentNode) return prev;
const mapTree = removeTreeNodeAtPath(prev.mapTree, currentNode.path);
setSelectedNodeIndex(null);
return updateSceneDataTree(prev, mapTree);
});
}, [selectedNodeIndex, setSceneData]);
if (isMapLoading) {
return (
<div className="editor-container">
@@ -279,6 +541,7 @@ export function EditorPage(): React.JSX.Element {
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
snapToTerrain={snapToTerrain}
onTransformModeChange={handleTransformModeChange}
onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd}
@@ -306,9 +569,21 @@ export function EditorPage(): React.JSX.Element {
? sceneData.mapNodes[selectedNodeIndex].name || null
: null
}
selectedNodeScale={
selectedNodeIndex !== null && sceneData.mapNodes[selectedNodeIndex]
? sceneData.mapNodes[selectedNodeIndex].scale
: null
}
isSelectionLocked={isSelectionLocked}
onSelectionLockToggle={handleSelectionLockToggle}
onClearSelection={handleClearSelection}
snapToTerrain={snapToTerrain}
onSnapToTerrainToggle={handleSnapToTerrainToggle}
newNodeName={newNodeName}
onNewNodeNameChange={handleNewNodeNameChange}
onAddNode={handleAddNode}
onDeleteSelectedNode={handleDeleteSelectedNode}
onSelectedScaleChange={handleSelectedScaleChange}
undoCount={undoCount}
redoCount={redoCount}
onUndo={handleUndo}
+6 -1
View File
@@ -8,13 +8,18 @@ export interface MapNode {
scale: Vector3Tuple;
}
export interface EditableMapNode extends MapNode {
path: number[];
}
export interface HierarchicalMapNode extends MapNode {
role?: "group";
children?: HierarchicalMapNode[];
}
export interface SceneData {
mapNodes: MapNode[];
mapNodes: EditableMapNode[];
mapTree: HierarchicalMapNode | HierarchicalMapNode[];
models: Map<string, string>;
}
+3 -3
View File
@@ -1,5 +1,5 @@
import type { SceneData } from "@/types/editor/editor";
import { parseMapNodes } from "@/utils/map/mapNodeValidation";
import { createSceneDataFromMapPayload } from "@/utils/map/loadMapSceneData";
const MAP_JSON_PATH = "/map.json";
@@ -18,7 +18,7 @@ export async function createSceneDataFromFiles(
}
const mapPayload: unknown = JSON.parse(await mapFile.text());
const mapNodes = parseMapNodes(mapPayload);
const sceneData = await createSceneDataFromMapPayload(mapPayload);
const models = new Map<string, string>();
for (const [path, file] of fileMap.entries()) {
@@ -31,7 +31,7 @@ export async function createSceneDataFromFiles(
}
}
return { mapNodes, models };
return { ...sceneData, models };
}
function getProjectRelativePath(file: File): string {
+92 -7
View File
@@ -1,5 +1,13 @@
import type { MapNode, SceneData } from "@/types/editor/editor";
import { parseMapNodes } from "@/utils/map/mapNodeValidation";
import type {
EditableMapNode,
HierarchicalMapNode,
MapNode,
SceneData,
} from "@/types/editor/editor";
import {
parseHierarchicalMapPayload,
parseMapNodes,
} from "@/utils/map/mapNodeValidation";
const MAP_JSON_PATH = "/map.json";
const MODEL_FILE_NAMES = ["model.glb", "model.gltf"];
@@ -45,9 +53,59 @@ async function loadMapSceneDataInternal(): Promise<SceneData | null> {
}
const mapPayload: unknown = await response.json();
const mapNodes = parseMapNodes(mapPayload);
return createSceneDataFromMapPayload(mapPayload);
}
export async function createSceneDataFromMapPayload(
mapPayload: unknown,
): Promise<SceneData> {
const mapTree = parseHierarchicalMapPayload(mapPayload);
const mapNodes = parseMapNodes(mapTree);
const editableNodes = createEditableMapNodes(mapTree);
const deduplicatedNodes = deduplicateMapNodes(mapNodes);
return createSceneData(deduplicatedNodes);
const deduplicatedEditableNodes = deduplicateEditableMapNodes(editableNodes);
return createSceneData(mapTree, deduplicatedEditableNodes, deduplicatedNodes);
}
function toMapNode(node: HierarchicalMapNode): MapNode {
return {
name: node.name,
position: node.position,
rotation: node.rotation,
scale: node.scale,
type: node.type,
};
}
function flattenEditableMapNode(
node: HierarchicalMapNode,
path: number[],
): EditableMapNode[] {
if (node.name === "terrain") {
return [];
}
if (node.role === "group") {
return (
node.children?.flatMap((child, index) =>
flattenEditableMapNode(child, [...path, index]),
) ?? []
);
}
return [{ ...toMapNode(node), path }];
}
function createEditableMapNodes(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): EditableMapNode[] {
if (Array.isArray(mapTree)) {
return mapTree.flatMap((node, index) =>
flattenEditableMapNode(node, [index]),
);
}
return flattenEditableMapNode(mapTree, []);
}
function createPositionKey(node: MapNode): string {
@@ -84,9 +142,36 @@ function deduplicateMapNodes(nodes: MapNode[]): MapNode[] {
return result;
}
async function createSceneData(mapNodes: MapNode[]): Promise<SceneData> {
const models = await loadMapModelUrls(mapNodes);
return { mapNodes, models };
function deduplicateEditableMapNodes(
nodes: EditableMapNode[],
): EditableMapNode[] {
const seen = new Set<string>();
const result: EditableMapNode[] = [];
const sortedNodes = [...nodes].sort((a, b) => {
if (a.type === "Object3D" && b.type !== "Object3D") return -1;
if (a.type !== "Object3D" && b.type === "Object3D") return 1;
return 0;
});
for (const node of sortedNodes) {
const key = createPositionKey(node);
if (!seen.has(key)) {
seen.add(key);
result.push(node);
}
}
return result;
}
async function createSceneData(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
mapNodes: EditableMapNode[],
modelLookupNodes: MapNode[],
): Promise<SceneData> {
const models = await loadMapModelUrls(modelLookupNodes);
return { mapNodes, mapTree, models };
}
async function loadMapModelUrls(
+19 -5
View File
@@ -26,7 +26,9 @@ function isMapNode(value: unknown): value is MapNode {
);
}
function isHierarchicalMapNode(value: unknown): value is HierarchicalMapNode {
export function isHierarchicalMapNode(
value: unknown,
): value is HierarchicalMapNode {
if (!isMapNode(value)) {
return false;
}
@@ -54,13 +56,25 @@ function flattenMapNode(node: HierarchicalMapNode): MapNode[] {
rotation: node.rotation,
scale: node.scale,
};
const childNodes = node.children?.flatMap(flattenMapNode) ?? [];
if (node.role === "group") {
return childNodes;
return node.children?.flatMap(flattenMapNode) ?? [];
}
return [mapNode, ...childNodes];
return [mapNode];
}
export function parseHierarchicalMapPayload(
value: unknown,
): HierarchicalMapNode | HierarchicalMapNode[] {
if (Array.isArray(value) && value.every(isHierarchicalMapNode)) {
return value;
}
if (isHierarchicalMapNode(value)) {
return value;
}
throw new Error("Invalid map node data");
}
export function parseMapNodes(value: unknown): MapNode[] {
+12 -4
View File
@@ -4,6 +4,7 @@ import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -11,8 +12,9 @@ import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import {
getObjectBottomOffset,
normalizeMapScale,
useTerrainSnappedPosition,
useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight";
import { TerrainModel } from "@/components/three/world/TerrainModel";
import {
@@ -356,15 +358,21 @@ function ModelInstance({
onLoaded: () => void;
}): React.JSX.Element {
const { position, rotation, scale } = node;
const snappedPosition = useTerrainSnappedPosition(position);
const normalizedScale = normalizeMapScale(scale);
const terrainHeight = useTerrainHeightSampler();
const { scene } = useLoggedGLTF(modelUrl, {
scope: "GameMap.ModelInstance",
position: snappedPosition,
position,
rotation,
scale: normalizedScale,
});
const sceneInstance = useClonedObject(scene);
const groundedPosition = useMemo(() => {
const [x, y, z] = position;
const height = terrainHeight.getHeight(x, z);
const bottomOffset = getObjectBottomOffset(sceneInstance, normalizedScale);
return [x, height !== null ? height + bottomOffset : y, z] as const;
}, [normalizedScale, position, sceneInstance, terrainHeight]);
useEffect(() => {
sceneInstance.traverse((child) => {
@@ -379,7 +387,7 @@ function ModelInstance({
return (
<primitive
object={sceneInstance}
position={snappedPosition}
position={groundedPosition}
rotation={rotation}
scale={normalizedScale}
/>
+18 -1
View File
@@ -130,6 +130,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
function setInstanceMatrices(
instancedMesh: THREE.InstancedMesh,
instances: MapAssetInstance[],
geometryBottomY: number,
): void {
const position = new THREE.Vector3();
const rotation = new THREE.Euler();
@@ -145,6 +146,7 @@ function setInstanceMatrices(
rotation.set(...instance.rotation);
quaternion.setFromEuler(rotation);
scale.set(...instance.scale);
position.y += -geometryBottomY * scale.y;
matrix.compose(position, quaternion, scale);
instancedMesh.setMatrixAt(i, matrix);
}
@@ -152,6 +154,20 @@ function setInstanceMatrices(
instancedMesh.instanceMatrix.needsUpdate = true;
}
function getMeshBottomY(meshDataList: MeshData[]): number {
let bottomY = Number.POSITIVE_INFINITY;
for (const meshData of meshDataList) {
meshData.geometry.computeBoundingBox();
const minY = meshData.geometry.boundingBox?.min.y;
if (minY !== undefined) {
bottomY = Math.min(bottomY, minY);
}
}
return Number.isFinite(bottomY) ? bottomY : 0;
}
export function InstancedMapAsset({
modelPath,
instances,
@@ -185,6 +201,7 @@ export function InstancedMapAsset({
optimizeGLTFSceneTextures(scene, maxAnisotropy);
const meshDataList = extractMeshes(scene);
const geometryBottomY = getMeshBottomY(meshDataList);
const instancedMeshes = meshDataList.map((meshData, index) => {
const instancedMesh = new THREE.InstancedMesh(
meshData.geometry,
@@ -192,7 +209,7 @@ export function InstancedMapAsset({
groundedInstances.length,
);
setInstanceMatrices(instancedMesh, groundedInstances);
setInstanceMatrices(instancedMesh, groundedInstances, geometryBottomY);
instancedMesh.castShadow = castShadow;
instancedMesh.receiveShadow = receiveShadow;
instancedMesh.name = `instanced-map-asset-${index}`;
+23 -2
View File
@@ -75,6 +75,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
function createInstanceMatrices(
instances: VegetationInstance[],
scaleMultiplier: number,
geometryBottomY: number,
): THREE.Matrix4[] {
const matrices: THREE.Matrix4[] = [];
const position = new THREE.Vector3();
@@ -90,6 +91,7 @@ function createInstanceMatrices(
const matrix = new THREE.Matrix4();
position.set(...instance.position);
position.y += -geometryBottomY * scaleMultiplier;
rotation.set(...instance.rotation);
quaternion.setFromEuler(rotation);
matrix.compose(position, quaternion, scale);
@@ -99,6 +101,20 @@ function createInstanceMatrices(
return matrices;
}
function getMeshBottomY(meshDataList: MeshData[]): number {
let bottomY = Number.POSITIVE_INFINITY;
for (const meshData of meshDataList) {
meshData.geometry.computeBoundingBox();
const minY = meshData.geometry.boundingBox?.min.y;
if (minY !== undefined) {
bottomY = Math.min(bottomY, minY);
}
}
return Number.isFinite(bottomY) ? bottomY : 0;
}
export function InstancedVegetation({
modelPath,
instances,
@@ -130,8 +146,13 @@ export function InstancedVegetation({
[instances, terrainHeight],
);
const matrices = useMemo(
() => createInstanceMatrices(groundedInstances, scaleMultiplier),
[groundedInstances, scaleMultiplier],
() =>
createInstanceMatrices(
groundedInstances,
scaleMultiplier,
getMeshBottomY(meshDataList),
),
[groundedInstances, meshDataList, scaleMultiplier],
);
const instancedMeshes = useMemo(() => {