fix(editor): restore stable map editing behavior

This commit is contained in:
Tom Boullay
2026-05-29 00:52:44 +02:00
parent d5675fe82c
commit 343a122c06
28 changed files with 453 additions and 302 deletions
+95 -44
View File
@@ -1,12 +1,22 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { Grid, TransformControls } from "@react-three/drei";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
Suspense,
} from "react";
import { TransformControls } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three";
import { TerrainModel } from "@/components/three/world/TerrainModel";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
import {
getObjectBottomOffset,
useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight";
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
import {
isEditorVisibleMapNode,
@@ -94,6 +104,30 @@ function getEditorModelVisualScaleMultiplier(name: string): number {
);
}
function getEditorModelVisualYOffset(
object: THREE.Object3D,
node: MapNode,
terrainHeight: ReturnType<typeof useTerrainHeightSampler>,
visualScaleMultiplier: number,
): number {
const [x, y, z] = node.position;
const height = terrainHeight.getHeight(x, z);
if (height === null) return 0;
const finalScale: [number, number, number] = [
node.scale[0] * visualScaleMultiplier,
node.scale[1] * visualScaleMultiplier,
node.scale[2] * visualScaleMultiplier,
];
const originalPosition = object.position.clone();
object.position.set(0, 0, 0);
const bottomOffset = getObjectBottomOffset(object, finalScale);
object.position.copy(originalPosition);
const parentScaleY = Math.abs(node.scale[1]) > 0.0001 ? node.scale[1] : 1;
return (height + bottomOffset - y) / parentScaleY;
}
function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
object.position.set(...node.position);
object.rotation.set(...node.rotation);
@@ -222,7 +256,6 @@ export function EditorMap({
selectedNodeIndex !== null
? (sceneData.mapNodes[selectedNodeIndex]?.name ?? null)
: null;
const getTransformObject = useCallback(() => {
if (isMultiSelection) {
return transformGroupRef.current;
@@ -407,35 +440,22 @@ export function EditorMap({
return (
<>
<Grid
args={[100, 100]}
cellSize={1}
cellThickness={0.5}
cellColor="#242424"
sectionSize={5}
sectionThickness={1}
sectionColor="#3a3a3a"
fadeDistance={50}
fadeStrength={1}
followCamera={false}
infiniteGrid={false}
/>
<axesHelper args={[10]} />
<group>
{terrainNode ? (
<EditorTerrainNode
index={terrainNodeIndex}
node={terrainNode}
isSelected={selectedIndexSet.has(terrainNodeIndex)}
isHovered={hoveredNodeIndex === terrainNodeIndex}
lockTerrainSelection={lockTerrainSelection}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
<Suspense fallback={null}>
<EditorTerrainNode
index={terrainNodeIndex}
node={terrainNode}
isSelected={selectedIndexSet.has(terrainNodeIndex)}
isHovered={hoveredNodeIndex === terrainNodeIndex}
lockTerrainSelection={lockTerrainSelection}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
</Suspense>
) : null}
{sceneData.mapNodes.map((node, index) => {
if (!shouldRenderEditorNode(node, selectedNodeName)) {
@@ -446,19 +466,35 @@ export function EditorMap({
if (modelUrl) {
return (
<EditorModelNode
<Suspense
key={index}
index={index}
node={node}
modelUrl={modelUrl}
isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
fallback={
<EditorFallbackNode
index={index}
node={node}
isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
}
>
<EditorModelNode
index={index}
node={node}
modelUrl={modelUrl}
isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
</Suspense>
);
} else {
return (
@@ -519,7 +555,18 @@ function EditorModelNode({
scale: node.scale,
});
const sceneInstance = useClonedObject(scene);
const terrainHeight = useTerrainHeightSampler();
const visualScaleMultiplier = getEditorModelVisualScaleMultiplier(node.name);
const visualYOffset = useMemo(
() =>
getEditorModelVisualYOffset(
sceneInstance,
node,
terrainHeight,
visualScaleMultiplier,
),
[node, sceneInstance, terrainHeight, visualScaleMultiplier],
);
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
@@ -588,7 +635,11 @@ function EditorModelNode({
scale={node.scale}
{...pointerHandlers}
>
<primitive object={sceneInstance} scale={visualScaleMultiplier} />
<primitive
object={sceneInstance}
position={[0, visualYOffset, 0]}
scale={visualScaleMultiplier}
/>
</group>
);
}
+35 -21
View File
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useRef } from "react";
import { OrbitControls } from "@react-three/drei";
import { Suspense, useCallback, useEffect, useRef } from "react";
import { Grid, OrbitControls } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import gsap from "gsap";
import * as THREE from "three";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
import { EditorMap } from "@/components/editor/scene/EditorMap";
import { FlyController } from "@/controls/editor/FlyController";
import { PersonnageSystem } from "@/world/personnages/PersonnageSystem";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
@@ -214,26 +213,41 @@ export function EditorScene({
/>
)}
<EditorMap
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
transformMode={transformMode}
snapToTerrain={snapToTerrain}
lockTerrainSelection={lockTerrainSelection}
onTransformStart={onTransformStart}
onTransformEnd={onTransformEnd}
onNodeTransform={onNodeTransform}
snapAllToTerrainRequest={snapAllToTerrainRequest}
onSnapAllToTerrain={onSnapAllToTerrain}
<Grid
args={[100, 100]}
cellSize={1}
cellThickness={0.5}
cellColor="#242424"
sectionSize={5}
sectionThickness={1}
sectionColor="#3a3a3a"
fadeDistance={50}
fadeStrength={1}
followCamera={false}
infiniteGrid={false}
/>
<axesHelper args={[10]} />
<PersonnageSystem />
<Suspense fallback={null}>
<EditorMap
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
transformMode={transformMode}
snapToTerrain={snapToTerrain}
lockTerrainSelection={lockTerrainSelection}
onTransformStart={onTransformStart}
onTransformEnd={onTransformEnd}
onNodeTransform={onNodeTransform}
snapAllToTerrainRequest={snapAllToTerrainRequest}
onSnapAllToTerrain={onSnapAllToTerrain}
/>
</Suspense>
<ambientLight intensity={0.6} />
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
+3 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
@@ -41,7 +42,7 @@ export function SimpleModel({
rotation,
scale,
});
const model = useMemo(() => scene.clone(true), [scene]);
const model = useClonedObject(scene);
useEffect(() => {
applyShadowSettings(model, castShadow, receiveShadow);
+10 -1
View File
@@ -5,6 +5,7 @@ import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
interface SkyModelProps {
fallbackModelPath?: string | undefined;
modelPath: string;
fallbackColor?: string | undefined;
scale?: number | undefined;
@@ -52,12 +53,20 @@ class SkyModelErrorBoundary extends Component<
export function SkyModel({
fallbackColor,
fallbackModelPath,
modelPath,
scale = SKY_MODEL_SCALE,
}: SkyModelProps): React.JSX.Element {
const fallback = fallbackColor ? (
const colorFallback = fallbackColor ? (
<color attach="background" args={[fallbackColor]} />
) : null;
const fallback = fallbackModelPath ? (
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
<SkyModelContent modelPath={fallbackModelPath} scale={scale} />
</SkyModelErrorBoundary>
) : (
colorFallback
);
return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>