import React, { useEffect, useState } from 'react';
import { Cog, Dices, Eye, RefreshCw } from 'lucide-react';
import SidePanel, { PanelTabs } from './SidePanel.jsx';
import { PANEL_ICONS } from '../icons/panelIcons.jsx';
import { SliderCtl, ToggleRow, SelectRow } from '../controls.jsx';
import { PANEL_META, PANEL_ORDER, panelAvailable, getPanelDisplay } from './panelMeta.js';
export { PANEL_META, PANEL_ORDER, panelAvailable, getPanelDisplay } from './panelMeta.js';
import ImportMapsContent from '../ui/ImportMapsContent.jsx';
import CollapsibleGroup from '../ui/CollapsibleGroup.jsx';
import ControlSection from '../ui/ControlSection.jsx';
import TileMapDebugSection from '../ui/TileMapDebugSection.jsx';
import { TERRAIN_SLIDERS, NOISE_SLIDERS, BIOME_SLIDERS, InfoDot } from './defs.jsx';
import { PRESETS } from '../../engine/presets.js';
import { NOISE_PRESETS } from '../../engine/style/NoisePresets.js';
import { EROSION_PRESETS, EROSION_QUALITY } from '../../engine/terrain/erosion/ErosionPresets.js';
import { formatTimeOfDay } from '../../engine/sky/TimeOfDay.js';
import { APP_VERSION } from '../../constants/app.js';
import { applyExportPreset, EXPORT_PRESET_OPTIONS, getExportPreset } from '../../export/ExportPresetManager.js';
import { hasExportErrors, validateExport } from '../../export/ExportValidator.js';
import PlanetStylePanel from '../PlanetStylePanel.jsx';
import WorldPanelInner from '../ui/WorldPanel.jsx';
import CloudPanelInner from '../ui/CloudPanel.jsx';
import WaterPanelInner from '../ui/WaterPanel.jsx';
import VisualsPanelInner from '../ui/VisualsPanel.jsx';
import PanelResetButton from '../ui/PanelResetButton.jsx';
import EnvironmentPanelInner from '../ui/EnvironmentPanel.jsx';
import PerformanceStats from '../ui/PerformancePanel.jsx';
import PlanetSummaryCard from '../ui/PlanetSummaryCard.jsx';
import { LodPanel, CameraPanel } from '../RightPanels.jsx';
import PerfSettings, { SurfacePropertiesSettings } from './PerfSettings.jsx';
import SurfaceLibraryPanel from '../ui/SurfaceLibraryPanel.jsx';
import NoiseLayersPanel from '../NoiseLayersPanel.jsx';
import SplinesPanel from './SplinesPanel.jsx';
import { AnalysisContent } from './AnalysisPanel.jsx';
import HistoryPanel from './HistoryPanel.jsx';
import { useLiveMetrics } from '../../state/LiveMetricsStore.js';
// ---------------------------------------------------------------- helpers
function SeedRow({ seed, onParam, onRandomizeSeed }) {
const [text, setText] = useState(String(seed));
useEffect(() => { setText(String(seed)); }, [seed]);
const commit = () => {
const v = parseInt(text, 10);
if (Number.isFinite(v)) onParam('seed', v >>> 0);
else setText(String(seed));
};
return (
);
}
const RandomizeTerrainButton = ({ onRandomize }) => (
);
const SURFACE_TABS = [
{ id: 'general', label: 'General Settings' },
{ id: 'textures', label: 'Textures' },
];
// Terrain > Surface: procedural material-render sliders (General Settings) and
// the Surface Library (default texture packs, variants, file overrides,
// sphere preview) live in their own sub-tab (Textures).
function SurfaceTab({ ctx }) {
const [subTab, setSubTab] = useState('general');
useEffect(() => {
const target = ctx.settingsTarget;
if (target?.tabId === 'surface' && target.subTabId && target.subTabId !== subTab) {
setSubTab(target.subTabId);
}
}, [ctx.settingsTarget, subTab]);
return (
<>
{subTab === 'general' && (
)}
{subTab === 'textures' && }
>
);
}
// ---------------------------------------------------------------- panels
function TerrainPanel({ ctx }) {
const [tab, setTab] = useState('shape');
const { params, onParam, worldMode } = ctx;
const isStudio = worldMode === 'studio';
// Erosion lives as a tab here (Tile mode only). Its bake state is shared
// between the tab body and the footer's Bake / Reset buttons.
const erosion = useErosionBake(ctx);
useEffect(() => {
const targetTab = ctx.settingsTarget?.tabId;
if (targetTab && targetTab !== tab) setTab(targetTab);
}, [ctx.settingsTarget?.tabId, tab]);
// Leaving Tile mode hides the Erosion tab — fall back to Shape so we never
// render an unavailable tab.
useEffect(() => {
if (!isStudio && tab === 'erosion') setTab('shape');
}, [isStudio, tab]);
const onErosionTab = isStudio && tab === 'erosion';
const tabs = [
{ id: 'shape', label: 'Shape' },
{ id: 'noise', label: 'Noise' },
{ id: 'surface', label: 'Surface' },
...(isStudio ? [{ id: 'erosion', label: 'Erosion' }] : []),
...(isStudio ? [{ id: 'import', label: 'Import' }] : []),
];
return (
: }>
{tab === 'shape' && (
<>
({ value: key, label: p.label }))}
onChange={ctx.onPreset} info="Global terrain layout preset." />
{TERRAIN_SLIDERS.map((def) => (
onParam(def.key, v)} settingId={`terrain.${def.key}`} />
))}
onParam('edgeFalloffMode', v)} info="Island fades terrain toward the boundary. Mountains preserves the terrain and adds ridged noise around the outer edge." />
>
)}
{tab === 'noise' && (
<>
({ value: key, label: p.label }))}
onChange={ctx.planetStyleProps.onNoisePreset} info="Baseline noise shape configuration." />
{NOISE_SLIDERS.map((def) => (
onParam(def.key, v)} settingId={`terrain.${def.key}`} />
))}
>
)}
{tab === 'surface' && }
{onErosionTab && }
{tab === 'import' && isStudio && }
{!onErosionTab && (
ctx.onResetPanel?.('terrain')} settingId="terrain.reset" />
)}
);
}
function WorldPanel({ ctx }) {
return (
{ctx.worldMode === 'studio' && }
ctx.onResetPanel?.('world')} settingId="world.reset" />
);
}
function PlanetPanel({ ctx }) {
const isPlanet = ctx.worldMode === 'planet';
const { title, desc } = getPanelDisplay('planet', ctx.worldMode);
return (
{isPlanet && (
<>
>
)}
{!isPlanet && (
)}
ctx.onResetPanel?.('planet')} settingId="planet.reset" />
);
}
const EROSION_MAIN = [
{ key: 'erosionStrength', label: 'Strength', min: 0, max: 1, step: 0.01, digits: 2, info: 'Master blend of the eroded result over the base terrain (0 = none).' },
{ key: 'erosionDroplets', label: 'Droplets', min: 0, max: 200000, step: 5000, digits: 0, info: 'Rain droplets in the hydraulic pass. More = deeper valleys/ravines, slower bake.' },
{ key: 'erosionLifetime', label: 'Droplet Lifetime', min: 5, max: 80, step: 1, digits: 0, info: 'Max steps each droplet travels before evaporating.' },
{ key: 'erosionSeed', label: 'Seed', min: 1, max: 999, step: 1, digits: 0, info: 'Deterministic random seed for droplet spawn positions.' },
];
const EROSION_ADVANCED = [
{ key: 'erosionRadius', label: 'Erosion Radius', min: 1, max: 6, step: 1, digits: 0, info: 'Brush radius for material removal (larger = smoother channels).' },
{ key: 'erosionErosionRate', label: 'Erosion Strength', min: 0, max: 1, step: 0.01, digits: 2, info: 'How aggressively fast-moving water carves terrain.' },
{ key: 'erosionDeposition', label: 'Deposition', min: 0, max: 1, step: 0.01, digits: 2, info: 'How readily carried sediment settles back out.' },
{ key: 'erosionSedimentCapacity', label: 'Sediment Capacity', min: 1, max: 12, step: 0.5, digits: 1, info: 'How much material a droplet can carry before depositing.' },
{ key: 'erosionEvaporation', label: 'Evaporation', min: 0, max: 0.1, step: 0.005, digits: 3, info: 'Water lost per step (higher = shorter drainage lines).' },
{ key: 'erosionGravity', label: 'Gravity', min: 1, max: 12, step: 0.5, digits: 1, info: 'Downhill acceleration of droplets.' },
{ key: 'erosionInertia', label: 'Inertia', min: 0, max: 0.95, step: 0.01, digits: 2, info: 'How much droplets keep their direction vs. follow the slope.' },
{ key: 'erosionThermalStrength', label: 'Thermal Strength', min: 0, max: 1, step: 0.01, digits: 2, info: 'Strength of loose-material sliding off steep slopes.' },
{ key: 'erosionThermalIterations', label: 'Thermal Iterations', min: 0, max: 100, step: 5, digits: 0, info: 'Relaxation passes for the thermal (talus) erosion.' },
{ key: 'erosionTalus', label: 'Talus Angle', min: 0.1, max: 2, step: 0.05, digits: 2, info: 'Slope steepness (relative to cell size) above which material slides.' },
{ key: 'erosionSmoothing', label: 'Smoothing', min: 0, max: 1, step: 0.01, digits: 2, info: 'Final low-pass blend to soften noise.' },
];
const EROSION_PHASE_LABEL = {
sampling: 'Sampling base terrain…',
hydraulic: 'Hydraulic pass',
thermal: 'Thermal pass',
done: 'Updating terrain…',
starting: 'Starting…',
};
// Erosion bake state, shared between the Terrain panel's Erosion tab body and
// its footer (bake / reset live in the footer, the body shows the controls).
function useErosionBake(ctx) {
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState(0);
const [phase, setPhase] = useState('');
const [baked, setBaked] = useState(!!ctx.erosionHasResult);
// Keep the "baked" flag in sync with the engine after undo / redo (which can
// add or drop the baked result without going through bake/reset here).
useEffect(() => { setBaked(!!ctx.erosionHasResult); }, [ctx.erosionHasResult]);
const bake = async () => {
setBusy(true); setProgress(0); setPhase('starting');
try {
const ok = await ctx.onErosionBake((p, ph) => { setProgress(p); setPhase(ph); });
if (ok) setBaked(true);
} finally { setBusy(false); }
};
const reset = () => { ctx.onErosionReset(); setBaked(false); setProgress(0); setPhase(''); };
return { busy, progress, phase, baked, bake, reset };
}
function ErosionTabFooter({ erosion }) {
const { busy, progress, phase, baked, bake, reset } = erosion;
const pct = Math.round(progress * 100);
return (
);
}
function ErosionTabContent({ ctx, erosion }) {
const { params, onParam } = ctx;
const { baked } = erosion;
// Editing any knob detaches from the named preset (→ Custom) without clobbering.
const setKnob = (key, v) => {
onParam(key, v);
if (params.erosionPreset !== 'custom') onParam('erosionPreset', 'custom');
};
// Open the Advanced section when search navigates to one of its knobs.
const advTarget = EROSION_ADVANCED.some((d) => ctx.settingsTarget?.settingId === `erosion.${d.key}`);
return (
<>
onParam('erosionEnabled', v)}
settingId="erosion.erosionEnabled"
info="Apply the baked erosion to the terrain. Toggle to compare Before / After. Disabled until you bake." />
{!baked && (
No erosion baked yet. Pick a preset, then press Bake Erosion. The simulation runs in the background.
)}
({ value: key, label: p.label }))}
onChange={(v) => ctx.onErosionPreset(v)} info="Erosion style. Editing any slider switches to Custom." />
({ value: key, label: `${q.label} (${q.res}²)` }))}
onChange={(v) => onParam('erosionQuality', v)} info="Grid resolution of the bake. Higher = finer channels but slower." />
{EROSION_MAIN.map((def) => (
setKnob(def.key, v)} settingId={`erosion.${def.key}`} />
))}
{EROSION_ADVANCED.map((def) => (
setKnob(def.key, v)} settingId={`erosion.${def.key}`} />
))}
Erosion also produces flow / rock / sediment / slope masks used by texturing & props (wiring in progress). Exports already include the eroded terrain. Bake / reset can be reverted with Ctrl+Z.
>
);
}
function BiomesPanel({ ctx }) {
const { params, onParam } = ctx;
return (
{BIOME_SLIDERS.map((def) => (
onParam(def.key, v)} settingId={`biomes.${def.key}`} />
))}
onParam('biomeDebug', v)}
settingId="biomes.biomeDebug"
info="Color-code biomes directly on the terrain surface for inspection." />
ctx.onResetPanel?.('biomes')} settingId="biomes.reset" />
);
}
function WaterPanel({ ctx }) {
const { stats } = useLiveMetrics(ctx.liveMetrics);
return (
ctx.onResetPanel?.('water')}
onExportWaterMasks={ctx.onExportWaterMasks}
onApplyWaterBaselineScene={ctx.onApplyWaterBaselineScene}
onCaptureWaterBaseline={ctx.onCaptureWaterBaseline}
settingsTarget={ctx.settingsTarget}
/>
);
}
const PROP_SLIDERS = {
propsDensity: { label: 'Density', min: 0, max: 2, step: 0.05, digits: 2 },
propsGrass: { label: 'Grass Scale', min: 0.05, max: 2, step: 0.05, digits: 2 },
propsRocks: { label: 'Rock Mix', min: 0, max: 2, step: 0.05, digits: 2 },
propsRockScale: { label: 'Rock Scale', min: 0.05, max: 2.5, step: 0.05, digits: 2 },
propsWind: { label: 'Wind', min: 0, max: 1.5, step: 0.05, digits: 2 },
propsWindSpeed: { label: 'Animation Speed', min: 0, max: 4, step: 0.05, digits: 2 },
propsGust: { label: 'Gust Motion', min: 0, max: 1.5, step: 0.05, digits: 2 },
propsFlowers: { label: 'Flower Mix', min: 0, max: 1, step: 0.01, digits: 2 },
propsCullDistance: { label: 'Cull Distance', min: 120, max: 1800, step: 20, digits: 0, unit: ' u' },
propsLodDistance: { label: 'LOD Distance', min: 60, max: 900, step: 10, digits: 0, unit: ' u' },
};
function PropsPanel({ ctx }) {
const { params, onParam, worldMode } = ctx;
const enabled = !!params.propsEnabled;
return (
onParam('propsEnabled', v)}
info="Scatter lightweight procedural grass patches, flowers and terrain-matched rocks in Tile, Infinite World, and Planet modes." />
{enabled && (
<>
onParam('propsDensity', v)} />
onParam('propsFlowers', v)} />
onParam('propsRocks', v)} />
onParam('propsGrass', v)} />
onParam('propsRockScale', v)} />
onParam('propsWind', v)} />
onParam('propsWindSpeed', v)} />
onParam('propsGust', v)} />
onParam('propsCullDistance', v)} />
onParam('propsLodDistance', v)} />
{worldMode === 'studio'
? 'Studio also reads the props mask painted in Paint Mode.'
: 'This mode uses deterministic procedural scattering from the current seed.'}
>
)}
ctx.onResetPanel?.('props')} settingId="props.reset" />
);
}
function CloudsPanel({ ctx }) {
return (
ctx.onResetPanel?.('clouds')} settingId="clouds.reset" />
);
}
// Shared time-of-day control. `timeOfDay` is a single engine-owned value used
// by the Skybox tab here, the Lighting system and the infinite HUD — never
// duplicated. Owned (surfaced) by the Skybox tab.
function TimeOfDayControl({ timeOfDay, onTimeOfDay, settingId }) {
return (
Time
{formatTimeOfDay(timeOfDay)}
onTimeOfDay(parseFloat(e.target.value))} />
);
}
const SKYBOX_SLIDERS = {
skyboxBrightness: { key: 'skyboxBrightness', label: 'Sky Brightness', min: 0.2, max: 2.5, step: 0.05, digits: 2, info: 'Overall brightness of the sky dome and sun glow.' },
skyboxHaze: { key: 'skyboxHaze', label: 'Horizon Haze', min: 0, max: 1.2, step: 0.05, digits: 2, info: 'Strength of the atmospheric haze band blended around the horizon.' },
skyboxCycleSpeed: { key: 'skyboxCycleSpeed', label: 'Cycle Speed', min: 0.05, max: 12, step: 0.05, digits: 2, unit: 'x', info: 'Day/night animation speed. 1x is one full cycle in about two minutes.' },
};
function SkyboxPanel({ ctx }) {
const { params, onParam } = ctx;
const enabled = params.skyboxEnabled !== false;
return (
onParam('skyboxEnabled', v)}
settingId="skybox.skyboxEnabled"
info="Surround the scene with the procedural sky dome (Tile + Infinite World). When off, a flat backdrop and the manual Lighting sun angles are used." />
onParam('skyboxDayNightCycle', v)}
settingId="skybox.skyboxDayNightCycle"
info="Animate the time of day while the procedural sky is active." />
onParam('skyboxCycleSpeed', v)} settingId="skybox.skyboxCycleSpeed" />
Drives the sky colours, sun position and atmosphere. Shared across the Tile view and the Infinite World.
{enabled && (
onParam('skyboxBrightness', v)} settingId="skybox.skyboxBrightness" />
onParam('skyboxHaze', v)} settingId="skybox.skyboxHaze" />
onParam('skyboxStars', v)}
settingId="skybox.skyboxStars"
info="Show the procedural star field when the sun is below the horizon." />
)}
ctx.onResetPanel?.('skybox')} settingId="skybox.reset" />
);
}
function LightingPanel({ ctx }) {
const { params } = ctx;
const skyOn = params.skyboxEnabled !== false;
return (
{skyOn && (
Time of day and the sky environment are configured in the Skybox tab. While the procedural sky is on, it drives the sun direction and atmosphere. Turn it off to use the manual lighting palette below.
)}
ctx.onResetPanel?.('lighting')} settingId="lighting.reset" />
);
}
function VisualsPanel({ ctx }) {
return (
);
}
function PerformancePanel({ ctx }) {
const { stats } = useLiveMetrics(ctx.liveMetrics);
return (
ctx.onResetPanel?.('performance')} settingId="performance.reset" />
);
}
function DebugPanel({ ctx }) {
const [tab, setTab] = useState('monitor');
const isStudio = ctx.worldMode === 'studio';
const live = useLiveMetrics(ctx.liveMetrics);
useEffect(() => {
const targetTab = ctx.settingsTarget?.tabId;
if (targetTab && targetTab !== tab) setTab(targetTab);
}, [ctx.settingsTarget?.tabId, tab]);
return (
{tab === 'monitor' && (
<>
>
)}
{tab === 'viewport' && (
<>
{ctx.worldMode !== 'planet' && ctx.worldMode !== 'infinite' && (
)}
{isStudio && (
)}
>
)}
{tab === 'engine' && }
{tab === 'analysis' && isStudio && }
ctx.onResetPanel?.('debug')} settingId="debug.reset" />
);
}
function SessionInfo({ ctx }) {
return (
SESSION
World Mode
{ctx.worldMode}
Seed
{ctx.params.seed}
Board
{ctx.boardSize} u
{ctx.worldMode === 'studio' && (
Height Bake
{ctx.debugFlags?.disableHeightBake ? 'Off (live field)' : 'Active'}
)}
Version
v{APP_VERSION}
);
}
function TerrainOverlayOptions({ ctx }) {
const { params, onParam, worldMode } = ctx;
const isStudio = worldMode === 'studio';
const detailDebugOptions = [
{ value: 'off', label: 'Off' },
{ value: 'slope', label: 'Slope Mask' },
{ value: 'rock', label: 'Rock Mask' },
{ value: 'shoreline', label: 'Shoreline Mask' },
{ value: 'detailFade', label: 'Close Detail Fade' },
{ value: 'detail', label: 'Detail Noise' },
{ value: 'albedo', label: 'Final Albedo' },
{ value: 'normal', label: 'Final Normal' },
];
return (
}
defaultOpen
>
onParam('wireframe', v)}
info="Draw the terrain as wire mesh lines instead of solid triangles."
/>
onParam('lodDebug', v)}
info="Tint chunks by their active level-of-detail (red = highest detail → blue = lowest)."
/>
{isStudio && (
onParam('chunkGrid', v)}
info="Overlay borders along chunk boundaries. Lines turn green over merged chunk groups and magenta over the macro proxy."
/>
)}
ctx.onDebugFlag?.('mergeDebug', v)}
info="Tint folded terrain by merge level (green = small 2×2 fold → magenta = whole region). Works in Tile, Infinite and Planet modes. Watch blocks colour in as terrain folds at distance."
settingId="debug.mergeDebug"
/>
onParam('biomeDebug', v)}
info="Color-code biomes directly on the terrain surface for inspection."
/>
ctx.onDebugFlag?.('terrainDetailDebug', v)}
info="Inspect close-detail masks, albedo, and normals generated by the terrain material."
settingId="debug.terrainDetailDebug"
/>
);
}
function EngineDebugOptions({ ctx }) {
const { params, onParam, worldMode } = ctx;
const flags = ctx.debugFlags ?? {};
const setFlag = ctx.onDebugFlag ?? (() => {});
const isStudio = worldMode === 'studio';
return (
<>
}
defaultOpen
>
onParam('autoUpdate', v)}
info="Rebuild the terrain live as shape settings change. When off, edits stay pending until Auto Update is turned back on."
settingId="debug.autoUpdate"
/>
}
defaultOpen={isStudio || worldMode === 'planet'}
>
{isStudio || worldMode === 'planet' ? (
<>
setFlag('freezeCulling', v)}
info="Stop recomputing chunk visibility. Freeze, then orbit out to inspect the culling frustum from outside."
settingId="debug.freezeCulling"
/>
setFlag('freezeLod', v)}
info="Stop recomputing per-chunk level of detail — hold the current LOD layout while you move."
settingId="debug.freezeLod"
/>
setFlag('forceRender', v)}
info="Bypass on-demand rendering and draw every frame (use to read true sustained FPS)."
settingId="debug.forceRender"
/>
setFlag('disableHeightBake', v)}
info={isStudio
? 'Force the live per-pixel height field instead of the baked texture — A/B the studio render optimization.'
: 'Force the live per-pixel height field instead of the baked cubemap — A/B the planet render optimization.'}
settingId="debug.disableHeightBake"
/>
setFlag('freeCamNoClip', v)}
info="Temporarily switch to a collision-free FPS debug camera, then restore the previous explore/camera mode when disabled."
settingId="debug.freeCamNoClip"
/>
>
) : (
<>
setFlag('freeCamNoClip', v)}
info="Temporarily switch to a collision-free FPS debug camera, then restore the previous explore/camera mode when disabled."
settingId="debug.freeCamNoClip"
/>
Freeze / render diagnostics apply to Tile or Planet mode.
>
)}
>
);
}
// ------------------------------------------------------------- export panel
const FORMAT_OPTIONS = [
{ value: 'glb', label: 'GLB / GLTF (Recommended)' },
{ value: 'obj', label: 'OBJ (Wavefront)' },
];
const RES_OPTIONS = [
{ value: '64', label: '64 × 64 (Low-poly)' }, { value: '128', label: '128 × 128' },
{ value: '256', label: '256 × 256' }, { value: '512', label: '512 × 512 (Standard)' },
{ value: '1024', label: '1024 × 1024 (High-end)' },
];
const TEX_OPTIONS = [
{ value: '512', label: '512 × 512' }, { value: '1024', label: '1024 × 1024' },
{ value: '2048', label: '2048 × 2048 (Crisp)' }, { value: '4096', label: '4096 × 4096 (UHD)' },
];
const COLL_OPTIONS = [
{ value: '32', label: '32 × 32' }, { value: '64', label: '64 × 64' },
{ value: '128', label: '128 × 128 (Recommended)' }, { value: '256', label: '256 × 256' },
];
function ExportPanel({ ctx }) {
const [opt, setOpt] = useState({
exportPresetId: 'custom', packageRoot: null, packagePaths: null, heightmapRawPath: null,
format: 'glb', meshRes: '512', includeMesh: true, includeSkirts: true, includeBase: true,
bakeColor: true, texRes: '2048', bakeLighting: false, bakeNormal: true,
exportHeightmap: false, exportSplat: false, exportCollision: false, collisionRes: '128',
exportWater: false, exportPreset: true,
exportWaterMask: false, exportDepthMap: false, exportShorelineMask: false, exportFoamMask: false,
excludeWaterFromExport: false, exportWaterMetadata: false,
exportTileMode: 'merged', exportSplineMasks: false,
});
const [busy, setBusy] = useState(false);
const set = (k, v) => setOpt((p) => ({ ...p, [k]: v }));
// Turning on any water mask auto-enables the water plane (overridable: the
// user can still switch the plane back off afterwards).
const setMask = (k, v) => setOpt((p) => ({ ...p, [k]: v, ...(v && !p.exportWater ? { exportWater: true } : {}) }));
const showTex = opt.bakeColor || opt.bakeNormal || opt.exportHeightmap;
const multiTile = ctx.worldMode === 'studio' && (ctx.tiles?.length ?? 1) > 1;
const circleTiles = ctx.tileAssemblyShape === 'circle';
const productionChecks = validateExport(opt, { worldMode: ctx.worldMode, boardSize: ctx.boardSize });
const exportBlocked = hasExportErrors(productionChecks);
const selectedPreset = getExportPreset(opt.exportPresetId);
const applyPreset = (id) => setOpt((current) => applyExportPreset(current, id));
const doExport = async () => {
if (exportBlocked) return;
setBusy(true);
try { await ctx.onExport(opt); }
finally { setBusy(false); }
};
return (
{busy ? 'Exporting…' : `Export ${ctx.worldMode === 'planet' ? 'Planet' : 'Terrain'}`}
)}>
{selectedPreset ? selectedPreset.description : 'Choose files, maps, and geometry manually.'}
Production Check
{productionChecks.map((check, index) => (
{check.status === 'success' ? '✓' : check.status === 'warning' ? '⚠' : '×'}{check.message}
))}
{multiTile && !circleTiles && (
set('exportTileMode', v)}
/>
Merged = one combined terrain mesh. Separate = one ZIP with an
importable model and enabled maps for every tile.
)}
set('format', v)} />
set('includeMesh', v)} />
{opt.includeMesh && (
<>
set('meshRes', v)} />
set('includeSkirts', v)} />
{opt.includeSkirts && (
set('includeBase', v)} />
)}
>
)}
set('bakeColor', v)} />
{opt.bakeColor && (
set('bakeLighting', v)} />
)}
set('bakeNormal', v)} />
{showTex && (
set('texRes', v)} />
)}
set('exportHeightmap', v)} />
{opt.exportHeightmap && (
set('exportSplat', v)} />
)}
set('exportCollision', v)} />
{opt.exportCollision && (
set('collisionRes', v)} />
)}
set('exportWater', v)} />
{ctx.worldMode === 'studio' && set('exportSplineMasks', v)} />}
{opt.exportWater && (
set('excludeWaterFromExport', v)} />
)}
setMask('exportWaterMask', v)} />
setMask('exportDepthMap', v)} />
setMask('exportShorelineMask', v)} />
setMask('exportFoamMask', v)} />
set('exportWaterMetadata', v)} />
set('exportPreset', v)} />
);
}
// --------------------------------------------------------------- tiles panel
function TilesContent({ ctx }) {
const tiles = ctx.tiles ?? [{ cx: 0, cz: 0 }];
const grid = ctx.tileGridSize ?? 5;
const extent = ctx.tileGridExtent ?? 2;
const gridCells = grid * grid;
const shape = ctx.tileAssemblyShape ?? 'square';
const diskOuter = extent + 0.5;
const diskMaxCells = Array.from({ length: grid }, (_, ix) => ix - extent)
.flatMap((cx) => Array.from({ length: grid }, (_, iz) => ({ cx, cz: iz - extent })))
.filter(({ cx, cz }) => Math.hypot(Math.max(Math.abs(cx) - 0.5, 0), Math.max(Math.abs(cz) - 0.5, 0)) < diskOuter - 1e-6)
.length;
const maxCells = shape === 'circle' ? diskMaxCells : gridCells;
const atGridEdge = tiles.length >= maxCells;
return (
{shape === 'square'
? `Hover near a board edge and click the highlighted square to add a tile. Placement is limited to a ${grid}×${grid} grid centred on the origin.`
: (ctx.diskRadiusCells < extent
? 'Hover around the circular edge and click the highlighted ring to expand the disk.'
: 'The circular terrain has reached its maximum radius.')}
{' '}Tiles share the same noise field and export together.
Tiles{tiles.length} / {maxCells}
{shape === 'circle' && Disk radius{(ctx.diskRadiusCells ?? 0).toFixed(2)} cells
}
{atGridEdge && (
All {maxCells} available cells are occupied.
)}
{shape === 'square' && tiles.length > 1 && (
{tiles.map((t) => (
))}
)}
);
}
function NoiseLayersPanelWrapper({ ctx }) {
return (
ctx.onResetPanel?.('noiseLayers')} settingId="noiseLayers.reset" />
);
}
const COMPONENTS = {
terrain: TerrainPanel, noiseLayers: NoiseLayersPanelWrapper, world: WorldPanel, planet: PlanetPanel, biomes: BiomesPanel,
water: WaterPanel, props: PropsPanel, clouds: CloudsPanel, visuals: VisualsPanel, skybox: SkyboxPanel, lighting: LightingPanel, export: ExportPanel,
performance: PerformancePanel, debug: DebugPanel,
splines: ({ ctx }) => , history: ({ ctx }) => ,
};
export function renderPanel(id, ctx) {
const Comp = COMPONENTS[id];
return Comp ? : null;
}