feat(editor): focus selected model editing
This commit is contained in:
@@ -28,6 +28,8 @@ interface EditorControlsProps {
|
||||
mapNodes: MapNode[];
|
||||
nodesCount: number;
|
||||
selectedNodeName: string | null;
|
||||
lockTerrainSelection: boolean;
|
||||
onLockTerrainSelectionChange: (locked: boolean) => void;
|
||||
isSelectionLocked: boolean;
|
||||
onSelectionLockToggle: () => void;
|
||||
onClearSelection: () => void;
|
||||
@@ -90,6 +92,8 @@ export function EditorControls({
|
||||
mapNodes,
|
||||
nodesCount,
|
||||
selectedNodeName,
|
||||
lockTerrainSelection,
|
||||
onLockTerrainSelectionChange,
|
||||
isSelectionLocked,
|
||||
onSelectionLockToggle,
|
||||
onClearSelection,
|
||||
@@ -105,6 +109,9 @@ export function EditorControls({
|
||||
}: EditorControlsProps): React.JSX.Element {
|
||||
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
|
||||
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
|
||||
const selectedNode =
|
||||
selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null;
|
||||
const transformValues = getTransformValues(selectedNode ?? null);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -155,7 +162,10 @@ export function EditorControls({
|
||||
aria-pressed={transformMode === mode}
|
||||
>
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
<span className="editor-transform-label">
|
||||
<span>{label}</span>
|
||||
<small>{transformValues[mode]}</small>
|
||||
</span>
|
||||
<kbd>{shortcut}</kbd>
|
||||
</button>
|
||||
))}
|
||||
@@ -257,6 +267,20 @@ export function EditorControls({
|
||||
{viewModeLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<label className="editor-checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={lockTerrainSelection}
|
||||
onChange={(event) =>
|
||||
onLockTerrainSelectionChange(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>Lock terrain</strong>
|
||||
<small>Keep terrain visible but ignore terrain clicks</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -329,6 +353,42 @@ export function EditorControls({
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||
}
|
||||
|
||||
function formatVector(values: readonly [number, number, number]): string {
|
||||
return `X ${formatNumber(values[0])} · Y ${formatNumber(values[1])} · Z ${formatNumber(values[2])}`;
|
||||
}
|
||||
|
||||
function formatRotation(values: readonly [number, number, number]): string {
|
||||
const degrees = values.map((value) => (value * 180) / Math.PI) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
|
||||
return `X ${formatNumber(degrees[0])}° · Y ${formatNumber(degrees[1])}° · Z ${formatNumber(degrees[2])}°`;
|
||||
}
|
||||
|
||||
function getTransformValues(
|
||||
node: MapNode | null,
|
||||
): Record<TransformMode, string> {
|
||||
if (!node) {
|
||||
return {
|
||||
translate: "No selection",
|
||||
rotate: "No selection",
|
||||
scale: "No selection",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
translate: formatVector(node.position),
|
||||
rotate: formatRotation(node.rotation),
|
||||
scale: formatVector(node.scale),
|
||||
};
|
||||
}
|
||||
|
||||
interface JsonPreviewLine {
|
||||
number: number;
|
||||
content: string;
|
||||
|
||||
@@ -3,9 +3,19 @@ import { Grid, 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 {
|
||||
normalizeMapScale,
|
||||
useTerrainSnappedPosition,
|
||||
} from "@/hooks/three/useTerrainHeight";
|
||||
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
||||
import {
|
||||
isEditorVisibleMapNode,
|
||||
getTerrainMapNode,
|
||||
} from "@/utils/map/mapRuntimeClassification";
|
||||
import { getVegetationScaleMultiplier } from "@/world/vegetation/vegetationConfig";
|
||||
|
||||
interface EditorMapProps {
|
||||
sceneData: SceneData;
|
||||
@@ -15,6 +25,7 @@ interface EditorMapProps {
|
||||
hoveredNodeIndex: number | null;
|
||||
onHoverNode: (index: number | null) => void;
|
||||
transformMode: TransformMode;
|
||||
lockTerrainSelection: boolean;
|
||||
onTransformStart: () => void;
|
||||
onTransformEnd: () => void;
|
||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||
@@ -138,6 +149,7 @@ export function EditorMap({
|
||||
hoveredNodeIndex,
|
||||
onHoverNode,
|
||||
transformMode,
|
||||
lockTerrainSelection,
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
onNodeTransform,
|
||||
@@ -169,6 +181,13 @@ export function EditorMap({
|
||||
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
|
||||
null,
|
||||
);
|
||||
const terrainNode = getTerrainMapNode(sceneData.mapNodes);
|
||||
const terrainNodeIndex = terrainNode
|
||||
? sceneData.mapNodes.indexOf(terrainNode)
|
||||
: -1;
|
||||
const selectedNode =
|
||||
selectedNodeIndex !== null ? sceneData.mapNodes[selectedNodeIndex] : null;
|
||||
const selectedModelName = selectedNode?.name ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNodeIndex !== null) {
|
||||
@@ -203,7 +222,28 @@ export function EditorMap({
|
||||
onSelectNode(null);
|
||||
}}
|
||||
>
|
||||
{terrainNode ? (
|
||||
<EditorTerrainNode
|
||||
index={terrainNodeIndex}
|
||||
node={terrainNode}
|
||||
isSelected={selectedNodeIndex === terrainNodeIndex}
|
||||
isHovered={hoveredNodeIndex === terrainNodeIndex}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
objectsMapRef={objectsMapRef}
|
||||
onSelectNode={onSelectNode}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
onHoverNode={onHoverNode}
|
||||
/>
|
||||
) : null}
|
||||
{sceneData.mapNodes.map((node, index) => {
|
||||
if (!isEditorVisibleMapNode(node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedModelName && node.name !== selectedModelName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelUrl = sceneData.models.get(node.name);
|
||||
|
||||
if (modelUrl) {
|
||||
@@ -265,14 +305,23 @@ function EditorModelNode({
|
||||
modelUrl: string;
|
||||
}) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const snappedPosition = useTerrainSnappedPosition(node.position);
|
||||
const vegetationScaleMultiplier = getVegetationScaleMultiplier(node.name);
|
||||
const normalizedScale = vegetationScaleMultiplier
|
||||
? ([
|
||||
vegetationScaleMultiplier,
|
||||
vegetationScaleMultiplier,
|
||||
vegetationScaleMultiplier,
|
||||
] satisfies MapNode["scale"])
|
||||
: normalizeMapScale(node.scale);
|
||||
const originalMaterialsRef = useRef(
|
||||
new Map<THREE.Mesh, THREE.Material | THREE.Material[]>(),
|
||||
);
|
||||
const { scene } = useLoggedGLTF(modelUrl, {
|
||||
scope: "EditorMap.EditorModelNode",
|
||||
position: node.position,
|
||||
position: snappedPosition,
|
||||
rotation: node.rotation,
|
||||
scale: node.scale,
|
||||
scale: normalizedScale,
|
||||
});
|
||||
const sceneInstance = useClonedObject(scene);
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
@@ -281,7 +330,16 @@ function EditorModelNode({
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
);
|
||||
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
|
||||
useRegisteredEditorNode(
|
||||
groupRef,
|
||||
index,
|
||||
{
|
||||
...node,
|
||||
position: snappedPosition,
|
||||
scale: normalizedScale,
|
||||
},
|
||||
objectsMapRef,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupRef.current) return;
|
||||
@@ -338,11 +396,42 @@ function EditorModelNode({
|
||||
<primitive
|
||||
ref={groupRef}
|
||||
object={sceneInstance}
|
||||
position={snappedPosition}
|
||||
rotation={node.rotation}
|
||||
scale={normalizedScale}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorTerrainNode({
|
||||
index,
|
||||
node,
|
||||
lockTerrainSelection,
|
||||
objectsMapRef,
|
||||
onSelectNode,
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
}: EditorNodeCommonProps & { lockTerrainSelection: boolean }) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
index,
|
||||
onSelectNode,
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
);
|
||||
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={node.position}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
{...(lockTerrainSelection ? {} : pointerHandlers)}
|
||||
>
|
||||
<TerrainModel receiveShadow visible />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,22 +446,33 @@ function EditorFallbackNode({
|
||||
onHoverNode,
|
||||
}: EditorNodeCommonProps) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
const snappedPosition = useTerrainSnappedPosition(node.position);
|
||||
const normalizedScale = normalizeMapScale(node.scale);
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
index,
|
||||
onSelectNode,
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
);
|
||||
useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
|
||||
useRegisteredEditorNode(
|
||||
meshRef,
|
||||
index,
|
||||
{
|
||||
...node,
|
||||
position: snappedPosition,
|
||||
scale: normalizedScale,
|
||||
},
|
||||
objectsMapRef,
|
||||
);
|
||||
|
||||
const color = getNodeHighlightColor(isSelected, isHovered) ?? "#6f6f6f";
|
||||
|
||||
return (
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
position={node.position}
|
||||
position={snappedPosition}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
scale={normalizedScale}
|
||||
{...pointerHandlers}
|
||||
>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
|
||||
@@ -21,6 +21,7 @@ interface EditorSceneProps {
|
||||
hoveredNodeIndex: number | null;
|
||||
onHoverNode: (index: number | null) => void;
|
||||
transformMode: TransformMode;
|
||||
lockTerrainSelection: boolean;
|
||||
onTransformModeChange: (mode: TransformMode) => void;
|
||||
onTransformStart: () => void;
|
||||
onTransformEnd: () => void;
|
||||
@@ -40,6 +41,7 @@ export function EditorScene({
|
||||
hoveredNodeIndex,
|
||||
onHoverNode,
|
||||
transformMode,
|
||||
lockTerrainSelection,
|
||||
onTransformModeChange,
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
@@ -126,6 +128,7 @@ export function EditorScene({
|
||||
hoveredNodeIndex={hoveredNodeIndex}
|
||||
onHoverNode={onHoverNode}
|
||||
transformMode={transformMode}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
onTransformStart={onTransformStart}
|
||||
onTransformEnd={onTransformEnd}
|
||||
onNodeTransform={onNodeTransform}
|
||||
|
||||
Reference in New Issue
Block a user