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
|
## 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
|
## Routing
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ src/
|
|||||||
|
|
||||||
`src/controls/editor/FlyController.tsx` provides editor movement controls for player-style navigation.
|
`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.
|
`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
|
Group nodes use `role: "group"`; editable nodes keep `name`, `type`, `position`, `rotation`, and `scale`.
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "pylone",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [0, 5, 0],
|
|
||||||
"rotation": [0, 1.57, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Each node `name` maps to a model folder:
|
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.
|
4. If `/map.json` is missing, the page displays a folder-upload flow.
|
||||||
5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
|
5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
|
||||||
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
|
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
|
||||||
7. `EditorControls` exposes transform mode, 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
|
## Controls
|
||||||
|
|
||||||
@@ -136,6 +126,9 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
|
|||||||
- `T`: translate mode.
|
- `T`: translate mode.
|
||||||
- `R`: rotate mode.
|
- `R`: rotate mode.
|
||||||
- `S`: scale 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+Z` or `Cmd+Z`: undo.
|
||||||
- `Ctrl+Y` or `Cmd+Y`: redo.
|
- `Ctrl+Y` or `Cmd+Y`: redo.
|
||||||
- `WASD`, `ZQSD`, or arrow keys: move in player-controller mode.
|
- `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:
|
The editor supports two output paths:
|
||||||
|
|
||||||
- Export JSON downloads the current `MapNode[]` as `map.json`.
|
- Export JSON downloads the current hierarchical map tree as `map.json`.
|
||||||
- Save to Server posts the current `MapNode[]` to `/api/save-map`.
|
- 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
|
## 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:
|
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
|
- inspect the raw JSON generated by the editor
|
||||||
- preview and edit cinematics from `public/cinematics.json`
|
- preview and edit cinematics from `public/cinematics.json`
|
||||||
- create, preview, and validate dialogue entries from `public/sounds/dialogue/dialogues.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:
|
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.
|
- `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.
|
- Missing models are displayed as gray fallback cubes, so incomplete maps remain editable.
|
||||||
|
|
||||||
## Map Node Format
|
## 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 |
|
| 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.
|
2. Click an object in the scene to select it.
|
||||||
3. Choose a transform mode: translate, rotate, or scale.
|
3. Choose a transform mode: translate, rotate, or scale.
|
||||||
4. Drag the transform gizmo in the 3D view.
|
4. Drag the transform gizmo in the 3D view.
|
||||||
5. Check the JSON inspector if you need exact values.
|
5. Keep `Snap terrain on move` enabled when placing objects on the terrain.
|
||||||
6. Use undo or redo if the transform is not correct.
|
6. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
|
||||||
7. Export the JSON or save it to the dev server.
|
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
|
## 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.
|
- Click empty space or press `Esc` to clear the selection.
|
||||||
- Use the `X` button to clear the selection explicitly.
|
- Use the `X` button to clear the selection explicitly.
|
||||||
- Use the `Lock` button to protect the current selection while editing.
|
- Use the `Lock` button to protect the current selection while editing.
|
||||||
|
- 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:
|
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
|
## 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 no object is selected, it shows the full map node list.
|
||||||
- When an object is selected, it highlights the JSON lines for that object.
|
- 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
|
||||||
|
|
||||||
`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
|
||||||
|
|
||||||
`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.
|
The button is hidden in production builds because production persistence is not implemented.
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
MousePointer2,
|
MousePointer2,
|
||||||
Move3D,
|
Move3D,
|
||||||
|
Plus,
|
||||||
Redo2,
|
Redo2,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Save,
|
Save,
|
||||||
|
Trash2,
|
||||||
Undo2,
|
Undo2,
|
||||||
Unlock,
|
Unlock,
|
||||||
X,
|
X,
|
||||||
@@ -19,18 +21,27 @@ import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinemati
|
|||||||
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
||||||
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
||||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
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 {
|
interface EditorControlsProps {
|
||||||
transformMode: TransformMode;
|
transformMode: TransformMode;
|
||||||
onTransformModeChange: (mode: TransformMode) => void;
|
onTransformModeChange: (mode: TransformMode) => void;
|
||||||
selectedNodeIndex: number | null;
|
selectedNodeIndex: number | null;
|
||||||
mapNodes: MapNode[];
|
mapNodes: EditableMapNode[];
|
||||||
nodesCount: number;
|
nodesCount: number;
|
||||||
selectedNodeName: string | null;
|
selectedNodeName: string | null;
|
||||||
|
selectedNodeScale: Vector3Tuple | null;
|
||||||
isSelectionLocked: boolean;
|
isSelectionLocked: boolean;
|
||||||
onSelectionLockToggle: () => void;
|
onSelectionLockToggle: () => void;
|
||||||
onClearSelection: () => 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;
|
undoCount: number;
|
||||||
redoCount: number;
|
redoCount: number;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
@@ -90,9 +101,17 @@ export function EditorControls({
|
|||||||
mapNodes,
|
mapNodes,
|
||||||
nodesCount,
|
nodesCount,
|
||||||
selectedNodeName,
|
selectedNodeName,
|
||||||
|
selectedNodeScale,
|
||||||
isSelectionLocked,
|
isSelectionLocked,
|
||||||
onSelectionLockToggle,
|
onSelectionLockToggle,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
|
snapToTerrain,
|
||||||
|
onSnapToTerrainToggle,
|
||||||
|
newNodeName,
|
||||||
|
onNewNodeNameChange,
|
||||||
|
onAddNode,
|
||||||
|
onDeleteSelectedNode,
|
||||||
|
onSelectedScaleChange,
|
||||||
undoCount,
|
undoCount,
|
||||||
redoCount,
|
redoCount,
|
||||||
onUndo,
|
onUndo,
|
||||||
@@ -181,6 +200,15 @@ export function EditorControls({
|
|||||||
<span>{redoCount}</span>
|
<span>{redoCount}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="editor-checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={snapToTerrain}
|
||||||
|
onChange={onSnapToTerrainToggle}
|
||||||
|
/>
|
||||||
|
<span>Snap terrain on move</span>
|
||||||
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -204,6 +232,14 @@ export function EditorControls({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-selected-actions">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSelectionLockToggle}
|
onClick={onSelectionLockToggle}
|
||||||
@@ -230,6 +266,26 @@ export function EditorControls({
|
|||||||
<X size={14} aria-hidden="true" />
|
<X size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="editor-no-selection">
|
<div className="editor-no-selection">
|
||||||
@@ -239,6 +295,32 @@ export function EditorControls({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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
|
<section
|
||||||
className="editor-control-section"
|
className="editor-control-section"
|
||||||
aria-labelledby="view-heading"
|
aria-labelledby="view-heading"
|
||||||
@@ -341,7 +423,7 @@ interface JsonPreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getJsonPreview(
|
function getJsonPreview(
|
||||||
mapNodes: MapNode[],
|
mapNodes: EditableMapNode[],
|
||||||
selectedNodeIndex: number | null,
|
selectedNodeIndex: number | null,
|
||||||
): JsonPreview {
|
): JsonPreview {
|
||||||
const { lines, ranges } = formatMapNodesWithRanges(mapNodes);
|
const { lines, ranges } = formatMapNodesWithRanges(mapNodes);
|
||||||
@@ -370,7 +452,7 @@ function getJsonPreview(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMapNodesWithRanges(mapNodes: MapNode[]): {
|
function formatMapNodesWithRanges(mapNodes: EditableMapNode[]): {
|
||||||
lines: string[];
|
lines: string[];
|
||||||
ranges: Array<{ start: number; end: number }>;
|
ranges: Array<{ start: number; end: number }>;
|
||||||
} {
|
} {
|
||||||
@@ -378,7 +460,14 @@ function formatMapNodesWithRanges(mapNodes: MapNode[]): {
|
|||||||
const ranges: Array<{ start: number; end: number }> = [];
|
const ranges: Array<{ start: number; end: number }> = [];
|
||||||
|
|
||||||
mapNodes.forEach((node, index) => {
|
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")
|
.split("\n")
|
||||||
.map((line) => ` ${line}`);
|
.map((line) => ` ${line}`);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as THREE from "three";
|
|||||||
|
|
||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
|
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
||||||
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
||||||
|
|
||||||
interface EditorMapProps {
|
interface EditorMapProps {
|
||||||
@@ -15,6 +16,7 @@ interface EditorMapProps {
|
|||||||
hoveredNodeIndex: number | null;
|
hoveredNodeIndex: number | null;
|
||||||
onHoverNode: (index: number | null) => void;
|
onHoverNode: (index: number | null) => void;
|
||||||
transformMode: TransformMode;
|
transformMode: TransformMode;
|
||||||
|
snapToTerrain: boolean;
|
||||||
onTransformStart: () => void;
|
onTransformStart: () => void;
|
||||||
onTransformEnd: () => void;
|
onTransformEnd: () => void;
|
||||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||||
@@ -138,11 +140,13 @@ export function EditorMap({
|
|||||||
hoveredNodeIndex,
|
hoveredNodeIndex,
|
||||||
onHoverNode,
|
onHoverNode,
|
||||||
transformMode,
|
transformMode,
|
||||||
|
snapToTerrain,
|
||||||
onTransformStart,
|
onTransformStart,
|
||||||
onTransformEnd,
|
onTransformEnd,
|
||||||
onNodeTransform,
|
onNodeTransform,
|
||||||
}: EditorMapProps): React.JSX.Element {
|
}: EditorMapProps): React.JSX.Element {
|
||||||
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
|
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
|
||||||
|
const terrainHeight = useTerrainHeightSampler();
|
||||||
|
|
||||||
const handleTransformMouseDown = () => {
|
const handleTransformMouseDown = () => {
|
||||||
onTransformStart();
|
onTransformStart();
|
||||||
@@ -154,9 +158,22 @@ export function EditorMap({
|
|||||||
if (!obj) return;
|
if (!obj) return;
|
||||||
const node = sceneData.mapNodes[selectedNodeIndex];
|
const node = sceneData.mapNodes[selectedNodeIndex];
|
||||||
if (node) {
|
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 = {
|
const updatedNode: MapNode = {
|
||||||
...node,
|
...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],
|
rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z],
|
||||||
scale: [obj.scale.x, obj.scale.y, obj.scale.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 gsap from "gsap";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
||||||
|
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
||||||
import { FlyController } from "@/controls/editor/FlyController";
|
import { FlyController } from "@/controls/editor/FlyController";
|
||||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
||||||
@@ -21,6 +22,7 @@ interface EditorSceneProps {
|
|||||||
hoveredNodeIndex: number | null;
|
hoveredNodeIndex: number | null;
|
||||||
onHoverNode: (index: number | null) => void;
|
onHoverNode: (index: number | null) => void;
|
||||||
transformMode: TransformMode;
|
transformMode: TransformMode;
|
||||||
|
snapToTerrain: boolean;
|
||||||
onTransformModeChange: (mode: TransformMode) => void;
|
onTransformModeChange: (mode: TransformMode) => void;
|
||||||
onTransformStart: () => void;
|
onTransformStart: () => void;
|
||||||
onTransformEnd: () => void;
|
onTransformEnd: () => void;
|
||||||
@@ -40,6 +42,7 @@ export function EditorScene({
|
|||||||
hoveredNodeIndex,
|
hoveredNodeIndex,
|
||||||
onHoverNode,
|
onHoverNode,
|
||||||
transformMode,
|
transformMode,
|
||||||
|
snapToTerrain,
|
||||||
onTransformModeChange,
|
onTransformModeChange,
|
||||||
onTransformStart,
|
onTransformStart,
|
||||||
onTransformEnd,
|
onTransformEnd,
|
||||||
@@ -126,11 +129,14 @@ export function EditorScene({
|
|||||||
hoveredNodeIndex={hoveredNodeIndex}
|
hoveredNodeIndex={hoveredNodeIndex}
|
||||||
onHoverNode={onHoverNode}
|
onHoverNode={onHoverNode}
|
||||||
transformMode={transformMode}
|
transformMode={transformMode}
|
||||||
|
snapToTerrain={snapToTerrain}
|
||||||
onTransformStart={onTransformStart}
|
onTransformStart={onTransformStart}
|
||||||
onTransformEnd={onTransformEnd}
|
onTransformEnd={onTransformEnd}
|
||||||
onNodeTransform={onNodeTransform}
|
onNodeTransform={onNodeTransform}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TerrainModel />
|
||||||
|
|
||||||
<ambientLight intensity={0.6} />
|
<ambientLight intensity={0.6} />
|
||||||
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
|
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
|
||||||
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
|
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
|
||||||
|
|||||||
@@ -1,13 +1,76 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
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 {
|
interface ObjectTransform {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
|
path: number[];
|
||||||
position: { x: number; y: number; z: number };
|
position: { x: number; y: number; z: number };
|
||||||
rotation: { x: number; y: number; z: number };
|
rotation: { x: number; y: number; z: number };
|
||||||
scale: { 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 {
|
class HistoryManager {
|
||||||
private history: ObjectTransform[][] = [];
|
private history: ObjectTransform[][] = [];
|
||||||
private currentIndex = -1;
|
private currentIndex = -1;
|
||||||
@@ -81,13 +144,14 @@ export function useEditorHistory(
|
|||||||
setSceneData((prev) => {
|
setSceneData((prev) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
|
|
||||||
|
let mapTree = prev.mapTree;
|
||||||
const mapNodes = prev.mapNodes.map((node, index) => {
|
const mapNodes = prev.mapNodes.map((node, index) => {
|
||||||
const transform = snapshot.find(
|
const transform = snapshot.find(
|
||||||
(item) => item.uuid === `node-${index}`,
|
(item) => item.uuid === `node-${index}`,
|
||||||
);
|
);
|
||||||
if (!transform) return node;
|
if (!transform) return node;
|
||||||
|
|
||||||
return {
|
const nextNode = {
|
||||||
...node,
|
...node,
|
||||||
position: [
|
position: [
|
||||||
transform.position.x,
|
transform.position.x,
|
||||||
@@ -101,9 +165,13 @@ export function useEditorHistory(
|
|||||||
],
|
],
|
||||||
scale: [transform.scale.x, transform.scale.y, transform.scale.z],
|
scale: [transform.scale.x, transform.scale.y, transform.scale.z],
|
||||||
} satisfies MapNode;
|
} satisfies MapNode;
|
||||||
|
|
||||||
|
mapTree = updateTreeNodeAtPath(mapTree, node.path, transform);
|
||||||
|
|
||||||
|
return nextNode;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...prev, mapNodes };
|
return { ...prev, mapNodes, mapTree };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setSceneData],
|
[setSceneData],
|
||||||
@@ -149,6 +217,7 @@ export function useEditorHistory(
|
|||||||
function createSnapshot(sceneData: SceneData): ObjectTransform[] {
|
function createSnapshot(sceneData: SceneData): ObjectTransform[] {
|
||||||
return sceneData.mapNodes.map((node, index) => ({
|
return sceneData.mapNodes.map((node, index) => ({
|
||||||
uuid: `node-${index}`,
|
uuid: `node-${index}`,
|
||||||
|
path: node.path,
|
||||||
position: {
|
position: {
|
||||||
x: node.position[0],
|
x: node.position[0],
|
||||||
y: node.position[1],
|
y: node.position[1],
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ export function useTerrainSnappedPosition(
|
|||||||
}, [position, terrainHeight]);
|
}, [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 {
|
export function normalizeMapScale(scale: Vector3Tuple): Vector3Tuple {
|
||||||
const [x, y, z] = scale;
|
const [x, y, z] = scale;
|
||||||
const isUniform = Math.abs(x - y) < 0.001 && Math.abs(x - z) < 0.001;
|
const isUniform = Math.abs(x - y) < 0.001 && Math.abs(x - z) < 0.001;
|
||||||
|
|||||||
@@ -1390,6 +1390,52 @@ canvas {
|
|||||||
color: #050505;
|
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 {
|
.editor-selected-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
+280
-5
@@ -9,7 +9,13 @@ import { Subtitles } from "@/components/ui/Subtitles";
|
|||||||
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
||||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
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 {
|
import {
|
||||||
INITIAL_SCENE_LOADING_STATE,
|
INITIAL_SCENE_LOADING_STATE,
|
||||||
type SceneLoadingChangeHandler,
|
type SceneLoadingChangeHandler,
|
||||||
@@ -18,13 +24,200 @@ import {
|
|||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
|
||||||
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
|
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
|
||||||
|
const DEFAULT_NEW_NODE_NAME = "new-model";
|
||||||
|
|
||||||
interface EditorSceneLoadingTrackerProps {
|
interface EditorSceneLoadingTrackerProps {
|
||||||
onLoadingStateChange: SceneLoadingChangeHandler;
|
onLoadingStateChange: SceneLoadingChangeHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeMapNodes(sceneData: SceneData): string {
|
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({
|
function EditorSceneLoadingTracker({
|
||||||
@@ -69,6 +262,8 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
useState<TransformMode>("translate");
|
useState<TransformMode>("translate");
|
||||||
const [isPlayerMode, setIsPlayerMode] = useState(false);
|
const [isPlayerMode, setIsPlayerMode] = useState(false);
|
||||||
const [isSelectionLocked, setIsSelectionLocked] = 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>(
|
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
||||||
{
|
{
|
||||||
...INITIAL_SCENE_LOADING_STATE,
|
...INITIAL_SCENE_LOADING_STATE,
|
||||||
@@ -122,6 +317,14 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
setIsSelectionLocked((locked) => !locked);
|
setIsSelectionLocked((locked) => !locked);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSnapToTerrainToggle = useCallback(() => {
|
||||||
|
setSnapToTerrain((enabled) => !enabled);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNewNodeNameChange = useCallback((value: string) => {
|
||||||
|
setNewNodeName(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleHoverNode = useCallback((index: number | null) => {
|
const handleHoverNode = useCallback((index: number | null) => {
|
||||||
setHoveredNodeIndex(index);
|
setHoveredNodeIndex(index);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -186,14 +389,73 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
(nodeIndex: number, updatedNode: MapNode) => {
|
(nodeIndex: number, updatedNode: MapNode) => {
|
||||||
setSceneData((prev) => {
|
setSceneData((prev) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
const newMapNodes = [...prev.mapNodes];
|
const currentNode = prev.mapNodes[nodeIndex];
|
||||||
newMapNodes[nodeIndex] = updatedNode;
|
if (!currentNode) return prev;
|
||||||
return { ...prev, mapNodes: newMapNodes };
|
|
||||||
|
const mapTree = updateTreeNodeAtPath(
|
||||||
|
prev.mapTree,
|
||||||
|
currentNode.path,
|
||||||
|
(node) => ({
|
||||||
|
...node,
|
||||||
|
position: updatedNode.position,
|
||||||
|
rotation: updatedNode.rotation,
|
||||||
|
scale: updatedNode.scale,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return updateSceneDataTree(prev, mapTree);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setSceneData],
|
[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) {
|
if (isMapLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="editor-container">
|
<div className="editor-container">
|
||||||
@@ -279,6 +541,7 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
hoveredNodeIndex={hoveredNodeIndex}
|
hoveredNodeIndex={hoveredNodeIndex}
|
||||||
onHoverNode={handleHoverNode}
|
onHoverNode={handleHoverNode}
|
||||||
transformMode={transformMode}
|
transformMode={transformMode}
|
||||||
|
snapToTerrain={snapToTerrain}
|
||||||
onTransformModeChange={handleTransformModeChange}
|
onTransformModeChange={handleTransformModeChange}
|
||||||
onTransformStart={handleTransformStart}
|
onTransformStart={handleTransformStart}
|
||||||
onTransformEnd={handleTransformEnd}
|
onTransformEnd={handleTransformEnd}
|
||||||
@@ -306,9 +569,21 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
? sceneData.mapNodes[selectedNodeIndex].name || null
|
? sceneData.mapNodes[selectedNodeIndex].name || null
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
selectedNodeScale={
|
||||||
|
selectedNodeIndex !== null && sceneData.mapNodes[selectedNodeIndex]
|
||||||
|
? sceneData.mapNodes[selectedNodeIndex].scale
|
||||||
|
: null
|
||||||
|
}
|
||||||
isSelectionLocked={isSelectionLocked}
|
isSelectionLocked={isSelectionLocked}
|
||||||
onSelectionLockToggle={handleSelectionLockToggle}
|
onSelectionLockToggle={handleSelectionLockToggle}
|
||||||
onClearSelection={handleClearSelection}
|
onClearSelection={handleClearSelection}
|
||||||
|
snapToTerrain={snapToTerrain}
|
||||||
|
onSnapToTerrainToggle={handleSnapToTerrainToggle}
|
||||||
|
newNodeName={newNodeName}
|
||||||
|
onNewNodeNameChange={handleNewNodeNameChange}
|
||||||
|
onAddNode={handleAddNode}
|
||||||
|
onDeleteSelectedNode={handleDeleteSelectedNode}
|
||||||
|
onSelectedScaleChange={handleSelectedScaleChange}
|
||||||
undoCount={undoCount}
|
undoCount={undoCount}
|
||||||
redoCount={redoCount}
|
redoCount={redoCount}
|
||||||
onUndo={handleUndo}
|
onUndo={handleUndo}
|
||||||
|
|||||||
@@ -8,13 +8,18 @@ export interface MapNode {
|
|||||||
scale: Vector3Tuple;
|
scale: Vector3Tuple;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EditableMapNode extends MapNode {
|
||||||
|
path: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface HierarchicalMapNode extends MapNode {
|
export interface HierarchicalMapNode extends MapNode {
|
||||||
role?: "group";
|
role?: "group";
|
||||||
children?: HierarchicalMapNode[];
|
children?: HierarchicalMapNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SceneData {
|
export interface SceneData {
|
||||||
mapNodes: MapNode[];
|
mapNodes: EditableMapNode[];
|
||||||
|
mapTree: HierarchicalMapNode | HierarchicalMapNode[];
|
||||||
models: Map<string, string>;
|
models: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { SceneData } from "@/types/editor/editor";
|
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";
|
const MAP_JSON_PATH = "/map.json";
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export async function createSceneDataFromFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mapPayload: unknown = JSON.parse(await mapFile.text());
|
const mapPayload: unknown = JSON.parse(await mapFile.text());
|
||||||
const mapNodes = parseMapNodes(mapPayload);
|
const sceneData = await createSceneDataFromMapPayload(mapPayload);
|
||||||
const models = new Map<string, string>();
|
const models = new Map<string, string>();
|
||||||
|
|
||||||
for (const [path, file] of fileMap.entries()) {
|
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 {
|
function getProjectRelativePath(file: File): string {
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { MapNode, SceneData } from "@/types/editor/editor";
|
import type {
|
||||||
import { parseMapNodes } from "@/utils/map/mapNodeValidation";
|
EditableMapNode,
|
||||||
|
HierarchicalMapNode,
|
||||||
|
MapNode,
|
||||||
|
SceneData,
|
||||||
|
} from "@/types/editor/editor";
|
||||||
|
import {
|
||||||
|
parseHierarchicalMapPayload,
|
||||||
|
parseMapNodes,
|
||||||
|
} from "@/utils/map/mapNodeValidation";
|
||||||
|
|
||||||
const MAP_JSON_PATH = "/map.json";
|
const MAP_JSON_PATH = "/map.json";
|
||||||
const MODEL_FILE_NAMES = ["model.glb", "model.gltf"];
|
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 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);
|
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 {
|
function createPositionKey(node: MapNode): string {
|
||||||
@@ -84,9 +142,36 @@ function deduplicateMapNodes(nodes: MapNode[]): MapNode[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSceneData(mapNodes: MapNode[]): Promise<SceneData> {
|
function deduplicateEditableMapNodes(
|
||||||
const models = await loadMapModelUrls(mapNodes);
|
nodes: EditableMapNode[],
|
||||||
return { mapNodes, models };
|
): 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(
|
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)) {
|
if (!isMapNode(value)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -54,13 +56,25 @@ function flattenMapNode(node: HierarchicalMapNode): MapNode[] {
|
|||||||
rotation: node.rotation,
|
rotation: node.rotation,
|
||||||
scale: node.scale,
|
scale: node.scale,
|
||||||
};
|
};
|
||||||
const childNodes = node.children?.flatMap(flattenMapNode) ?? [];
|
|
||||||
|
|
||||||
if (node.role === "group") {
|
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[] {
|
export function parseMapNodes(value: unknown): MapNode[] {
|
||||||
|
|||||||
+12
-4
@@ -4,6 +4,7 @@ import {
|
|||||||
Suspense,
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -11,8 +12,9 @@ import * as THREE from "three";
|
|||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
import {
|
import {
|
||||||
|
getObjectBottomOffset,
|
||||||
normalizeMapScale,
|
normalizeMapScale,
|
||||||
useTerrainSnappedPosition,
|
useTerrainHeightSampler,
|
||||||
} from "@/hooks/three/useTerrainHeight";
|
} from "@/hooks/three/useTerrainHeight";
|
||||||
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
||||||
import {
|
import {
|
||||||
@@ -356,15 +358,21 @@ function ModelInstance({
|
|||||||
onLoaded: () => void;
|
onLoaded: () => void;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { position, rotation, scale } = node;
|
const { position, rotation, scale } = node;
|
||||||
const snappedPosition = useTerrainSnappedPosition(position);
|
|
||||||
const normalizedScale = normalizeMapScale(scale);
|
const normalizedScale = normalizeMapScale(scale);
|
||||||
|
const terrainHeight = useTerrainHeightSampler();
|
||||||
const { scene } = useLoggedGLTF(modelUrl, {
|
const { scene } = useLoggedGLTF(modelUrl, {
|
||||||
scope: "GameMap.ModelInstance",
|
scope: "GameMap.ModelInstance",
|
||||||
position: snappedPosition,
|
position,
|
||||||
rotation,
|
rotation,
|
||||||
scale: normalizedScale,
|
scale: normalizedScale,
|
||||||
});
|
});
|
||||||
const sceneInstance = useClonedObject(scene);
|
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(() => {
|
useEffect(() => {
|
||||||
sceneInstance.traverse((child) => {
|
sceneInstance.traverse((child) => {
|
||||||
@@ -379,7 +387,7 @@ function ModelInstance({
|
|||||||
return (
|
return (
|
||||||
<primitive
|
<primitive
|
||||||
object={sceneInstance}
|
object={sceneInstance}
|
||||||
position={snappedPosition}
|
position={groundedPosition}
|
||||||
rotation={rotation}
|
rotation={rotation}
|
||||||
scale={normalizedScale}
|
scale={normalizedScale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
|
|||||||
function setInstanceMatrices(
|
function setInstanceMatrices(
|
||||||
instancedMesh: THREE.InstancedMesh,
|
instancedMesh: THREE.InstancedMesh,
|
||||||
instances: MapAssetInstance[],
|
instances: MapAssetInstance[],
|
||||||
|
geometryBottomY: number,
|
||||||
): void {
|
): void {
|
||||||
const position = new THREE.Vector3();
|
const position = new THREE.Vector3();
|
||||||
const rotation = new THREE.Euler();
|
const rotation = new THREE.Euler();
|
||||||
@@ -145,6 +146,7 @@ function setInstanceMatrices(
|
|||||||
rotation.set(...instance.rotation);
|
rotation.set(...instance.rotation);
|
||||||
quaternion.setFromEuler(rotation);
|
quaternion.setFromEuler(rotation);
|
||||||
scale.set(...instance.scale);
|
scale.set(...instance.scale);
|
||||||
|
position.y += -geometryBottomY * scale.y;
|
||||||
matrix.compose(position, quaternion, scale);
|
matrix.compose(position, quaternion, scale);
|
||||||
instancedMesh.setMatrixAt(i, matrix);
|
instancedMesh.setMatrixAt(i, matrix);
|
||||||
}
|
}
|
||||||
@@ -152,6 +154,20 @@ function setInstanceMatrices(
|
|||||||
instancedMesh.instanceMatrix.needsUpdate = true;
|
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({
|
export function InstancedMapAsset({
|
||||||
modelPath,
|
modelPath,
|
||||||
instances,
|
instances,
|
||||||
@@ -185,6 +201,7 @@ export function InstancedMapAsset({
|
|||||||
|
|
||||||
optimizeGLTFSceneTextures(scene, maxAnisotropy);
|
optimizeGLTFSceneTextures(scene, maxAnisotropy);
|
||||||
const meshDataList = extractMeshes(scene);
|
const meshDataList = extractMeshes(scene);
|
||||||
|
const geometryBottomY = getMeshBottomY(meshDataList);
|
||||||
const instancedMeshes = meshDataList.map((meshData, index) => {
|
const instancedMeshes = meshDataList.map((meshData, index) => {
|
||||||
const instancedMesh = new THREE.InstancedMesh(
|
const instancedMesh = new THREE.InstancedMesh(
|
||||||
meshData.geometry,
|
meshData.geometry,
|
||||||
@@ -192,7 +209,7 @@ export function InstancedMapAsset({
|
|||||||
groundedInstances.length,
|
groundedInstances.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
setInstanceMatrices(instancedMesh, groundedInstances);
|
setInstanceMatrices(instancedMesh, groundedInstances, geometryBottomY);
|
||||||
instancedMesh.castShadow = castShadow;
|
instancedMesh.castShadow = castShadow;
|
||||||
instancedMesh.receiveShadow = receiveShadow;
|
instancedMesh.receiveShadow = receiveShadow;
|
||||||
instancedMesh.name = `instanced-map-asset-${index}`;
|
instancedMesh.name = `instanced-map-asset-${index}`;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
|
|||||||
function createInstanceMatrices(
|
function createInstanceMatrices(
|
||||||
instances: VegetationInstance[],
|
instances: VegetationInstance[],
|
||||||
scaleMultiplier: number,
|
scaleMultiplier: number,
|
||||||
|
geometryBottomY: number,
|
||||||
): THREE.Matrix4[] {
|
): THREE.Matrix4[] {
|
||||||
const matrices: THREE.Matrix4[] = [];
|
const matrices: THREE.Matrix4[] = [];
|
||||||
const position = new THREE.Vector3();
|
const position = new THREE.Vector3();
|
||||||
@@ -90,6 +91,7 @@ function createInstanceMatrices(
|
|||||||
const matrix = new THREE.Matrix4();
|
const matrix = new THREE.Matrix4();
|
||||||
|
|
||||||
position.set(...instance.position);
|
position.set(...instance.position);
|
||||||
|
position.y += -geometryBottomY * scaleMultiplier;
|
||||||
rotation.set(...instance.rotation);
|
rotation.set(...instance.rotation);
|
||||||
quaternion.setFromEuler(rotation);
|
quaternion.setFromEuler(rotation);
|
||||||
matrix.compose(position, quaternion, scale);
|
matrix.compose(position, quaternion, scale);
|
||||||
@@ -99,6 +101,20 @@ function createInstanceMatrices(
|
|||||||
return matrices;
|
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({
|
export function InstancedVegetation({
|
||||||
modelPath,
|
modelPath,
|
||||||
instances,
|
instances,
|
||||||
@@ -130,8 +146,13 @@ export function InstancedVegetation({
|
|||||||
[instances, terrainHeight],
|
[instances, terrainHeight],
|
||||||
);
|
);
|
||||||
const matrices = useMemo(
|
const matrices = useMemo(
|
||||||
() => createInstanceMatrices(groundedInstances, scaleMultiplier),
|
() =>
|
||||||
[groundedInstances, scaleMultiplier],
|
createInstanceMatrices(
|
||||||
|
groundedInstances,
|
||||||
|
scaleMultiplier,
|
||||||
|
getMeshBottomY(meshDataList),
|
||||||
|
),
|
||||||
|
[groundedInstances, meshDataList, scaleMultiplier],
|
||||||
);
|
);
|
||||||
|
|
||||||
const instancedMeshes = useMemo(() => {
|
const instancedMeshes = useMemo(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user