feat(editor): edit hierarchical map nodes

This commit is contained in:
Tom Boullay
2026-05-27 08:30:54 +02:00
parent ab100c683f
commit c2b16434fb
16 changed files with 740 additions and 64 deletions
+12 -4
View File
@@ -4,6 +4,7 @@ import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -11,8 +12,9 @@ import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import {
getObjectBottomOffset,
normalizeMapScale,
useTerrainSnappedPosition,
useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight";
import { TerrainModel } from "@/components/three/world/TerrainModel";
import {
@@ -356,15 +358,21 @@ function ModelInstance({
onLoaded: () => void;
}): React.JSX.Element {
const { position, rotation, scale } = node;
const snappedPosition = useTerrainSnappedPosition(position);
const normalizedScale = normalizeMapScale(scale);
const terrainHeight = useTerrainHeightSampler();
const { scene } = useLoggedGLTF(modelUrl, {
scope: "GameMap.ModelInstance",
position: snappedPosition,
position,
rotation,
scale: normalizedScale,
});
const sceneInstance = useClonedObject(scene);
const groundedPosition = useMemo(() => {
const [x, y, z] = position;
const height = terrainHeight.getHeight(x, z);
const bottomOffset = getObjectBottomOffset(sceneInstance, normalizedScale);
return [x, height !== null ? height + bottomOffset : y, z] as const;
}, [normalizedScale, position, sceneInstance, terrainHeight]);
useEffect(() => {
sceneInstance.traverse((child) => {
@@ -379,7 +387,7 @@ function ModelInstance({
return (
<primitive
object={sceneInstance}
position={snappedPosition}
position={groundedPosition}
rotation={rotation}
scale={normalizedScale}
/>
+18 -1
View File
@@ -130,6 +130,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
function setInstanceMatrices(
instancedMesh: THREE.InstancedMesh,
instances: MapAssetInstance[],
geometryBottomY: number,
): void {
const position = new THREE.Vector3();
const rotation = new THREE.Euler();
@@ -145,6 +146,7 @@ function setInstanceMatrices(
rotation.set(...instance.rotation);
quaternion.setFromEuler(rotation);
scale.set(...instance.scale);
position.y += -geometryBottomY * scale.y;
matrix.compose(position, quaternion, scale);
instancedMesh.setMatrixAt(i, matrix);
}
@@ -152,6 +154,20 @@ function setInstanceMatrices(
instancedMesh.instanceMatrix.needsUpdate = true;
}
function getMeshBottomY(meshDataList: MeshData[]): number {
let bottomY = Number.POSITIVE_INFINITY;
for (const meshData of meshDataList) {
meshData.geometry.computeBoundingBox();
const minY = meshData.geometry.boundingBox?.min.y;
if (minY !== undefined) {
bottomY = Math.min(bottomY, minY);
}
}
return Number.isFinite(bottomY) ? bottomY : 0;
}
export function InstancedMapAsset({
modelPath,
instances,
@@ -185,6 +201,7 @@ export function InstancedMapAsset({
optimizeGLTFSceneTextures(scene, maxAnisotropy);
const meshDataList = extractMeshes(scene);
const geometryBottomY = getMeshBottomY(meshDataList);
const instancedMeshes = meshDataList.map((meshData, index) => {
const instancedMesh = new THREE.InstancedMesh(
meshData.geometry,
@@ -192,7 +209,7 @@ export function InstancedMapAsset({
groundedInstances.length,
);
setInstanceMatrices(instancedMesh, groundedInstances);
setInstanceMatrices(instancedMesh, groundedInstances, geometryBottomY);
instancedMesh.castShadow = castShadow;
instancedMesh.receiveShadow = receiveShadow;
instancedMesh.name = `instanced-map-asset-${index}`;
+23 -2
View File
@@ -75,6 +75,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
function createInstanceMatrices(
instances: VegetationInstance[],
scaleMultiplier: number,
geometryBottomY: number,
): THREE.Matrix4[] {
const matrices: THREE.Matrix4[] = [];
const position = new THREE.Vector3();
@@ -90,6 +91,7 @@ function createInstanceMatrices(
const matrix = new THREE.Matrix4();
position.set(...instance.position);
position.y += -geometryBottomY * scaleMultiplier;
rotation.set(...instance.rotation);
quaternion.setFromEuler(rotation);
matrix.compose(position, quaternion, scale);
@@ -99,6 +101,20 @@ function createInstanceMatrices(
return matrices;
}
function getMeshBottomY(meshDataList: MeshData[]): number {
let bottomY = Number.POSITIVE_INFINITY;
for (const meshData of meshDataList) {
meshData.geometry.computeBoundingBox();
const minY = meshData.geometry.boundingBox?.min.y;
if (minY !== undefined) {
bottomY = Math.min(bottomY, minY);
}
}
return Number.isFinite(bottomY) ? bottomY : 0;
}
export function InstancedVegetation({
modelPath,
instances,
@@ -130,8 +146,13 @@ export function InstancedVegetation({
[instances, terrainHeight],
);
const matrices = useMemo(
() => createInstanceMatrices(groundedInstances, scaleMultiplier),
[groundedInstances, scaleMultiplier],
() =>
createInstanceMatrices(
groundedInstances,
scaleMultiplier,
getMeshBottomY(meshDataList),
),
[groundedInstances, meshDataList, scaleMultiplier],
);
const instancedMeshes = useMemo(() => {