Skip to content

Commit c7adf31

Browse files
feat: implement terrain editor with node-based graph system
- Added TerrainEditor component for visual terrain manipulation. - Integrated React Flow for node-based graph representation. - Created terrain editor store to manage editor state and terrain ID. - Developed terrain store for terrain resource management and graph updates. - Introduced terrain node types: input, output, perlin, and voronoi. - Implemented terrain graph evaluation to generate heightmaps. - Added utility functions for grid mesh generation and normal map baking. - Created Perlin and Voronoi noise evaluation functions for terrain modification. - Established context menu for adding terrain nodes in the editor. - Enhanced UI with drag inputs for node parameters and context-sensitive actions.
1 parent 6bbdf7a commit c7adf31

File tree

20 files changed

+897
-2
lines changed

20 files changed

+897
-2
lines changed

ai-log/agent-conversations/90.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,5 @@ Rendering:
3434
- These terrain textures are ALWAYS applied (unless in wireframe mode), and the objects material is additionally added to the renderer, so it has two materials in it if users add a custom material, but in any case that texture stuff needs to always be included!
3535

3636
Notes:
37-
- I already have a working react-flow based shader editor,
37+
- I already have a working react-flow based shader editor, you can look up there how i solved many problems, like adding items via a context menu etc.
38+
- For simplicity start with two primitive nodes: Perlin noise and Voronoi. Allow users to tweak these in the node.

src/app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
"use client";
12
import { StoreProvider } from '@/stores';
23
import { ShortcutProvider } from '@/components/shortcut-provider';
34
import EditorLayout from '@/features/layout/components/editor-layout';
45

6+
57
export default function Home() {
68
return (
79
<StoreProvider>

src/features/layout/components/editor-layout.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import UVEditor from '@/features/uv-editor/components/uv-editor';
2222
import { useUVEditorStore } from '@/stores/uv-editor-store';
2323
import { AnimatePresence } from "motion/react"
2424

25+
import TerrainEditor from '@/features/terrain/components/terrain-editor'
26+
import { useTerrainEditorStore } from '@/stores/terrain-editor-store';
27+
2528
const EditorLayout: React.FC = () => {
2629
const shaderOpen = useShaderEditorStore((s) => s.open);
2730
const setShaderOpen = useShaderEditorStore((s) => s.setOpen);
@@ -100,6 +103,9 @@ const EditorLayout: React.FC = () => {
100103
{/* UV Editor Panel */}
101104
<UVEditor open={uvOpen} onOpenChange={setUVOpen} />
102105

106+
<TerrainEditor open={useTerrainEditorStore((s) => s.open)} onOpenChange={() => { }} />
107+
108+
103109
{/* Timeline overlays inside the viewport region */}
104110
{timelineOpen && <Timeline />}
105111
</div>

src/features/menu/components/menu-bar.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useRegisterShortcuts } from '@/components/shortcut-provider';
2323
import { Euler, Matrix4, Quaternion, Vector3 } from 'three/webgpu';
2424
import AddObjectMenu from '@/features/shared/add-object-menu';
2525
import { useTextStore } from '@/stores/text-store';
26+
import { useTerrainStore } from '@/stores/terrain-store';
2627

2728
type Props = { onOpenShaderEditor?: () => void };
2829
const MenuBar: React.FC<Props> = ({ onOpenShaderEditor }) => {
@@ -393,6 +394,14 @@ const MenuBar: React.FC<Props> = ({ onOpenShaderEditor }) => {
393394
sceneStore.selectObject(id);
394395
if (useSelectionStore.getState().selection.viewMode === 'object') useSelectionStore.getState().selectObjects([id]);
395396
}}
397+
onCreateTerrain={() => {
398+
const res = useTerrainStore.getState().createTerrain();
399+
// createTerrain returns { terrainId, objectId }
400+
if (res?.objectId) {
401+
sceneStore.selectObject(res.objectId);
402+
if (useSelectionStore.getState().selection.viewMode === 'object') useSelectionStore.getState().selectObjects([res.objectId]);
403+
}
404+
}}
396405
/>
397406

398407
{/* View (placeholders) */}

src/features/properties-panel/components/tabs/inspector-panel.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useForceFieldStore } from '@/stores/force-field-store';
1414
import { useFluidStore } from '@/stores/fluid-store';
1515
import { useTextStore, useTextResource } from '@/stores/text-store';
1616
import { useMetaballStore } from '@/stores/metaball-store';
17+
import { useTerrainStore } from '@/stores/terrain-store';
1718

1819
const Label: React.FC<{ label: string } & React.HTMLAttributes<HTMLDivElement>> = ({ label, children, className = '', ...rest }) => (
1920
<div className={`text-xs text-gray-400 ${className}`} {...rest}>
@@ -31,6 +32,8 @@ export const InspectorPanel: React.FC = () => {
3132
const selected = useSelectedObject();
3233
const scene = useSceneStore();
3334
const activeClipId = useAnimationStore((s) => s.activeClipId);
35+
const terrains = useTerrainStore();
36+
3437

3538
if (!selected) {
3639
return <div className="p-3 text-xs text-gray-500">No object selected.</div>;
@@ -147,6 +150,14 @@ export const InspectorPanel: React.FC = () => {
147150
<div>
148151
<div className="text-[11px] uppercase tracking-wide text-gray-400 mb-1">Object Data</div>
149152
<ObjectDataSection objectId={selected.id} />
153+
<div className="mt-2">
154+
<button
155+
className="px-2 py-1 rounded border border-white/10 hover:bg-white/10 text-xs"
156+
onClick={() => terrains.createTerrain({ name: 'Terrain' })}
157+
>
158+
Create Terrain
159+
</button>
160+
</div>
150161
</div>
151162
)}
152163

src/features/properties-panel/components/tabs/sections/object-data-section.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { useSceneStore } from '@/stores/scene-store';
55
import { useGeometryStore } from '@/stores/geometry-store';
66
import { MaterialSection } from './material-section';
77
import { ShadingSection } from './shading-section';
8+
import { useTerrainStore } from '@/stores/terrain-store';
9+
import { useTerrainEditorStore } from '@/stores/terrain-editor-store';
10+
import { DragInput } from '@/components/drag-input';
11+
import Switch from '@/components/switch';
812

913
type Props = { objectId: string };
1014

@@ -24,8 +28,55 @@ export const ObjectDataSection: React.FC<Props> = ({ objectId }) => {
2428
geo.updateMesh(mesh.id, (m) => { m.materialId = id; });
2529
};
2630

31+
// Determine if this mesh belongs to a terrain resource
32+
const tStore = useTerrainStore();
33+
const terrainEntry = Object.values(tStore.terrains).find((t) => t.meshId === obj.meshId);
34+
const te = useTerrainEditorStore();
35+
2736
return (
2837
<div className="space-y-2">
38+
{terrainEntry && (
39+
<div className="bg-white/5 border border-white/10 rounded p-2 space-y-2">
40+
<div className="text-[11px] uppercase tracking-wide text-gray-400 mb-1">Terrain</div>
41+
<div className="grid grid-cols-3 gap-2 mb-2">
42+
<div>
43+
<div className="text-xs text-gray-400">Width (X)</div>
44+
<DragInput compact value={terrainEntry.width} step={0.1} precision={2} onChange={(v) => tStore.updateTerrain(terrainEntry.id, (t) => { t.width = Math.max(0.1, v); })} />
45+
</div>
46+
<div>
47+
<div className="text-xs text-gray-400">Depth (Z)</div>
48+
<DragInput compact value={terrainEntry.height} step={0.1} precision={2} onChange={(v) => tStore.updateTerrain(terrainEntry.id, (t) => { t.height = Math.max(0.1, v); })} />
49+
</div>
50+
<div>
51+
<div className="text-xs text-gray-400">Height Scale</div>
52+
<DragInput compact value={terrainEntry.heightScale || 3.0} step={0.1} precision={2} onChange={(v) => tStore.updateTerrain(terrainEntry.id, (t) => { t.heightScale = Math.max(0.0, v); })} />
53+
</div>
54+
</div>
55+
<div className="grid grid-cols-2 gap-2">
56+
<div>
57+
<div className="text-xs text-gray-400">Vertex Res X</div>
58+
<DragInput compact value={terrainEntry.vertexResolution.x} step={1} precision={0} onChange={(v) => tStore.updateTerrain(terrainEntry.id, (t) => { t.vertexResolution.x = Math.max(2, Math.round(v)); })} />
59+
</div>
60+
<div>
61+
<div className="text-xs text-gray-400">Vertex Res Y</div>
62+
<DragInput compact value={terrainEntry.vertexResolution.y} step={1} precision={0} onChange={(v) => tStore.updateTerrain(terrainEntry.id, (t) => { t.vertexResolution.y = Math.max(2, Math.round(v)); })} />
63+
</div>
64+
<div>
65+
<div className="text-xs text-gray-400">Texture W</div>
66+
<DragInput compact value={terrainEntry.textureResolution.width} step={16} precision={0} onChange={(v) => tStore.updateTerrain(terrainEntry.id, (t) => { t.textureResolution.width = Math.max(16, Math.round(v)); })} />
67+
</div>
68+
<div>
69+
<div className="text-xs text-gray-400">Texture H</div>
70+
<DragInput compact value={terrainEntry.textureResolution.height} step={16} precision={0} onChange={(v) => tStore.updateTerrain(terrainEntry.id, (t) => { t.textureResolution.height = Math.max(16, Math.round(v)); })} />
71+
</div>
72+
</div>
73+
<div className="flex gap-2">
74+
<button className="px-2 py-1 rounded border border-white/10 hover:bg-white/10" onClick={() => te.openFor(terrainEntry.id)}>Open Terrain Editor</button>
75+
<button className="px-2 py-1 rounded border border-white/10 hover:bg-white/10" onClick={() => tStore.regenerate(terrainEntry.id)}>Regenerate</button>
76+
</div>
77+
</div>
78+
)}
79+
2980
<MaterialSection materialId={mesh?.materialId} onAssignMaterial={assignMaterial} />
3081
{mesh && <ShadingSection meshId={mesh.id} />}
3182
</div>

src/features/shared/add-object-menu.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type Props = {
2020
onAddParticleSystem: () => void;
2121
onAddFluidSystem?: () => void;
2222
onAddMetaball?: () => void;
23+
onCreateTerrain?: () => void;
2324
};
2425

2526
const AddObjectMenu: React.FC<Props> = ({
@@ -37,6 +38,7 @@ const AddObjectMenu: React.FC<Props> = ({
3738
onAddParticleSystem,
3839
onAddFluidSystem,
3940
onAddMetaball,
41+
onCreateTerrain,
4042
}) => {
4143
const closeIfControlled = () => { if (typeof onOpenChange === 'function') onOpenChange(false); };
4244

@@ -82,6 +84,7 @@ const AddObjectMenu: React.FC<Props> = ({
8284
<Menu.Popup className="min-w-44 rounded border border-white/10 bg-[#0b0e13]/95 shadow-lg py-1 text-xs z-90" style={{ zIndex: 10050 }}>
8385
<Menu.Item className="w-full text-left px-3 py-1.5 hover:bg-white/10 text-gray-200" onClick={() => { onCreateShape('cube'); closeIfControlled(); }}>Cube</Menu.Item>
8486
<Menu.Item className="w-full text-left px-3 py-1.5 hover:bg-white/10 text-gray-200" onClick={() => { onCreateShape('plane'); closeIfControlled(); }}>Plane</Menu.Item>
87+
<Menu.Item className="w-full text-left px-3 py-1.5 hover:bg-white/10 text-gray-200" onClick={() => { if (onCreateTerrain) onCreateTerrain(); closeIfControlled(); }}>Terrain</Menu.Item>
8588
<Menu.Item className="w-full text-left px-3 py-1.5 hover:bg-white/10 text-gray-200" onClick={() => { onCreateShape('cylinder'); closeIfControlled(); }}>Cylinder</Menu.Item>
8689
<Menu.Item className="w-full text-left px-3 py-1.5 hover:bg-white/10 text-gray-200" onClick={() => { onCreateShape('cone'); closeIfControlled(); }}>Cone</Menu.Item>
8790
<Menu.Item className="w-full text-left px-3 py-1.5 hover:bg-white/10 text-gray-200" onClick={() => { onCreateShape('uvsphere'); closeIfControlled(); }}>UV Sphere</Menu.Item>

0 commit comments

Comments
 (0)