diff --git a/src/components/editor/EditorControls.tsx b/src/components/editor/EditorControls.tsx
index e9b099d..51d960a 100644
--- a/src/components/editor/EditorControls.tsx
+++ b/src/components/editor/EditorControls.tsx
@@ -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}
>
- {label}
+
+ {label}
+ {transformValues[mode]}
+
{shortcut}
))}
@@ -257,6 +267,20 @@ export function EditorControls({
{viewModeLabel}
)}
+
+
(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 {
+ 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;
diff --git a/src/components/editor/scene/EditorMap.tsx b/src/components/editor/scene/EditorMap.tsx
index b6eeedc..4676812 100644
--- a/src/components/editor/scene/EditorMap.tsx
+++ b/src/components/editor/scene/EditorMap.tsx
@@ -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(
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 ? (
+
+ ) : 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(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(),
);
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({
+ );
+}
+
+function EditorTerrainNode({
+ index,
+ node,
+ lockTerrainSelection,
+ objectsMapRef,
+ onSelectNode,
+ isSelectionLocked,
+ onHoverNode,
+}: EditorNodeCommonProps & { lockTerrainSelection: boolean }) {
+ const groupRef = useRef(null);
+ const pointerHandlers = createEditorNodePointerHandlers(
+ index,
+ onSelectNode,
+ isSelectionLocked,
+ onHoverNode,
+ );
+ useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
+
+ return (
+
+ {...(lockTerrainSelection ? {} : pointerHandlers)}
+ >
+
+
);
}
@@ -357,22 +446,33 @@ function EditorFallbackNode({
onHoverNode,
}: EditorNodeCommonProps) {
const meshRef = useRef(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 (
diff --git a/src/components/editor/scene/EditorScene.tsx b/src/components/editor/scene/EditorScene.tsx
index c9d585c..2c141df 100644
--- a/src/components/editor/scene/EditorScene.tsx
+++ b/src/components/editor/scene/EditorScene.tsx
@@ -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}
diff --git a/src/index.css b/src/index.css
index cd732fe..8f3cbb6 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1244,7 +1244,7 @@ canvas {
.editor-transform-button {
display: grid;
- grid-template-columns: 18px 1fr auto;
+ grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
width: 100%;
@@ -1264,6 +1264,30 @@ canvas {
transform 160ms ease;
}
+.editor-transform-label {
+ display: grid;
+ min-width: 0;
+ gap: 2px;
+}
+
+.editor-transform-label span,
+.editor-transform-label small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.editor-transform-label small {
+ color: #8f8f8f;
+ font-size: 0.64rem;
+ font-weight: 620;
+ letter-spacing: 0;
+}
+
+.editor-transform-button.active .editor-transform-label small {
+ color: #555555;
+}
+
.editor-transform-button.active {
background: #ffffff;
color: #050505;
@@ -1378,6 +1402,42 @@ canvas {
color: #050505;
}
+.editor-checkbox-row {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ gap: 10px;
+ margin-top: 9px;
+ padding: 10px 11px;
+ background: #101010;
+ border: 1px solid #242424;
+ border-radius: 14px;
+ color: #f2f2f2;
+ cursor: pointer;
+}
+
+.editor-checkbox-row input {
+ width: 16px;
+ height: 16px;
+ accent-color: #ffffff;
+}
+
+.editor-checkbox-row span {
+ display: grid;
+ gap: 2px;
+}
+
+.editor-checkbox-row strong {
+ font-size: 0.82rem;
+ line-height: 1.1;
+}
+
+.editor-checkbox-row small {
+ color: #8f8f8f;
+ font-size: 0.68rem;
+ line-height: 1.2;
+}
+
.editor-selected-info {
display: grid;
grid-template-columns: 17px 1fr auto;
diff --git a/src/pages/editor/page.tsx b/src/pages/editor/page.tsx
index be4d228..b25f795 100644
--- a/src/pages/editor/page.tsx
+++ b/src/pages/editor/page.tsx
@@ -141,6 +141,7 @@ export function EditorPage(): React.JSX.Element {
useState("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false);
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
+ const [lockTerrainSelection, setLockTerrainSelection] = useState(true);
const [sceneLoadingState, setSceneLoadingState] = useState(
{
...INITIAL_SCENE_LOADING_STATE,
@@ -194,6 +195,22 @@ export function EditorPage(): React.JSX.Element {
setIsSelectionLocked((locked) => !locked);
}, []);
+ const handleTerrainSelectionLockChange = useCallback(
+ (locked: boolean) => {
+ setLockTerrainSelection(locked);
+
+ if (!locked) return;
+
+ setSelectedNodeIndex((currentIndex) => {
+ if (currentIndex === null) return null;
+
+ const selectedNode = sceneData?.mapNodes[currentIndex];
+ return selectedNode?.name === "terrain" ? null : currentIndex;
+ });
+ },
+ [sceneData],
+ );
+
const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index);
}, []);
@@ -351,6 +368,7 @@ export function EditorPage(): React.JSX.Element {
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
+ lockTerrainSelection={lockTerrainSelection}
onTransformModeChange={handleTransformModeChange}
onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd}
@@ -378,6 +396,8 @@ export function EditorPage(): React.JSX.Element {
? sceneData.mapNodes[selectedNodeIndex].name || null
: null
}
+ lockTerrainSelection={lockTerrainSelection}
+ onLockTerrainSelectionChange={handleTerrainSelectionLockChange}
isSelectionLocked={isSelectionLocked}
onSelectionLockToggle={handleSelectionLockToggle}
onClearSelection={handleClearSelection}
diff --git a/src/utils/map/mapRuntimeClassification.ts b/src/utils/map/mapRuntimeClassification.ts
new file mode 100644
index 0000000..52e021c
--- /dev/null
+++ b/src/utils/map/mapRuntimeClassification.ts
@@ -0,0 +1,39 @@
+import type { MapNode } from "@/types/editor/editor";
+import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig";
+
+const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking", "terrain"]);
+const RUNTIME_VEGETATION_NODE_NAMES = new Set([
+ "arbre",
+ "buisson",
+ "champdeble",
+ "champdesoja",
+ "champsdetournesol",
+ "sapin",
+]);
+
+export function isRuntimeStructureMapNode(name: string): boolean {
+ return MAP_STRUCTURE_NODE_NAMES.has(name);
+}
+
+export function isRuntimeSingleMapNode(node: MapNode): boolean {
+ if (isRuntimeStructureMapNode(node.name)) {
+ return false;
+ }
+
+ if (node.type === "Mesh") {
+ return false;
+ }
+
+ return (
+ !RUNTIME_VEGETATION_NODE_NAMES.has(node.name) &&
+ !isInstancedMapNodeName(node.name)
+ );
+}
+
+export function isEditorVisibleMapNode(node: MapNode): boolean {
+ return !isRuntimeStructureMapNode(node.name) && node.type !== "Mesh";
+}
+
+export function getTerrainMapNode(nodes: readonly MapNode[]): MapNode | null {
+ return nodes.find((node) => node.name === "terrain") ?? null;
+}
diff --git a/src/world/vegetation/vegetationConfig.ts b/src/world/vegetation/vegetationConfig.ts
index 9249439..19d8ce8 100644
--- a/src/world/vegetation/vegetationConfig.ts
+++ b/src/world/vegetation/vegetationConfig.ts
@@ -62,3 +62,11 @@ export const INSTANCED_MAP_EXCEPTIONS = new Set([
"blocking",
"terrain",
]);
+
+export function getVegetationScaleMultiplier(name: string): number | null {
+ const config = Object.values(VEGETATION_TYPES).find(
+ (vegetationConfig) => vegetationConfig.mapName === name,
+ );
+
+ return config?.scaleMultiplier ?? null;
+}