feat(editor): edit hierarchical map nodes
This commit is contained in:
+11
-18
@@ -4,7 +4,7 @@ This document describes the map editor that exists in the current codebase.
|
||||
|
||||
## Purpose
|
||||
|
||||
The editor is a React route used to inspect and adjust the `public/map.json` scene data from inside the La-Fabrik app. It shares the same `MapNode` data format as the game scene and uses React Three Fiber for rendering.
|
||||
The editor is a React route used to inspect and adjust the current hierarchical `public/map.json` scene data from inside the La-Fabrik app. It exposes editable object nodes as a flat list for UI selection, while preserving and saving the full map tree.
|
||||
|
||||
## Routing
|
||||
|
||||
@@ -72,7 +72,7 @@ src/
|
||||
|
||||
`src/controls/editor/FlyController.tsx` provides editor movement controls for player-style navigation.
|
||||
|
||||
`src/utils/map/loadMapSceneData.ts` is shared by the game map and editor. It loads `/map.json` and resolves available `public/models/{name}/model.glb` files first, then falls back to `public/models/{name}/model.gltf`.
|
||||
`src/utils/map/loadMapSceneData.ts` is shared by the game map and editor. It loads `/map.json`, validates the hierarchical payload, exposes editable nodes with their tree path, and resolves available `public/models/{name}/model.glb` files first, then falls back to `public/models/{name}/model.gltf`.
|
||||
|
||||
`src/utils/editor/loadEditorScene.ts` contains editor-only upload handling for user-selected folders.
|
||||
|
||||
@@ -90,19 +90,9 @@ interface MapNode {
|
||||
}
|
||||
```
|
||||
|
||||
`public/map.json` is expected to be a `MapNode[]`.
|
||||
`public/map.json` may be hierarchical. The editor keeps the hierarchy in `SceneData.mapTree` and stores editable entries in `SceneData.mapNodes` with a `path` back to the real tree node.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "pylone",
|
||||
"type": "Mesh",
|
||||
"position": [0, 5, 0],
|
||||
"rotation": [0, 1.57, 0],
|
||||
"scale": [1, 1, 1]
|
||||
}
|
||||
]
|
||||
```
|
||||
Group nodes use `role: "group"`; editable nodes keep `name`, `type`, `position`, `rotation`, and `scale`.
|
||||
|
||||
Each node `name` maps to a model folder:
|
||||
|
||||
@@ -124,7 +114,7 @@ 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, history actions, export, save, JSON preview, selection lock, and the cinematic/dialogue/SRT editors.
|
||||
7. `EditorControls` exposes transform mode, terrain snap, add/delete node, precise scale inputs, history actions, export, save, JSON preview, selection lock, and the cinematic/dialogue/SRT editors.
|
||||
|
||||
## Controls
|
||||
|
||||
@@ -136,6 +126,9 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
|
||||
- `T`: translate mode.
|
||||
- `R`: rotate mode.
|
||||
- `S`: scale mode.
|
||||
- Snap terrain on move: enabled by default and applied when releasing a translated object.
|
||||
- Add node: creates a fallback cube under `blocking` using the requested model folder name.
|
||||
- Delete selected node: removes the editable node from the preserved map tree.
|
||||
- `Ctrl+Z` or `Cmd+Z`: undo.
|
||||
- `Ctrl+Y` or `Cmd+Y`: redo.
|
||||
- `WASD`, `ZQSD`, or arrow keys: move in player-controller mode.
|
||||
@@ -146,10 +139,10 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
|
||||
|
||||
The editor supports two output paths:
|
||||
|
||||
- Export JSON downloads the current `MapNode[]` as `map.json`.
|
||||
- Save to Server posts the current `MapNode[]` to `/api/save-map`.
|
||||
- Export JSON downloads the current hierarchical map tree as `map.json`.
|
||||
- Save to Server posts the current hierarchical map tree to `/api/save-map`.
|
||||
|
||||
The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite.config.ts`. It writes to `public/map.json` and enforces a maximum payload size.
|
||||
The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite.config.ts`. It validates the payload through the shared map parser, writes to `public/map.json`, and enforces a maximum payload size.
|
||||
|
||||
## Editor Loading Overlay
|
||||
|
||||
|
||||
+30
-9
@@ -6,7 +6,7 @@ The map editor is available at `/editor`. It is a browser-based tool for editing
|
||||
|
||||
Use the editor when you need to:
|
||||
|
||||
- move, rotate, or scale objects from `public/map.json`
|
||||
- move, rotate, scale, add, or delete objects from `public/map.json`
|
||||
- inspect the raw JSON generated by the editor
|
||||
- preview and edit cinematics from `public/cinematics.json`
|
||||
- create, preview, and validate dialogue entries from `public/sounds/dialogue/dialogues.json`
|
||||
@@ -14,13 +14,13 @@ Use the editor when you need to:
|
||||
|
||||
The map editor reads the same map data as the runtime scene:
|
||||
|
||||
- `public/map.json` contains the object list.
|
||||
- `public/map.json` contains the current hierarchical runtime map.
|
||||
- `public/models/{name}/model.glb` contains the matching 3D model for each object name. `model.gltf` is still supported as a fallback during migration.
|
||||
- Missing models are displayed as gray fallback cubes, so incomplete maps remain editable.
|
||||
|
||||
## Map Node Format
|
||||
|
||||
Each entry in `public/map.json` represents one object:
|
||||
`public/map.json` is hierarchical. Group nodes such as `Scene`, `blocking`, `vegetation`, or `agriculture` organize the map. Editable object nodes still use the same transform fields:
|
||||
|
||||
| Field | Description |
|
||||
| ---------- | ------------------------------------------------- |
|
||||
@@ -47,9 +47,22 @@ Only the `Editor` group is open by default. Open the other groups when you need
|
||||
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. Check the JSON inspector if you need exact values.
|
||||
6. Use undo or redo if the transform is not correct.
|
||||
7. Export the JSON or save it to the dev server.
|
||||
5. Keep `Snap terrain on move` enabled when placing objects on the terrain.
|
||||
6. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
|
||||
7. Check the JSON inspector if you need exact values.
|
||||
8. Use undo or redo if the transform is not correct.
|
||||
9. Export the JSON or save it to the dev server.
|
||||
|
||||
## Adding And Deleting Nodes
|
||||
|
||||
Use `Add Node` to create a new editable object under the `blocking` group.
|
||||
|
||||
- The new object starts as a fallback cube at `[0, 0, 0]`.
|
||||
- Name it with the model folder name you want, for example `maison1`.
|
||||
- If `public/models/{name}/model.glb` or `model.gltf` exists, saving and reloading will display the matching model.
|
||||
- If no matching model exists, the node stays editable as a gray cube.
|
||||
|
||||
Use the trash button in `Selection` to delete the selected node from the map tree.
|
||||
|
||||
## Controls
|
||||
|
||||
@@ -76,6 +89,14 @@ The `Selection` section shows the selected object name and its index in `public/
|
||||
- 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.
|
||||
- Use the scale fields to edit X/Y/Z scale precisely.
|
||||
- Use the trash button to remove the selected object.
|
||||
|
||||
## Terrain Snapping
|
||||
|
||||
`Snap terrain on move` is enabled by default. When you move an object and release the transform gizmo, the editor samples the terrain height at the object's X/Z position and updates its Y position.
|
||||
|
||||
This is intended for map objects that should sit on the ground. Disable it when you intentionally need a floating object.
|
||||
|
||||
When selection is locked:
|
||||
|
||||
@@ -90,7 +111,7 @@ The `Lock view` action switches the editor into a movement mode closer to the ru
|
||||
|
||||
## JSON Inspector
|
||||
|
||||
The `JSON` section shows the raw map data that will be exported or saved:
|
||||
The `JSON` section shows the editable node data:
|
||||
|
||||
- When no object is selected, it shows the full map node list.
|
||||
- When an object is selected, it highlights the JSON lines for that object.
|
||||
@@ -101,11 +122,11 @@ Use it to verify exact numeric transform values before saving or exporting. The
|
||||
|
||||
### Export JSON
|
||||
|
||||
`Export JSON` downloads the current map node list as `map.json`. Use this when you want to manually replace `public/map.json`.
|
||||
`Export JSON` downloads the current hierarchical map tree as `map.json`. Use this when you want to manually replace `public/map.json`.
|
||||
|
||||
### Save To Server
|
||||
|
||||
`Save to server` is available only during local development. It writes the edited map back to `public/map.json` through the Vite dev-server endpoint.
|
||||
`Save to server` is available only during local development. It writes the edited hierarchical map back to `public/map.json` through the Vite dev-server endpoint.
|
||||
|
||||
The button is hidden in production builds because production persistence is not implemented.
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user