@@ -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}
diff --git a/src/types/editor/editor.ts b/src/types/editor/editor.ts
index ceda325..0871fac 100644
--- a/src/types/editor/editor.ts
+++ b/src/types/editor/editor.ts
@@ -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
;
}
diff --git a/src/utils/editor/loadEditorScene.ts b/src/utils/editor/loadEditorScene.ts
index 3da9c53..aaa88b6 100644
--- a/src/utils/editor/loadEditorScene.ts
+++ b/src/utils/editor/loadEditorScene.ts
@@ -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();
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 {
diff --git a/src/utils/map/loadMapSceneData.ts b/src/utils/map/loadMapSceneData.ts
index 2efef22..bda7fcc 100644
--- a/src/utils/map/loadMapSceneData.ts
+++ b/src/utils/map/loadMapSceneData.ts
@@ -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 {
}
const mapPayload: unknown = await response.json();
- const mapNodes = parseMapNodes(mapPayload);
+ return createSceneDataFromMapPayload(mapPayload);
+}
+
+export async function createSceneDataFromMapPayload(
+ mapPayload: unknown,
+): Promise {
+ 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 {
- const models = await loadMapModelUrls(mapNodes);
- return { mapNodes, models };
+function deduplicateEditableMapNodes(
+ nodes: EditableMapNode[],
+): EditableMapNode[] {
+ const seen = new Set();
+ 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 {
+ const models = await loadMapModelUrls(modelLookupNodes);
+ return { mapNodes, mapTree, models };
}
async function loadMapModelUrls(
diff --git a/src/utils/map/mapNodeValidation.ts b/src/utils/map/mapNodeValidation.ts
index 7610bd8..b5b83cf 100644
--- a/src/utils/map/mapNodeValidation.ts
+++ b/src/utils/map/mapNodeValidation.ts
@@ -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[] {
diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx
index 8de9b7b..6723962 100644
--- a/src/world/GameMap.tsx
+++ b/src/world/GameMap.tsx
@@ -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 (
diff --git a/src/world/map-instancing/InstancedMapAsset.tsx b/src/world/map-instancing/InstancedMapAsset.tsx
index 38dd5c6..2e06433 100644
--- a/src/world/map-instancing/InstancedMapAsset.tsx
+++ b/src/world/map-instancing/InstancedMapAsset.tsx
@@ -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}`;
diff --git a/src/world/vegetation/InstancedVegetation.tsx b/src/world/vegetation/InstancedVegetation.tsx
index ff8598a..c17078e 100644
--- a/src/world/vegetation/InstancedVegetation.tsx
+++ b/src/world/vegetation/InstancedVegetation.tsx
@@ -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(() => {