import { BiomeType, MAP_SIZE_PRESET_METERS, MAP_SIZE_PRESET_RESOLUTION, buildStrategicRoughHeightConditions, createMapGridSpecFromRecipe, deserializeStrategicBundle, deserializeStrategicRoughHeightConditions, generateStrategicMapBundle, hashImportRecipe, serializeStrategicBundle, serializeStrategicRoughHeightConditions, validateImportRecipe, type ImportRecipe, type Layer, type LayerKind, type LayerUnit, type MapSizePreset, type MapGridSpec, type TerrainImportAttribution, type StrategicMapBundle, type StrategicMapMasks, type StrategicMapRecipe, type StrategicOverlayAuthority, type StrategicOverlayBundle, type StrategicOverlayPrimitiveType, type StrategicMapStamp, type StrategicAreaFeature, type StrategicSplineFeature, type StrategicControlPoint, type StrategicAreaFeatureType, type StrategicSdfProfileMode, type StrategicSplineFeatureType, type StrategicRoughHeightConditions, type StrategicRoughHeightMode, type StrategicStampType } from '@shared'; import { LayerStore } from './maplab/LayerStore'; import { renderMapLabGpuOverlay, type OverlayFloatLayerCache } from './maplab/MapLabGpuOverlay'; import { MapLabWebGpuOverlayRenderer, type OverlayColormapId } from './maplab/MapLabWebGpuOverlayRenderer'; const STORAGE_KEYS = { latestBundle: 'rts.maplab.latestBundle', latestBundleRef: 'rts.maplab.latestBundleRef', latestRecipe: 'rts.maplab.latestRecipe', latestRough: 'rts.maplab.latestRough', launchRequest: 'rts.maplab.autostart', slot: (slot: string) => `rts.maplab.slot.${slot}`, slotRef: (slot: string) => `rts.maplab.slotRef.${slot}` }; const MAPLAB_LAUNCH_DB_NAME = 'rts.maplab.launch.db'; const MAPLAB_LAUNCH_DB_STORE = 'launchBundles'; const MAPLAB_LAUNCH_BUNDLE_KEY = 'pending'; const MAPLAB_LATEST_BUNDLE_DB_KEY = 'latest'; const MAPLAB_CHANNEL_NAME = 'rts.maplab.stream'; const MAPLAB_BASE_LAYER_ID = 'import.baseHeight'; const MAPLAB_IMPORT_LAYER_DOC_VERSION = 1; const MAPLAB_TERRARIUM_URL_TEMPLATE = 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'; const MAPLAB_TERRARIUM_ATTRIBUTION_URL = 'https://registry.opendata.aws/terrain-tiles/'; const MAPLAB_TERRARIUM_PROVIDER_LABEL = 'AWS Terrain Tiles (Terrarium)'; const LIVE_ROUGH_RESOLUTION = 128; const HEATMAP_RENDER_MAX_DIM = 384; const PREVIEW_ROUGH_BROADCAST_INTERVAL_MS = 1800; const PREVIEW_LISTENER_PROBE_INTERVAL_MS = 900; const PREVIEW_LISTENER_ACTIVE_WINDOW_MS = 5000; const TURBO_CONTOUR_IDLE_RESTORE_MS = 420; const RECIPE_PERSIST_DEBOUNCE_MS = 1200; const CONTOUR_INTERVAL_METERS = 6; const CONTOUR_CPU_STROKE_ALPHA = 0.16; const CONTOUR_CPU_ROW_TARGET = 120; const CONTOUR_CPU_COL_TARGET = 180; const CONTOUR_CPU_SAMPLE_MOD = 12; const PREVIEW_RESOLUTION_TIERS = [160, 192, 256, 320] as const; const PREVIEW_ADAPTIVE_MAX_LEVEL = 3; const PREVIEW_ADAPTIVE_SLOW_WORKER_MS = 22; const PREVIEW_ADAPTIVE_SLOW_RENDER_MS = 14; const PREVIEW_ADAPTIVE_FAST_WORKER_MS = 10; const PREVIEW_ADAPTIVE_FAST_RENDER_MS = 8; const MAX_QUEUED_STAMP_DELTAS = 48; const STATUS_THROTTLE_MS = 140; const PERF_PANEL_MIN_UPDATE_MS = 90; const FULL_WORKER_TIMEOUT_MS = 45000; const BRUSH_DYNAMIC_SPACING_MAX_SCALE = 1.75; const BRUSH_MAX_STEPS_PER_POINTER_MOVE = 12; const INFLUENCE_MAX_STEPS_PER_POINTER_MOVE = 10; const POINTER_SAMPLE_STRIDE_MAX = 5; const INTERACTION_LOAD_SCALE_MIN = 1; const INTERACTION_LOAD_SCALE_MAX = 2; const MANUAL_STAMP_SPATIAL_INDEX_MIN_COUNT = 1400; const MANUAL_STAMP_SPATIAL_INDEX_CELL_SIZE_METERS = 320; const HISTORY_MAX_ENTRIES = 72; const GEOMETRY_PICK_POINT_RADIUS_PX = 18; const GEOMETRY_PICK_SEGMENT_RADIUS_PX = 28; const GEOMETRY_PICK_BODY_RADIUS_PX = 22; const GEOMETRY_PICK_PROPERTY_HANDLE_RADIUS_PX = 18; const GEOMETRY_PICK_SPLINE_GIZMO_RADIUS_PX = 18; const GEOMETRY_MOVE_EPSILON_METERS = 0.35; const PROPERTY_HANDLE_DRAG_PIXELS_FOR_FULL_RANGE = 220; const MAPLAB_EDITOR_STATE_VERSION = 1 as const; const BRUSH_STAMP_TYPES: StrategicStampType[] = [ 'ramp', 'basin', 'plateau', 'chokepoint', 'valley', 'ridge', 'lake', 'mesa', 'crater', 'trench', 'berm', 'saddle' ]; const BRUSH_SIGN_VALUES: Array<'auto' | 'raise' | 'lower'> = ['auto', 'raise', 'lower']; type CaLayerPresetId = 'paper-default' | 'continental' | 'fragmented' | 'custom'; type RecipeCaConfig = NonNullable; type RecipeCaLayer = NonNullable[number]; type RecipeCaRule = NonNullable[number]; type RecipeCaPostprocess = NonNullable; type RecipeMarkerGuidanceConfig = NonNullable; const CA_LAYER_PRESET_VALUES: CaLayerPresetId[] = ['paper-default', 'continental', 'fragmented', 'custom']; const DEFAULT_CA_BASE_RESOLUTION = 96; const DEFAULT_CA_SUBDIVISION_STEPS = 2; const DEFAULT_CA_RULE_SEED_OFFSET = 0; const DEFAULT_CA_POSTPROCESS_ITERATIONS = 2; const DEFAULT_CA_POSTPROCESS_STRENGTH = 0.36; const DEFAULT_CA_POSTPROCESS_CRITICAL_MAX_DRIFT = 3; const DEFAULT_CA_DETAIL_TREE_DENSITY = 1; const DEFAULT_CA_DETAIL_ROCK_DENSITY = 1; const DEFAULT_CA_DETAIL_MATERIAL_VARIATION = 1; const DEFAULT_CA_RULE_TEMPLATE: RecipeCaRule = { iterations: 2, birthMin: 4, birthMax: 5, surviveMin: 3, surviveMax: 6, randomBirthChance: 0, randomKillChance: 0 }; const DEFAULT_MARKER_SPAWN_MIN_DISTANCE_METERS = 1200; const DEFAULT_MARKER_SPAWN_MAX_SLOPE = 0.22; const DEFAULT_MARKER_SPAWN_MIN_BUILDABLE_COVERAGE = 0.62; const DEFAULT_MARKER_BUILDABLE_PROBE_RADIUS_METERS = 420; const DEFAULT_MARKER_RESOURCE_MIN_DISTANCE_METERS = 280; const DEFAULT_MARKER_RESOURCE_SPAWN_SEPARATION_FACTOR = 0.45; const DEFAULT_MARKER_SPAWN_SCORE_THRESHOLD = 0.58; const DEFAULT_MARKER_RESOURCE_SCORE_THRESHOLD = 0.52; const MARKER_BLOCKER_REJECT_THRESHOLD = 0.5; const CA_LAYER_PRESETS: Record, RecipeCaLayer[]> = { 'paper-default': [ { id: 'seafloor', label: 'Seafloor', enabled: true, seedDensity: 0.42, rules: [{ iterations: 3, birthMin: 4, birthMax: 5, surviveMin: 3, surviveMax: 6 }] }, { id: 'land', label: 'Land', enabled: true, seedDensity: 0.58, rules: [{ iterations: 2, birthMin: 3, birthMax: 5, surviveMin: 3, surviveMax: 6 }] }, { id: 'low_plateau', label: 'Low Plateau', enabled: true, seedDensity: 0.37, rules: [{ iterations: 2, birthMin: 4, birthMax: 5, surviveMin: 3, surviveMax: 5 }] }, { id: 'high_plateau', label: 'High Plateau', enabled: true, seedDensity: 0.24, rules: [{ iterations: 2, birthMin: 4, birthMax: 5, surviveMin: 4, surviveMax: 6 }] }, { id: 'mountain', label: 'Mountain', enabled: true, seedDensity: 0.17, rules: [{ iterations: 3, birthMin: 5, birthMax: 6, surviveMin: 4, surviveMax: 6 }] } ], continental: [ { id: 'seafloor', label: 'Seafloor', enabled: true, seedDensity: 0.35, rules: [{ iterations: 3, birthMin: 3, birthMax: 5, surviveMin: 3, surviveMax: 6 }] }, { id: 'land', label: 'Land', enabled: true, seedDensity: 0.68, rules: [{ iterations: 3, birthMin: 3, birthMax: 5, surviveMin: 3, surviveMax: 6 }] }, { id: 'low_plateau', label: 'Low Plateau', enabled: true, seedDensity: 0.32, rules: [{ iterations: 2, birthMin: 4, birthMax: 5, surviveMin: 3, surviveMax: 5 }] }, { id: 'high_plateau', label: 'High Plateau', enabled: true, seedDensity: 0.2, rules: [{ iterations: 2, birthMin: 4, birthMax: 5, surviveMin: 4, surviveMax: 6 }] }, { id: 'mountain', label: 'Mountain', enabled: true, seedDensity: 0.13, rules: [{ iterations: 3, birthMin: 5, birthMax: 6, surviveMin: 4, surviveMax: 6 }] } ], fragmented: [ { id: 'seafloor', label: 'Seafloor', enabled: true, seedDensity: 0.53, rules: [{ iterations: 3, birthMin: 4, birthMax: 6, surviveMin: 3, surviveMax: 6 }] }, { id: 'land', label: 'Land', enabled: true, seedDensity: 0.44, rules: [{ iterations: 2, birthMin: 4, birthMax: 5, surviveMin: 3, surviveMax: 5 }] }, { id: 'low_plateau', label: 'Low Plateau', enabled: true, seedDensity: 0.29, rules: [{ iterations: 2, birthMin: 4, birthMax: 5, surviveMin: 3, surviveMax: 5 }] }, { id: 'high_plateau', label: 'High Plateau', enabled: true, seedDensity: 0.16, rules: [{ iterations: 2, birthMin: 5, birthMax: 6, surviveMin: 4, surviveMax: 6 }] }, { id: 'mountain', label: 'Mountain', enabled: true, seedDensity: 0.1, rules: [{ iterations: 3, birthMin: 5, birthMax: 6, surviveMin: 4, surviveMax: 6 }] } ] }; type TerrariumBattlePreset = { id: string; label: string; lat: number; lon: number; radiusKm: number; zoom?: number; }; const TERRARIUM_BATTLE_PRESETS: TerrariumBattlePreset[] = [ { id: 'waterloo_1815', label: 'Waterloo (1815)', lat: 50.6806, lon: 4.4125, radiusKm: 8, zoom: 12 }, { id: 'austerlitz_1805', label: 'Austerlitz (1805)', lat: 49.1539, lon: 16.8760, radiusKm: 10, zoom: 12 }, { id: 'hastings_1066', label: 'Hastings (1066)', lat: 50.9114, lon: 0.4870, radiusKm: 8, zoom: 12 }, { id: 'cannae_216bc', label: 'Cannae (216 BC)', lat: 41.3058, lon: 16.1527, radiusKm: 10, zoom: 12 }, { id: 'gettysburg_1863', label: 'Gettysburg (1863)', lat: 39.8309, lon: -77.2311, radiusKm: 10, zoom: 12 }, { id: 'verdun_1916', label: 'Verdun (1916)', lat: 49.1590, lon: 5.3850, radiusKm: 12, zoom: 11 }, { id: 'somme_1916', label: 'Somme (1916)', lat: 50.0180, lon: 2.6500, radiusKm: 14, zoom: 11 }, { id: 'el_alamein_1942', label: 'El Alamein (1942)', lat: 30.8300, lon: 28.9550, radiusKm: 14, zoom: 11 }, { id: 'stalingrad_1942', label: 'Stalingrad (1942)', lat: 48.7080, lon: 44.5130, radiusKm: 15, zoom: 11 }, { id: 'kursk_1943', label: 'Kursk/Prokhorovka (1943)', lat: 51.0370, lon: 36.7330, radiusKm: 15, zoom: 11 } ]; type RegenerationMode = StrategicRoughHeightMode; type EditSpeedMode = 'turbo' | 'balanced' | 'quality'; type MouseAssistMode = 'default' | 'carve' | 'insert' | 'delete'; type WorkflowPresetId = 'sculpt' | 'carve' | 'river' | 'edit'; type DockTabId = 'session' | 'edit' | 'view' | 'import' | 'export'; type InfluenceMode = 'none' | 'lane' | 'safe' | 'nobuild' | 'lock'; type InfluenceLayerId = 'intentMainLane' | 'intentSafeExpansion' | 'intentNoBuild' | 'constraintLock'; type BrushSymmetryMode = 'none' | 'mirror-x' | 'mirror-z' | 'quad'; type TerraformMode = 'off' | 'raise' | 'lower' | 'smooth' | 'erode' | 'sharpen' | 'flatten-cursor' | 'flatten-height'; type BrushRuntimeSettings = { type: StrategicStampType; radius: number; intensity: number; length: number; width: number; falloff: number; sign: 'auto' | 'raise' | 'lower'; orientationRad: number; symmetryMode: BrushSymmetryMode; userSpacing: number; terraformMode: TerraformMode; terraformStrength: number; terraformTargetHeight: number; }; type ManualStampSpatialIndex = { source: StrategicMapStamp[]; cellSize: number; buckets: Map; }; type FeatureRef = { kind: 'area' | 'spline'; id: string }; type FeaturePropertyHandleKey = 'intensity' | 'falloff' | 'width'; type SplinePointGizmoKind = 'tangent' | 'normal'; const INFLUENCE_FLOAT_LAYER_IDS: InfluenceLayerId[] = ['intentMainLane', 'intentSafeExpansion', 'intentNoBuild']; const NO_BUILD_BLOCK_THRESHOLD = 0.55; const OVERLAY_STUDIO_DEFAULT_LAYERS: Array<{ id: string; label: string; authority: StrategicOverlayAuthority; }> = [ { id: 'wetness', label: 'Wetness', authority: 'gameplay' }, { id: 'biome_class', label: 'Biome Class', authority: 'visual' }, { id: 'resource_potential', label: 'Resource Potential', authority: 'gameplay' }, { id: 'spawn_score', label: 'Spawn Score', authority: 'gameplay' }, { id: 'resource_score', label: 'Resource Score', authority: 'gameplay' }, { id: 'marker_blockers', label: 'Marker Blockers', authority: 'gameplay' }, { id: 'detail_tree_density', label: 'Detail Tree Density', authority: 'visual' }, { id: 'detail_rock_density', label: 'Detail Rock Density', authority: 'visual' }, { id: 'detail_material_variation', label: 'Detail Material Variation', authority: 'visual' }, { id: 'dominance_seed', label: 'Dominance Seed', authority: 'gameplay' }, { id: 'mask_buildable', label: 'Mask Buildable', authority: 'gameplay' }, { id: 'mask_ramps', label: 'Mask Ramps', authority: 'visual' }, { id: 'mask_choke', label: 'Mask Choke', authority: 'visual' } ]; interface MapLabEditorState { version: 1; editSpeed: EditSpeedMode; mouseAssist: MouseAssistMode; brushType: StrategicStampType; brushSign: 'auto' | 'raise' | 'lower'; brushRadius: number; brushIntensity: number; brushLength: number; brushWidth: number; brushFalloff: number; brushOrientation: number; brushSymmetry: BrushSymmetryMode; brushSpacing: number; brushJitter: number; terraformMode: TerraformMode; terraformStrength: number; terraformTargetHeight: number; shapeMode: ShapeMode; shapeWidth: number; shapeIntensity: number; shapePointWeight: number; shapeFalloff: number; caLayerPreset: CaLayerPresetId; caLayerIndex?: number; caRuleIndex?: number; featureProfile: FeatureProfileMode; overlayLayerId: string; overlayAuthority: StrategicOverlayAuthority; overlayTool: StrategicOverlayPrimitiveType; } const OVERLAYS = { heat: document.getElementById('ml-overlay-height') as HTMLInputElement, contours: document.getElementById('ml-overlay-contours') as HTMLInputElement, ramps: document.getElementById('ml-overlay-ramps') as HTMLInputElement, chokes: document.getElementById('ml-overlay-chokes') as HTMLInputElement, buildable: document.getElementById('ml-overlay-buildable') as HTMLInputElement, features: document.getElementById('ml-overlay-features') as HTMLInputElement, spawn: document.getElementById('ml-overlay-spawn') as HTMLInputElement }; interface AppState { recipe: StrategicMapRecipe; bundle: StrategicMapBundle | null; fullBundle: StrategicMapBundle | null; previewTimer: number | null; previewFrameHandle: number | null; renderFrameHandle: number | null; renderQueued: boolean; fullTimer: number | null; stats: { min: number; max: number }; generationMode: RegenerationMode; broadcast: BroadcastChannel | null; worker: Worker | null; requestSeq: number; latestPreviewRequestId: number; latestFullRequestId: number; latestFullWorkerRecipe: StrategicMapRecipe | null; queuedFullWorkerRecipe: StrategicMapRecipe | null; fullInFlight: boolean; previewInFlight: boolean; previewWorkerPrimed: boolean; queuedPreviewRecipe: StrategicMapRecipe | null; queuedStampDeltas: Array<{ stamp: StrategicMapStamp; delta: 'add' | 'remove' }>; workerHeatmap: { width: number; height: number } | null; workerContour: { width: number; height: number } | null; lastRoughPublishAt: number; lastListenerProbeAt: number; lastListenerAckAt: number; contourRestoreTimer: number | null; contourRestoreEnabled: boolean; turboContoursSuppressed: boolean; broadcastClientId: string; recipePersistTimer: number | null; previewAdaptiveLevel: number; previewAdaptiveSlowStreak: number; previewAdaptiveFastStreak: number; perf: { lastWorkerMs: number; lastRenderMs: number; lastBlitMs: number; lastTransferBytes: number; lastHeightPatchBytes: number; lastHeatPatchBytes: number; lastContourPatchBytes: number; lastOverlayPatchBytes: number; lastOverlayPatchCount: number; lastHeightPatchDims: string; lastHeatPatchDims: string; lastContourPatchDims: string; broadcastTimes: number[]; broadcastRate: number; previewQueueDepth: number; coalescedPreviewCount: number; }; perfUiTimer: number | null; interactionLoadScale: number; lastInteractionLoadUpdateAt: number; fullWatchdogTimer: number | null; bootHydrationDone: boolean; latestBundlePersistHash: string | null; latestBundlePersistTransport: 'localStorage' | 'indexeddb' | null; persistingBundleHash: string | null; historyPast: StrategicMapRecipe[]; historyFuture: StrategicMapRecipe[]; historyPendingBefore: StrategicMapRecipe | null; lastPerfPanelUpdateAt: number; nextDataPanelAt: number; shapeDraft: { mode: ShapeMode; points: StrategicControlPoint[]; width: number; intensity: number; falloff: number; } | null; editSpeedMode: EditSpeedMode; selectedFeatureRef: { kind: 'area' | 'spline'; id: string; } | null; selectedFeaturePointIndex: number | null; brushStroke: { pointerId: number; mode: 'add' | 'remove'; lastWorld: { x: number; z: number }; flattenTargetHeight: number | null; terraformSampleContext: TerraformHeightSampleContext | null; workerPreviewRecipe: StrategicMapRecipe | null; didMutate: boolean; settings: BrushRuntimeSettings; } | null; stampScratch: StrategicMapStamp[]; stampDeltaScratch: Array<{ stamp: StrategicMapStamp; delta: 'add' | 'remove' }>; stampSelectedScratch: Set; stampIndexScratch: Set; nextManualStampId: number; manualStampSpatialIndex: ManualStampSpatialIndex | null; influencePaintStroke: { pointerId: number; mode: 'add' | 'remove'; influenceMode: InfluenceMode; lastWorld: { x: number; z: number }; layerId: Exclude | null; workingValues: Float32Array | null; width: number; height: number; dirty: boolean; } | null; constraintLocks: { width: number; height: number; mask: Uint8Array; values: Float32Array; dirty: boolean; cachedIndices: Uint32Array | null; } | null; lastPatchStatusAt: number; lastStatusMessage: string; geometryEditDrag: { pointerId: number; target: FeatureRef; mode: 'point' | 'feature'; pointIndex: number; startWorld: { x: number; z: number }; originPoints: StrategicControlPoint[]; moved: boolean; } | null; propertyHandleDrag: { pointerId: number; target: FeatureRef; key: FeaturePropertyHandleKey; startCanvasY: number; startValue: number; min: number; max: number; moved: boolean; } | null; splineGizmoDrag: { pointerId: number; target: FeatureRef; pointIndex: number; kind: SplinePointGizmoKind; startPoint: { x: number; z: number }; tangent: { x: number; z: number }; normal: { x: number; z: number }; startWeight: number; moved: boolean; } | null; lastGeometryPreviewAt: number; importWorker: Worker | null; importRequestSeq: number; pendingImportRequestId: number; importedBaseLayer: Float32Array | null; importedBaseLayerWidth: number; importedBaseLayerHeight: number; importRecipes: ImportRecipe[]; importAttribution: TerrainImportAttribution[]; pendingImportContext: ImportExecutionContext | null; layerStore: LayerStore; activeLayerId: string; overlayAlpha: number; overlayRangeMin: number; overlayRangeMax: number; overlayAutoRange: boolean; overlayColormap: OverlayColormapId; overlayRenderer: MapLabWebGpuOverlayRenderer | null; overlayRendererReady: boolean; overlayLayerDataVersionById: Record; overlayFloatCache: OverlayFloatLayerCache; caLayerStats: WorkerCaLayerStats[]; cacheHits: number; cacheMisses: number; sidebarDockOpen: boolean; dockTab: DockTabId; sidebarPanels: { importPinned: boolean; layerPinned: boolean; overlaysPinned: boolean; }; probe: { canvasX: number; canvasY: number; active: boolean; lastUpdateAt: number; }; } type ShapeMode = 'none' | 'edit' | 'forest' | 'mountain' | 'river' | 'road'; type FeatureProfileMode = StrategicSdfProfileMode; interface MapLabRealtimeRoughMessage { type: 'maplab-rough-height'; payload: unknown; } interface MapLabPresenceProbeMessage { type: 'maplab-listener-presence-request'; sourceId: string; sentAt: number; } interface MapLabPresenceAckMessage { type: 'maplab-listener-presence-response'; sourceId: string; sentAt: number; } interface MapLabAutoStartRequest { version: 1; source: 'maplab'; createdAt: number; mapName?: string; sourceHash?: string; seed?: number; playerCount?: number; biome?: BiomeType; mapSize?: MapSizePreset; bundleTransport?: 'localStorage' | 'indexeddb'; launchBundleKey?: string; } interface MapLabLaunchBundleRecord { key: string; createdAt: number; bundle: StrategicMapBundle; } interface MapLabBundleRef { version: 1; transport: 'localStorage' | 'indexeddb'; key?: string; createdAt: number; hash?: string; } type MapLabRealtimeMessage = | MapLabRealtimeRoughMessage | MapLabPresenceProbeMessage | MapLabPresenceAckMessage; interface TerrainImportRecipeDocument { version: 1; gridSpec: MapGridSpec; recipes: ImportRecipe[]; attribution: TerrainImportAttribution[]; layerRanges: Record; activeBaseLayerId: string; exportedAt: string; sourceBundleHash?: string; } interface TerrainImportProgressMessage { type: 'progress'; requestId: number; stage: string; completed: number; total: number; detail?: string; } interface TerrainImportSuccessMessage { type: 'success'; requestId: number; width: number; height: number; min: number; max: number; histogram: number[]; heightDataBuffer: ArrayBuffer; meta: Record; } interface TerrainImportErrorMessage { type: 'error'; requestId: number; message: string; } type TerrainImportWorkerMessage = | TerrainImportProgressMessage | TerrainImportSuccessMessage | TerrainImportErrorMessage; interface TerrainImportUploadPng8Request { type: 'upload-png8'; requestId: number; fileName: string; rgbaBuffer: ArrayBuffer; sourceWidth: number; sourceHeight: number; targetSize: number; normalizeInput: boolean; scaleMetersPerUnit: number; offsetMeters: number; resample: 'nearest' | 'bilinear'; } interface TerrainImportUploadRaw16Request { type: 'upload-raw16'; requestId: number; fileName: string; rawBuffer: ArrayBuffer; sourceWidth: number; sourceHeight: number; littleEndian: boolean; targetSize: number; normalizeInput: boolean; scaleMetersPerUnit: number; offsetMeters: number; resample: 'nearest' | 'bilinear'; } interface TerrainImportTerrariumRequest { type: 'terrarium-import'; requestId: number; bbox: { west: number; south: number; east: number; north: number }; targetSize: number; z?: number; resample: 'bilinear'; urlTemplate?: string; concurrency?: number; retries?: number; } type TerrainImportRequest = | TerrainImportUploadPng8Request | TerrainImportUploadRaw16Request | TerrainImportTerrariumRequest; type ImportExecutionContext = { recipe: ImportRecipe; attribution?: TerrainImportAttribution; sourceLabel: string; width: number; height: number; }; interface WorkerBundleTransfer { recipe?: StrategicMapRecipe; width: number; height: number; worldMetrics: StrategicMapBundle['worldMetrics']; analysis?: StrategicMapBundle['analysis']; graphSummary?: StrategicMapBundle['graphSummary']; pipelineStats?: StrategicMapBundle['pipelineStats']; hash: string; generatedAt: string; heightDataBuffer?: ArrayBuffer; masks?: Partial>; } interface WorkerHeightPatchTransfer { x: number; y: number; width: number; height: number; dataBuffer: ArrayBuffer; } interface WorkerHeatmapPatchTransfer { x: number; y: number; width: number; height: number; fullWidth: number; fullHeight: number; rgbaBuffer: ArrayBuffer; } interface WorkerContourPatchTransfer { x: number; y: number; width: number; height: number; fullWidth: number; fullHeight: number; rgbaBuffer: ArrayBuffer; } interface WorkerOverlayLayerPatchTransfer { layerId: string; label: string; kind: LayerKind; unit?: LayerUnit; authority: StrategicOverlayAuthority; x: number; y: number; width: number; height: number; fullWidth: number; fullHeight: number; rangeMin?: number; rangeMax?: number; dataBuffer: ArrayBuffer; } interface WorkerOverlayPrimitivePointTransfer { x: number; z: number; weight?: number; } interface WorkerOverlayPrimitiveTransfer { id: string; layerId: string; type: StrategicOverlayPrimitiveType; authority: StrategicOverlayAuthority; points: WorkerOverlayPrimitivePointTransfer[]; closed?: boolean; radius?: number; label?: string; } interface WorkerOverlayPrimitivesPatchTransfer { mode: 'replace'; primitives: WorkerOverlayPrimitiveTransfer[]; } interface WorkerCaLayerStats { layerId: string; label: string; activeRatio: number; componentCount: number; symmetryMismatch: number; } interface WorkerSuccessMessage { type: 'preview' | 'full'; requestId: number; bundle: WorkerBundleTransfer; heightPatch?: WorkerHeightPatchTransfer; heightPatches?: WorkerHeightPatchTransfer[]; heatmapPatch?: WorkerHeatmapPatchTransfer; heatmapPatches?: WorkerHeatmapPatchTransfer[]; contourPatch?: WorkerContourPatchTransfer; contourPatches?: WorkerContourPatchTransfer[]; overlayLayerPatches?: WorkerOverlayLayerPatchTransfer[]; overlayPrimitivesPatch?: WorkerOverlayPrimitivesPatchTransfer; minHeight?: number; maxHeight?: number; caLayerStats?: WorkerCaLayerStats[]; } interface WorkerErrorMessage { type: 'error'; requestId: number; message: string; } type WorkerMessage = WorkerSuccessMessage | WorkerErrorMessage; interface WorkerSetBaseLayerRequest { type: 'set-base-layer'; requestId: number; width: number; height: number; heightDataBuffer: ArrayBuffer; } interface WorkerClearBaseLayerRequest { type: 'clear-base-layer'; requestId: number; } const gpuCanvas = document.getElementById('maplab-gpu-canvas') as HTMLCanvasElement; const canvas = document.getElementById('maplab-canvas') as HTMLCanvasElement; const ctx = canvas.getContext('2d'); const heatmapCanvas = document.createElement('canvas'); const heatmapCtx = heatmapCanvas.getContext('2d'); const contourCanvas = document.createElement('canvas'); const contourCtx = contourCanvas.getContext('2d'); let heatmapImageCache: ImageData | null = null; let heatmapLookupCache: | { targetWidth: number; targetHeight: number; sourceWidth: number; sourceHeight: number; xLut: Int32Array; zLut: Int32Array; } | null = null; const statusEl = document.getElementById('maplab-status') as HTMLParagraphElement; const dataEl = document.getElementById('ml-data') as HTMLPreElement; const perfEl = document.getElementById('ml-perf') as HTMLPreElement | null; const importPreviewEl = document.getElementById('ml-import-preview') as HTMLPreElement | null; const probeReadoutEl = document.getElementById('ml-probe-readout') as HTMLPreElement | null; const objectivesReadoutEl = document.getElementById('ml-objectives') as HTMLPreElement | null; const cacheStatsEl = document.getElementById('ml-cache-stats') as HTMLParagraphElement | null; const controls = { seed: document.getElementById('ml-seed') as HTMLInputElement, randomSeed: document.getElementById('ml-random-seed') as HTMLButtonElement, players: document.getElementById('ml-players') as HTMLSelectElement, symmetry: document.getElementById('ml-symmetry') as HTMLSelectElement, biome: document.getElementById('ml-biome') as HTMLSelectElement, sizePreset: document.getElementById('ml-size-preset') as HTMLSelectElement, resolution: document.getElementById('ml-resolution') as HTMLSelectElement, competitive: document.getElementById('ml-competitive') as HTMLInputElement, caEnabled: document.getElementById('ml-ca-enabled') as HTMLSelectElement, caBaseResolution: document.getElementById('ml-ca-base-resolution') as HTMLSelectElement, caSubdivision: document.getElementById('ml-ca-subdivision') as HTMLSelectElement, caLayerPreset: document.getElementById('ml-ca-layer-preset') as HTMLSelectElement, caLayerEditor: document.getElementById('ml-ca-layer-editor') as HTMLElement, caLayerSelect: document.getElementById('ml-ca-layer-select') as HTMLSelectElement, caLayerEnabled: document.getElementById('ml-ca-layer-enabled') as HTMLSelectElement, caLayerSeedDensity: document.getElementById('ml-ca-layer-seed-density') as HTMLInputElement, caLayerBaseHeightDelta: document.getElementById('ml-ca-layer-base-height-delta') as HTMLInputElement, caLayerSubdivision: document.getElementById('ml-ca-layer-subdivision') as HTMLInputElement, caRuleSelect: document.getElementById('ml-ca-rule-select') as HTMLSelectElement, caRuleAdd: document.getElementById('ml-ca-rule-add') as HTMLButtonElement, caRuleRemove: document.getElementById('ml-ca-rule-remove') as HTMLButtonElement, caRuleIterations: document.getElementById('ml-ca-rule-iterations') as HTMLInputElement, caRuleBirthMin: document.getElementById('ml-ca-rule-birth-min') as HTMLInputElement, caRuleBirthMax: document.getElementById('ml-ca-rule-birth-max') as HTMLInputElement, caRuleSurviveMin: document.getElementById('ml-ca-rule-survive-min') as HTMLInputElement, caRuleSurviveMax: document.getElementById('ml-ca-rule-survive-max') as HTMLInputElement, caRuleRandomBirth: document.getElementById('ml-ca-rule-random-birth') as HTMLInputElement, caRuleRandomKill: document.getElementById('ml-ca-rule-random-kill') as HTMLInputElement, caLayerReadout: document.getElementById('ml-ca-layer-readout') as HTMLParagraphElement, caPostEnabled: document.getElementById('ml-ca-post-enabled') as HTMLSelectElement, caPostIterations: document.getElementById('ml-ca-post-iterations') as HTMLInputElement, caPostStrength: document.getElementById('ml-ca-post-strength') as HTMLInputElement, caPostPreserveMasks: document.getElementById('ml-ca-post-preserve-masks') as HTMLSelectElement, caPostMaxDrift: document.getElementById('ml-ca-post-max-drift') as HTMLInputElement, caDetailEnabled: document.getElementById('ml-ca-detail-enabled') as HTMLSelectElement, caDetailTreeDensity: document.getElementById('ml-ca-detail-tree-density') as HTMLInputElement, caDetailRockDensity: document.getElementById('ml-ca-detail-rock-density') as HTMLInputElement, caDetailMaterialVariation: document.getElementById('ml-ca-detail-material-variation') as HTMLInputElement, caPostReadout: document.getElementById('ml-ca-post-readout') as HTMLParagraphElement, markerGuidanceEnabled: document.getElementById('ml-marker-guidance-enabled') as HTMLSelectElement, markerSpawnMinDistance: document.getElementById('ml-marker-spawn-min-distance') as HTMLInputElement, markerSpawnMaxSlope: document.getElementById('ml-marker-spawn-max-slope') as HTMLInputElement, markerSpawnMinBuildable: document.getElementById('ml-marker-spawn-min-buildable') as HTMLInputElement, markerProbeRadius: document.getElementById('ml-marker-probe-radius') as HTMLInputElement, markerResourceMinDistance: document.getElementById('ml-marker-resource-min-distance') as HTMLInputElement, markerResourceSpawnSeparation: document.getElementById('ml-marker-resource-spawn-separation') as HTMLInputElement, markerCompetitiveSymmetryLock: document.getElementById('ml-marker-competitive-symmetry-lock') as HTMLSelectElement, markerGuidanceReadout: document.getElementById('ml-marker-guidance-readout') as HTMLParagraphElement, editSpeed: document.getElementById('ml-edit-speed') as HTMLSelectElement, mouseAssist: document.getElementById('ml-mouse-assist') as HTMLSelectElement, brushToolGroup: document.getElementById('ml-brush-tool-group') as HTMLElement, brushProfilePanel: document.getElementById('ml-brush-profile-panel') as HTMLElement, shapeProfilePanel: document.getElementById('ml-shape-profile-panel') as HTMLElement, featureSdfPanel: document.getElementById('ml-feature-sdf-panel') as HTMLElement, canvasBrushBar: document.getElementById('ml-canvas-brush-bar') as HTMLElement, canvasMouseBar: document.getElementById('ml-canvas-mouse-bar') as HTMLElement, brushType: document.getElementById('ml-brush-type') as HTMLSelectElement, brushRadius: document.getElementById('ml-brush-radius') as HTMLInputElement, brushIntensity: document.getElementById('ml-brush-intensity') as HTMLInputElement, brushLength: document.getElementById('ml-brush-length') as HTMLInputElement, brushWidth: document.getElementById('ml-brush-width') as HTMLInputElement, brushFalloff: document.getElementById('ml-brush-falloff') as HTMLInputElement, brushSign: document.getElementById('ml-brush-sign') as HTMLSelectElement, brushOrientation: document.getElementById('ml-brush-orientation') as HTMLInputElement, brushSymmetry: document.getElementById('ml-brush-symmetry') as HTMLSelectElement, brushSpacing: document.getElementById('ml-brush-spacing') as HTMLInputElement, brushJitter: document.getElementById('ml-brush-jitter') as HTMLInputElement, terraformMode: document.getElementById('ml-terraform-mode') as HTMLSelectElement, terraformStrength: document.getElementById('ml-terraform-strength') as HTMLInputElement, terraformTargetHeight: document.getElementById('ml-terraform-target-height') as HTMLInputElement, terraformCaptureHeight: document.getElementById('ml-terraform-capture-height') as HTMLButtonElement, shapeMode: document.getElementById('ml-shape-mode') as HTMLSelectElement, shapeWidth: document.getElementById('ml-shape-width') as HTMLInputElement, shapeIntensity: document.getElementById('ml-shape-intensity') as HTMLInputElement, shapePointWeight: document.getElementById('ml-shape-point-weight') as HTMLInputElement, shapeFalloff: document.getElementById('ml-shape-falloff') as HTMLInputElement, shapeStart: document.getElementById('ml-shape-start') as HTMLButtonElement, shapeFinish: document.getElementById('ml-shape-finish') as HTMLButtonElement, shapeCancel: document.getElementById('ml-shape-cancel') as HTMLButtonElement, shapeClear: document.getElementById('ml-shape-clear') as HTMLButtonElement, featureSelect: document.getElementById('ml-feature-select') as HTMLSelectElement, featureMeta: document.getElementById('ml-feature-meta') as HTMLParagraphElement, featureProfile: document.getElementById('ml-feature-profile') as HTMLSelectElement, featureEdge: document.getElementById('ml-feature-edge') as HTMLInputElement, featureSharpness: document.getElementById('ml-feature-sharpness') as HTMLInputElement, featureBank: document.getElementById('ml-feature-bank') as HTMLInputElement, featureTarget: document.getElementById('ml-feature-target') as HTMLInputElement, featureTerraceSteps: document.getElementById('ml-feature-terrace-steps') as HTMLInputElement, featureTerraceStrength: document.getElementById('ml-feature-terrace-strength') as HTMLInputElement, featureWeights: document.getElementById('ml-feature-weights') as HTMLInputElement, featureDelete: document.getElementById('ml-feature-delete') as HTMLButtonElement, featureApplyWeights: document.getElementById('ml-feature-apply-weights') as HTMLButtonElement, viewOverlaysPanel: document.getElementById('ml-view-overlays-panel') as HTMLElement, influencePanel: document.getElementById('ml-influence-panel') as HTMLElement, objectivesPanel: document.getElementById('ml-objectives-panel') as HTMLElement, importPanel: document.getElementById('ml-import-panel') as HTMLElement, layerPanel: document.getElementById('ml-layer-panel') as HTMLElement, clearStamps: document.getElementById('ml-clear-stamps') as HTMLButtonElement, regenerate: document.getElementById('ml-regenerate') as HTMLButtonElement, slot: document.getElementById('ml-slot') as HTMLSelectElement, saveSlot: document.getElementById('ml-save-slot') as HTMLButtonElement, loadSlot: document.getElementById('ml-load-slot') as HTMLButtonElement, startGame: document.getElementById('ml-start-game') as HTMLButtonElement, exportJson: document.getElementById('ml-export-json') as HTMLButtonElement, exportRecipeJson: document.getElementById('ml-export-recipe-json') as HTMLButtonElement, exportHeightRaw: document.getElementById('ml-export-height-raw') as HTMLButtonElement, exportOverlayPng: document.getElementById('ml-export-overlay-png') as HTMLButtonElement, importJson: document.getElementById('ml-import-json') as HTMLButtonElement, importFile: document.getElementById('ml-import-file') as HTMLInputElement, loadLatest: document.getElementById('ml-load-latest') as HTMLButtonElement, undo: document.getElementById('ml-undo') as HTMLButtonElement, redo: document.getElementById('ml-redo') as HTMLButtonElement, brushRadiusValue: document.getElementById('ml-brush-radius-value') as HTMLSpanElement, brushIntensityValue: document.getElementById('ml-brush-intensity-value') as HTMLSpanElement, brushLengthValue: document.getElementById('ml-brush-length-value') as HTMLSpanElement, brushWidthValue: document.getElementById('ml-brush-width-value') as HTMLSpanElement, brushFalloffValue: document.getElementById('ml-brush-falloff-value') as HTMLSpanElement, brushOrientationValue: document.getElementById('ml-brush-orientation-value') as HTMLSpanElement, brushSpacingValue: document.getElementById('ml-brush-spacing-value') as HTMLSpanElement, brushJitterValue: document.getElementById('ml-brush-jitter-value') as HTMLSpanElement, terraformStrengthValue: document.getElementById('ml-terraform-strength-value') as HTMLSpanElement, shapeWidthValue: document.getElementById('ml-shape-width-value') as HTMLSpanElement, shapeIntensityValue: document.getElementById('ml-shape-intensity-value') as HTMLSpanElement, shapePointWeightValue: document.getElementById('ml-shape-point-weight-value') as HTMLSpanElement, shapeFalloffValue: document.getElementById('ml-shape-falloff-value') as HTMLSpanElement, importMode: document.getElementById('ml-import-mode') as HTMLSelectElement, importUploadFields: document.getElementById('ml-import-upload-fields') as HTMLElement, importTerrariumFields: document.getElementById('ml-import-terrarium-fields') as HTMLElement, importHeightmapFile: document.getElementById('ml-import-heightmap-file') as HTMLInputElement, uploadFormat: document.getElementById('ml-upload-format') as HTMLSelectElement, uploadRawWidth: document.getElementById('ml-upload-raw-width') as HTMLInputElement, uploadRawHeight: document.getElementById('ml-upload-raw-height') as HTMLInputElement, uploadRawEndian: document.getElementById('ml-upload-raw-endian') as HTMLSelectElement, uploadResample: document.getElementById('ml-upload-resample') as HTMLSelectElement, uploadScale: document.getElementById('ml-upload-scale') as HTMLInputElement, uploadOffset: document.getElementById('ml-upload-offset') as HTMLInputElement, uploadNormalize: document.getElementById('ml-upload-normalize') as HTMLInputElement, importUploadRun: document.getElementById('ml-import-upload-run') as HTMLButtonElement, terrariumPreset: document.getElementById('ml-terrarium-preset') as HTMLSelectElement, terrariumLat: document.getElementById('ml-terrarium-lat') as HTMLInputElement, terrariumLon: document.getElementById('ml-terrarium-lon') as HTMLInputElement, terrariumRadiusKm: document.getElementById('ml-terrarium-radius-km') as HTMLInputElement, terrariumZoom: document.getElementById('ml-terrarium-z') as HTMLInputElement, terrariumAttribution: document.getElementById('ml-terrarium-attribution') as HTMLInputElement, importTerrariumRun: document.getElementById('ml-import-terrarium-run') as HTMLButtonElement, clearBaseLayer: document.getElementById('ml-clear-base-layer') as HTMLButtonElement, influenceMode: document.getElementById('ml-influence-mode') as HTMLSelectElement, influenceRadius: document.getElementById('ml-influence-radius') as HTMLInputElement, influenceStrength: document.getElementById('ml-influence-strength') as HTMLInputElement, influenceRadiusValue: document.getElementById('ml-influence-radius-value') as HTMLSpanElement, influenceStrengthValue: document.getElementById('ml-influence-strength-value') as HTMLSpanElement, influenceClearLayer: document.getElementById('ml-influence-clear-layer') as HTMLButtonElement, influenceClearLocks: document.getElementById('ml-influence-clear-locks') as HTMLButtonElement, overlayStudioPanel: document.getElementById('ml-overlay-studio-panel') as HTMLElement, overlayStudioLayer: document.getElementById('ml-overlay-studio-layer') as HTMLSelectElement, overlayStudioAuthority: document.getElementById('ml-overlay-studio-authority') as HTMLSelectElement, overlayStudioTool: document.getElementById('ml-overlay-studio-tool') as HTMLSelectElement, overlayStudioAddSelected: document.getElementById('ml-overlay-studio-add-selected') as HTMLButtonElement, overlayStudioAddWaypoint: document.getElementById('ml-overlay-studio-add-waypoint') as HTMLButtonElement, overlayStudioClearLayer: document.getElementById('ml-overlay-studio-clear-layer') as HTMLButtonElement, overlayStudioReadout: document.getElementById('ml-overlay-studio-readout') as HTMLPreElement, layerSearch: document.getElementById('ml-layer-search') as HTMLInputElement, layerPreset: document.getElementById('ml-layer-preset') as HTMLSelectElement, layerActive: document.getElementById('ml-layer-active') as HTMLSelectElement, layerColormap: document.getElementById('ml-layer-colormap') as HTMLSelectElement, layerAlpha: document.getElementById('ml-layer-alpha') as HTMLInputElement, layerMin: document.getElementById('ml-layer-min') as HTMLInputElement, layerMax: document.getElementById('ml-layer-max') as HTMLInputElement, layerAutoRange: document.getElementById('ml-layer-auto-range') as HTMLButtonElement, canvasOverlayHeat: document.getElementById('ml-canvas-overlay-heat') as HTMLButtonElement, canvasOverlayContours: document.getElementById('ml-canvas-overlay-contours') as HTMLButtonElement, canvasOverlayFeatures: document.getElementById('ml-canvas-overlay-features') as HTMLButtonElement, canvasFullscreen: document.getElementById('ml-canvas-fullscreen') as HTMLButtonElement, canvasUiToggle: document.getElementById('ml-canvas-ui-toggle') as HTMLButtonElement, canvasPanelImport: document.getElementById('ml-canvas-panel-import') as HTMLButtonElement, canvasPanelLayer: document.getElementById('ml-canvas-panel-layer') as HTMLButtonElement, canvasPanelOverlays: document.getElementById('ml-canvas-panel-overlays') as HTMLButtonElement, canvasPresetSculpt: document.getElementById('ml-canvas-preset-sculpt') as HTMLButtonElement, canvasPresetCarve: document.getElementById('ml-canvas-preset-carve') as HTMLButtonElement, canvasPresetRiver: document.getElementById('ml-canvas-preset-river') as HTMLButtonElement, canvasPresetEdit: document.getElementById('ml-canvas-preset-edit') as HTMLButtonElement, canvasShortcuts: document.getElementById('ml-canvas-shortcuts') as HTMLButtonElement, canvasShell: document.getElementById('ml-canvas-shell') as HTMLElement, sidebarRoot: document.querySelector('.maplab-sidebar') as HTMLElement, dockTabButtons: Array.from(document.querySelectorAll('button[data-dock-tab]')), contextHint: document.getElementById('ml-context-hint') as HTMLParagraphElement | null, shortcutsDialog: document.getElementById('ml-shortcuts-dialog') as HTMLDialogElement | null, shortcutsClose: document.getElementById('ml-shortcuts-close') as HTMLButtonElement | null }; const caLayerStatsEl = document.getElementById('ml-ca-layer-stats') as HTMLElement | null; const quickControls = { caMiniEnable: document.getElementById('ml-ca-mini-enable') as HTMLButtonElement | null, caMiniBaseResolution: document.getElementById('ml-ca-mini-base-resolution') as HTMLSelectElement | null, caMiniSubdivision: document.getElementById('ml-ca-mini-subdivision') as HTMLSelectElement | null, caMiniLayerPreset: document.getElementById('ml-ca-mini-layer-preset') as HTMLSelectElement | null }; function syncCaMiniControls(): void { if (!quickControls.caMiniEnable) { return; } quickControls.caMiniEnable.textContent = controls.caEnabled.value === '1' ? 'CA Macro: On' : 'CA Macro: Off'; if (quickControls.caMiniBaseResolution) { quickControls.caMiniBaseResolution.value = controls.caBaseResolution.value; } if (quickControls.caMiniSubdivision) { quickControls.caMiniSubdivision.value = controls.caSubdivision.value; } if (quickControls.caMiniLayerPreset) { quickControls.caMiniLayerPreset.value = controls.caLayerPreset.value; } } function createMapLabChannel(): BroadcastChannel | null { if (typeof BroadcastChannel === 'undefined') { return null; } try { return new BroadcastChannel(MAPLAB_CHANNEL_NAME); } catch { return null; } } function createMapLabWorker(): Worker | null { try { return new Worker(new URL('./maplab-worker.ts', import.meta.url), { type: 'module' }); } catch (error) { console.warn('[MapLab] Failed to start MapLab worker, falling back to main thread generation:', error); return null; } } function createMapLabImportWorker(): Worker | null { try { return new Worker(new URL('./maplab-terrain-import.worker.ts', import.meta.url), { type: 'module' }); } catch (error) { console.warn('[MapLab] Failed to start terrain import worker:', error); return null; } } function createBroadcastClientId(): string { const rand = Math.floor(Math.random() * 0x7fffffff).toString(16); return `maplab-${Date.now().toString(36)}-${rand}`; } function openMapLabLaunchDb(): Promise { return new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB is not available.')); return; } const request = indexedDB.open(MAPLAB_LAUNCH_DB_NAME, 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(MAPLAB_LAUNCH_DB_STORE)) { db.createObjectStore(MAPLAB_LAUNCH_DB_STORE, { keyPath: 'key' }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('Failed to open MapLab launch database.')); }); } function isQuotaExceededError(error: unknown): boolean { return error instanceof DOMException && error.name === 'QuotaExceededError'; } async function writeMapLabBundleToIndexedDb(key: string, bundle: StrategicMapBundle): Promise { const db = await openMapLabLaunchDb(); return await new Promise((resolve, reject) => { const tx = db.transaction(MAPLAB_LAUNCH_DB_STORE, 'readwrite'); const store = tx.objectStore(MAPLAB_LAUNCH_DB_STORE); const record: MapLabLaunchBundleRecord = { key, createdAt: Date.now(), bundle }; store.put(record); tx.oncomplete = () => { db.close(); resolve(key); }; tx.onerror = () => { db.close(); reject(tx.error ?? new Error('Failed to write launch bundle to IndexedDB.')); }; tx.onabort = () => { db.close(); reject(tx.error ?? new Error('Launch bundle write transaction aborted.')); }; }); } async function readMapLabBundleFromIndexedDb( key: string, options?: { consume?: boolean } ): Promise { let db: IDBDatabase | null = null; try { db = await openMapLabLaunchDb(); const consume = options?.consume === true; return await new Promise((resolve, reject) => { const tx = db!.transaction(MAPLAB_LAUNCH_DB_STORE, consume ? 'readwrite' : 'readonly'); const store = tx.objectStore(MAPLAB_LAUNCH_DB_STORE); const request = store.get(key); request.onsuccess = () => { const record = (request.result ?? null) as MapLabLaunchBundleRecord | null; resolve(record?.bundle ?? null); }; request.onerror = () => reject(request.error ?? new Error('Failed to read MapLab bundle from IndexedDB.')); if (consume) { store.delete(key); } tx.onabort = () => reject(tx.error ?? new Error('MapLab bundle IndexedDB transaction aborted.')); tx.onerror = () => reject(tx.error ?? new Error('MapLab bundle IndexedDB transaction failed.')); }); } finally { db?.close(); } } async function writeLaunchBundleToIndexedDb(bundle: StrategicMapBundle): Promise { return writeMapLabBundleToIndexedDb(MAPLAB_LAUNCH_BUNDLE_KEY, bundle); } function readStoredLatestBundleRef(): MapLabBundleRef | null { try { const raw = window.localStorage.getItem(STORAGE_KEYS.latestBundleRef); if (!raw) return null; const parsed = JSON.parse(raw) as Partial; if (parsed.transport !== 'indexeddb' && parsed.transport !== 'localStorage') return null; return { version: 1, transport: parsed.transport, key: typeof parsed.key === 'string' && parsed.key ? parsed.key : undefined, createdAt: Number.isFinite(parsed.createdAt) ? Number(parsed.createdAt) : Date.now(), hash: typeof parsed.hash === 'string' ? parsed.hash : undefined }; } catch { return null; } } function writeStoredLatestBundleRef(ref: MapLabBundleRef): void { window.localStorage.setItem(STORAGE_KEYS.latestBundleRef, JSON.stringify(ref)); } function clearStoredLatestBundleRef(): void { window.localStorage.removeItem(STORAGE_KEYS.latestBundleRef); } function getSlotIndexedDbKey(slot: string): string { return `slot.${slot}`; } function readStoredSlotRef(slot: string): MapLabBundleRef | null { const refKey = STORAGE_KEYS.slotRef(slot); const stores: Storage[] = [window.localStorage, window.sessionStorage]; for (const store of stores) { try { const raw = store.getItem(refKey); if (!raw) continue; const parsed = JSON.parse(raw) as Partial; if (parsed.transport !== 'indexeddb' && parsed.transport !== 'localStorage') continue; return { version: 1, transport: parsed.transport, key: typeof parsed.key === 'string' && parsed.key ? parsed.key : undefined, createdAt: Number.isFinite(parsed.createdAt) ? Number(parsed.createdAt) : Date.now(), hash: typeof parsed.hash === 'string' ? parsed.hash : undefined }; } catch { // Try next storage backend. } } return null; } function writeStoredSlotRef(slot: string, ref: MapLabBundleRef): void { const payload = JSON.stringify(ref); const refKey = STORAGE_KEYS.slotRef(slot); try { window.localStorage.setItem(refKey, payload); return; } catch { // localStorage may be full; try sessionStorage. } try { window.sessionStorage.setItem(refKey, payload); } catch { // No persistent reference available; load path still probes deterministic IndexedDB key. } } function clearStoredSlotRef(slot: string): void { const refKey = STORAGE_KEYS.slotRef(slot); try { window.localStorage.removeItem(refKey); } catch { // Ignore. } try { window.sessionStorage.removeItem(refKey); } catch { // Ignore. } } async function loadLatestBundleFromAnyStore(): Promise { const raw = window.localStorage.getItem(STORAGE_KEYS.latestBundle); if (raw) { return deserializeStrategicBundle(raw); } const ref = readStoredLatestBundleRef(); if (!ref || ref.transport !== 'indexeddb') { return null; } const key = ref.key ?? MAPLAB_LATEST_BUNDLE_DB_KEY; return await readMapLabBundleFromIndexedDb(key); } function cloneOverlayBundleForEdit(bundle: StrategicOverlayBundle | undefined): StrategicOverlayBundle | undefined { if (!bundle) return undefined; return { version: 1, raster: (bundle.raster ?? []).map((layer) => ({ id: layer.id, label: layer.label, kind: layer.kind, width: layer.width, height: layer.height, data: Array.isArray(layer.data) ? layer.data.slice() : [], unit: layer.unit, range: layer.range ? { ...layer.range } : undefined, authority: layer.authority, updatedAt: layer.updatedAt, tags: Array.isArray(layer.tags) ? layer.tags.slice() : undefined })), primitives: (bundle.primitives ?? []).map((primitive) => ({ id: primitive.id, layerId: primitive.layerId, type: primitive.type, points: (primitive.points ?? []).map((point) => ({ ...point })), authority: primitive.authority, closed: primitive.closed, radius: primitive.radius, label: primitive.label, metadata: primitive.metadata ? { ...primitive.metadata } : undefined, style: primitive.style ? { ...primitive.style } : undefined })), authority: bundle.authority ? { ...bundle.authority } : undefined, effects: bundle.effects ? { ...bundle.effects } : undefined, metadata: bundle.metadata ? { ...bundle.metadata } : undefined }; } function cloneCaRulesForEdit( rules: NonNullable | undefined ): NonNullable | undefined { if (!Array.isArray(rules)) return undefined; return rules.map((rule) => ({ iterations: Number.isFinite(rule.iterations) ? Number(rule.iterations) : undefined, birthMin: Number.isFinite(rule.birthMin) ? Number(rule.birthMin) : undefined, birthMax: Number.isFinite(rule.birthMax) ? Number(rule.birthMax) : undefined, surviveMin: Number.isFinite(rule.surviveMin) ? Number(rule.surviveMin) : undefined, surviveMax: Number.isFinite(rule.surviveMax) ? Number(rule.surviveMax) : undefined, randomBirthChance: Number.isFinite(rule.randomBirthChance) ? Number(rule.randomBirthChance) : undefined, randomKillChance: Number.isFinite(rule.randomKillChance) ? Number(rule.randomKillChance) : undefined })); } function cloneCaPostprocessForEdit( postprocess: RecipeCaConfig['postprocess'] ): RecipeCaConfig['postprocess'] { if (!postprocess) return undefined; return { enabled: postprocess.enabled === true, smoothingIterations: Number.isFinite(postprocess.smoothingIterations) ? Number(postprocess.smoothingIterations) : undefined, smoothingStrength: Number.isFinite(postprocess.smoothingStrength) ? Number(postprocess.smoothingStrength) : undefined, preserveGameplayMasks: postprocess.preserveGameplayMasks !== false, criticalMaskMaxDrift: Number.isFinite(postprocess.criticalMaskMaxDrift) ? Number(postprocess.criticalMaskMaxDrift) : undefined, detailRecoveryEnabled: postprocess.detailRecoveryEnabled === true, detailTreeDensity: Number.isFinite(postprocess.detailTreeDensity) ? Number(postprocess.detailTreeDensity) : undefined, detailRockDensity: Number.isFinite(postprocess.detailRockDensity) ? Number(postprocess.detailRockDensity) : undefined, detailMaterialVariation: Number.isFinite(postprocess.detailMaterialVariation) ? Number(postprocess.detailMaterialVariation) : undefined }; } function cloneCaLayersForEdit(layers: RecipeCaLayer[] | undefined): RecipeCaLayer[] { if (!Array.isArray(layers)) return []; return layers.map((layer) => ({ id: layer.id, label: layer.label, enabled: layer.enabled !== false, seedDensity: Number.isFinite(layer.seedDensity) ? Number(layer.seedDensity) : undefined, baseHeightDelta: Number.isFinite(layer.baseHeightDelta) ? Number(layer.baseHeightDelta) : undefined, subdivisionSteps: Number.isFinite(layer.subdivisionSteps) ? Number(layer.subdivisionSteps) : undefined, rules: cloneCaRulesForEdit(layer.rules) })); } function createDefaultCaPostprocessForEdit(): RecipeCaPostprocess { return { enabled: false, smoothingIterations: DEFAULT_CA_POSTPROCESS_ITERATIONS, smoothingStrength: DEFAULT_CA_POSTPROCESS_STRENGTH, preserveGameplayMasks: true, criticalMaskMaxDrift: DEFAULT_CA_POSTPROCESS_CRITICAL_MAX_DRIFT, detailRecoveryEnabled: false, detailTreeDensity: DEFAULT_CA_DETAIL_TREE_DENSITY, detailRockDensity: DEFAULT_CA_DETAIL_ROCK_DENSITY, detailMaterialVariation: DEFAULT_CA_DETAIL_MATERIAL_VARIATION }; } function createDefaultCaConfigForEdit(): RecipeCaConfig { return { enabled: false, gridBaseResolution: DEFAULT_CA_BASE_RESOLUTION, subdivisionSteps: DEFAULT_CA_SUBDIVISION_STEPS, symmetryMode: 'follow-recipe', ruleSeedOffset: DEFAULT_CA_RULE_SEED_OFFSET, postprocess: createDefaultCaPostprocessForEdit(), layers: cloneCaLayersForEdit(CA_LAYER_PRESETS['paper-default']) }; } function asCaLayerPresetId(value: string): CaLayerPresetId { if ((CA_LAYER_PRESET_VALUES as string[]).includes(value)) { return value as CaLayerPresetId; } return 'paper-default'; } function normalizePresetRuleValue(value: number | undefined): string { if (!Number.isFinite(value)) return '-'; return Number(value).toFixed(3); } function buildCaLayerSignature(layers: RecipeCaLayer[] | undefined): string { if (!Array.isArray(layers) || layers.length <= 0) return ''; return layers .map((layer) => { const ruleSignature = (layer.rules ?? []) .map((rule) => [ normalizePresetRuleValue(rule.iterations), normalizePresetRuleValue(rule.birthMin), normalizePresetRuleValue(rule.birthMax), normalizePresetRuleValue(rule.surviveMin), normalizePresetRuleValue(rule.surviveMax), normalizePresetRuleValue(rule.randomBirthChance), normalizePresetRuleValue(rule.randomKillChance) ].join(',')) .join(';'); return [ (layer.id ?? '').trim().toLowerCase(), normalizePresetRuleValue(layer.seedDensity), normalizePresetRuleValue(layer.baseHeightDelta), normalizePresetRuleValue(layer.subdivisionSteps), ruleSignature ].join('|'); }) .join('||'); } function resolveCaPresetIdForRecipe(recipe: StrategicMapRecipe): CaLayerPresetId { const storedPresetRaw = recipe.metadata?.maplabEditorState?.caLayerPreset; if (typeof storedPresetRaw === 'string') { const storedPreset = asCaLayerPresetId(storedPresetRaw); if (storedPreset !== 'paper-default' || storedPresetRaw === 'paper-default') { return storedPreset; } } const signature = buildCaLayerSignature(recipe.ca?.layers ? cloneCaLayersForEdit(recipe.ca.layers) : []); if (!signature) return 'paper-default'; const presetIds: Array> = ['paper-default', 'continental', 'fragmented']; for (const presetId of presetIds) { const presetSignature = buildCaLayerSignature(cloneCaLayersForEdit(CA_LAYER_PRESETS[presetId])); if (signature === presetSignature) { return presetId; } } return 'custom'; } function cloneCellularAutomataConfigForEdit(config: StrategicMapRecipe['ca']): StrategicMapRecipe['ca'] { if (!config) return undefined; return { enabled: config.enabled === true, gridBaseResolution: Number.isFinite(config.gridBaseResolution) ? Number(config.gridBaseResolution) : undefined, subdivisionSteps: Number.isFinite(config.subdivisionSteps) ? Number(config.subdivisionSteps) : undefined, symmetryMode: config.symmetryMode, ruleSeedOffset: Number.isFinite(config.ruleSeedOffset) ? Number(config.ruleSeedOffset) : undefined, postprocess: cloneCaPostprocessForEdit(config.postprocess), layers: cloneCaLayersForEdit(config.layers as RecipeCaLayer[] | undefined) }; } function cloneMarkerGuidanceConfigForEdit( config: StrategicMapRecipe['markerGuidance'] ): StrategicMapRecipe['markerGuidance'] { if (!config) return undefined; return { enabled: config.enabled !== false, spawnMinDistanceMeters: Number.isFinite(config.spawnMinDistanceMeters) ? Number(config.spawnMinDistanceMeters) : undefined, spawnMaxSlope: Number.isFinite(config.spawnMaxSlope) ? Number(config.spawnMaxSlope) : undefined, spawnMinBuildableCoverage: Number.isFinite(config.spawnMinBuildableCoverage) ? Number(config.spawnMinBuildableCoverage) : undefined, spawnBuildableProbeRadiusMeters: Number.isFinite(config.spawnBuildableProbeRadiusMeters) ? Number(config.spawnBuildableProbeRadiusMeters) : undefined, resourceMinDistanceMeters: Number.isFinite(config.resourceMinDistanceMeters) ? Number(config.resourceMinDistanceMeters) : undefined, resourceSpawnSeparationFactor: Number.isFinite(config.resourceSpawnSeparationFactor) ? Number(config.resourceSpawnSeparationFactor) : undefined, spawnScoreThreshold: Number.isFinite(config.spawnScoreThreshold) ? Number(config.spawnScoreThreshold) : undefined, resourceScoreThreshold: Number.isFinite(config.resourceScoreThreshold) ? Number(config.resourceScoreThreshold) : undefined, competitiveSymmetryLock: config.competitiveSymmetryLock !== false }; } function createDefaultMarkerGuidanceConfigForEdit(mapSizeMeters: number): RecipeMarkerGuidanceConfig { return { enabled: true, spawnMinDistanceMeters: Math.max(DEFAULT_MARKER_SPAWN_MIN_DISTANCE_METERS, mapSizeMeters * 0.24), spawnMaxSlope: DEFAULT_MARKER_SPAWN_MAX_SLOPE, spawnMinBuildableCoverage: DEFAULT_MARKER_SPAWN_MIN_BUILDABLE_COVERAGE, spawnBuildableProbeRadiusMeters: Math.max(DEFAULT_MARKER_BUILDABLE_PROBE_RADIUS_METERS, mapSizeMeters * 0.012), resourceMinDistanceMeters: Math.max(DEFAULT_MARKER_RESOURCE_MIN_DISTANCE_METERS, mapSizeMeters * 0.055), resourceSpawnSeparationFactor: DEFAULT_MARKER_RESOURCE_SPAWN_SEPARATION_FACTOR, spawnScoreThreshold: DEFAULT_MARKER_SPAWN_SCORE_THRESHOLD, resourceScoreThreshold: DEFAULT_MARKER_RESOURCE_SCORE_THRESHOLD, competitiveSymmetryLock: true }; } function clampCaNumber(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; if (value <= min) return min; if (value >= max) return max; return value; } function normalizeCaPostprocessForEditor(postprocess: RecipeCaConfig['postprocess']): RecipeCaPostprocess { const defaults = createDefaultCaPostprocessForEdit(); return { enabled: postprocess?.enabled === true, smoothingIterations: Math.floor(clampCaNumber( Number(postprocess?.smoothingIterations ?? defaults.smoothingIterations), 0, 8 )), smoothingStrength: clampCaNumber( Number(postprocess?.smoothingStrength ?? defaults.smoothingStrength), 0, 1 ), preserveGameplayMasks: postprocess?.preserveGameplayMasks !== false, criticalMaskMaxDrift: clampCaNumber( Number(postprocess?.criticalMaskMaxDrift ?? defaults.criticalMaskMaxDrift), 0, 64 ), detailRecoveryEnabled: postprocess?.detailRecoveryEnabled === true, detailTreeDensity: clampCaNumber( Number(postprocess?.detailTreeDensity ?? defaults.detailTreeDensity), 0, 2 ), detailRockDensity: clampCaNumber( Number(postprocess?.detailRockDensity ?? defaults.detailRockDensity), 0, 2 ), detailMaterialVariation: clampCaNumber( Number(postprocess?.detailMaterialVariation ?? defaults.detailMaterialVariation), 0, 2 ) }; } function ensureMarkerGuidanceConfigForEditing(state: AppState): RecipeMarkerGuidanceConfig { const mapSizeMeters = Math.max(1, state.recipe.mapSizeMeters || MAP_SIZE_PRESET_METERS.medium); const normalized = normalizeMarkerGuidanceConfigForEditor(mapSizeMeters, state.recipe.markerGuidance); state.recipe.markerGuidance = normalized; return normalized; } function normalizeMarkerGuidanceConfigForEditor( mapSizeMeters: number, config: StrategicMapRecipe['markerGuidance'] ): RecipeMarkerGuidanceConfig { const defaults = createDefaultMarkerGuidanceConfigForEdit(mapSizeMeters); const current = cloneMarkerGuidanceConfigForEdit(config) ?? defaults; return { enabled: current.enabled !== false, spawnMinDistanceMeters: clampCaNumber( Number(current.spawnMinDistanceMeters ?? defaults.spawnMinDistanceMeters), 200, mapSizeMeters * 0.62 ), spawnMaxSlope: clampCaNumber( Number(current.spawnMaxSlope ?? defaults.spawnMaxSlope), 0.02, 1 ), spawnMinBuildableCoverage: clampCaNumber( Number(current.spawnMinBuildableCoverage ?? defaults.spawnMinBuildableCoverage), 0, 1 ), spawnBuildableProbeRadiusMeters: clampCaNumber( Number(current.spawnBuildableProbeRadiusMeters ?? defaults.spawnBuildableProbeRadiusMeters), 60, mapSizeMeters * 0.2 ), resourceMinDistanceMeters: clampCaNumber( Number(current.resourceMinDistanceMeters ?? defaults.resourceMinDistanceMeters), 60, mapSizeMeters * 0.3 ), resourceSpawnSeparationFactor: clampCaNumber( Number(current.resourceSpawnSeparationFactor ?? defaults.resourceSpawnSeparationFactor), 0.1, 1.2 ), spawnScoreThreshold: clampCaNumber( Number(current.spawnScoreThreshold ?? defaults.spawnScoreThreshold), 0.05, 0.95 ), resourceScoreThreshold: clampCaNumber( Number(current.resourceScoreThreshold ?? defaults.resourceScoreThreshold), 0.05, 0.95 ), competitiveSymmetryLock: current.competitiveSymmetryLock !== false }; } function defaultBaseHeightForCaLayer(layerId: string): number { const normalized = layerId.trim().toLowerCase(); const fromPreset = CA_LAYER_PRESETS['paper-default'].find((layer) => layer.id === normalized)?.baseHeightDelta; if (Number.isFinite(fromPreset)) return Number(fromPreset); return 0; } function normalizeCaRuleForEditor(rule: RecipeCaRule | undefined): RecipeCaRule { const birthA = Number.isFinite(rule?.birthMin) ? Math.floor(Number(rule?.birthMin)) : 4; const birthB = Number.isFinite(rule?.birthMax) ? Math.floor(Number(rule?.birthMax)) : 5; const surviveA = Number.isFinite(rule?.surviveMin) ? Math.floor(Number(rule?.surviveMin)) : 3; const surviveB = Number.isFinite(rule?.surviveMax) ? Math.floor(Number(rule?.surviveMax)) : 6; return { iterations: Math.floor(clampCaNumber(Number(rule?.iterations ?? DEFAULT_CA_RULE_TEMPLATE.iterations), 1, 12)), birthMin: Math.min( Math.floor(clampCaNumber(birthA, 0, 8)), Math.floor(clampCaNumber(birthB, 0, 8)) ), birthMax: Math.max( Math.floor(clampCaNumber(birthA, 0, 8)), Math.floor(clampCaNumber(birthB, 0, 8)) ), surviveMin: Math.min( Math.floor(clampCaNumber(surviveA, 0, 8)), Math.floor(clampCaNumber(surviveB, 0, 8)) ), surviveMax: Math.max( Math.floor(clampCaNumber(surviveA, 0, 8)), Math.floor(clampCaNumber(surviveB, 0, 8)) ), randomBirthChance: clampCaNumber(Number(rule?.randomBirthChance ?? DEFAULT_CA_RULE_TEMPLATE.randomBirthChance), 0, 1), randomKillChance: clampCaNumber(Number(rule?.randomKillChance ?? DEFAULT_CA_RULE_TEMPLATE.randomKillChance), 0, 1) }; } function normalizeCaLayerForEditor(layer: RecipeCaLayer, index: number): RecipeCaLayer { const id = typeof layer.id === 'string' && layer.id.trim().length > 0 ? layer.id.trim().toLowerCase() : `layer_${index + 1}`; const label = typeof layer.label === 'string' && layer.label.trim().length > 0 ? layer.label.trim() : id; const rulesRaw = Array.isArray(layer.rules) && layer.rules.length > 0 ? layer.rules : [DEFAULT_CA_RULE_TEMPLATE]; const rules = rulesRaw.map((rule) => normalizeCaRuleForEditor(rule)); return { ...layer, id, label, enabled: layer.enabled !== false, seedDensity: clampCaNumber(Number(layer.seedDensity ?? 0.5), 0.01, 0.99), baseHeightDelta: clampCaNumber( Number(layer.baseHeightDelta ?? defaultBaseHeightForCaLayer(id)), -256, 256 ), subdivisionSteps: Math.floor(clampCaNumber(Number(layer.subdivisionSteps ?? DEFAULT_CA_SUBDIVISION_STEPS), 0, 6)), rules }; } function clonePresetCaLayersForEditor(preset: Exclude): RecipeCaLayer[] { return cloneCaLayersForEdit(CA_LAYER_PRESETS[preset]).map((layer, index) => normalizeCaLayerForEditor(layer, index)); } function ensureCaConfigForEditing(state: AppState): RecipeCaConfig { const current = cloneCellularAutomataConfigForEdit(state.recipe.ca) ?? createDefaultCaConfigForEdit(); const selectedPreset = asCaLayerPresetId(controls.caLayerPreset.value); const fallbackPreset: Exclude = selectedPreset === 'custom' ? 'paper-default' : selectedPreset; const inputLayers = Array.isArray(current.layers) && current.layers.length > 0 ? current.layers : clonePresetCaLayersForEditor(fallbackPreset); const normalizedLayers = inputLayers.map((layer, index) => normalizeCaLayerForEditor(layer, index)); const normalized: RecipeCaConfig = { enabled: current.enabled === true, gridBaseResolution: Math.floor(clampCaNumber(Number(current.gridBaseResolution ?? DEFAULT_CA_BASE_RESOLUTION), 16, 512)), subdivisionSteps: Math.floor(clampCaNumber(Number(current.subdivisionSteps ?? DEFAULT_CA_SUBDIVISION_STEPS), 0, 6)), symmetryMode: current.symmetryMode ?? 'follow-recipe', ruleSeedOffset: Number.isFinite(current.ruleSeedOffset) ? Math.floor(Number(current.ruleSeedOffset)) : DEFAULT_CA_RULE_SEED_OFFSET, postprocess: normalizeCaPostprocessForEditor(current.postprocess), layers: normalizedLayers }; state.recipe.ca = normalized; return normalized; } function parseEditorIndex(value: string, length: number, fallback = 0): number { if (length <= 0) return 0; const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) return Math.max(0, Math.min(length - 1, fallback)); return Math.max(0, Math.min(length - 1, Math.floor(parsed))); } function formatEditorNumber(value: number, decimals = 3): string { if (!Number.isFinite(value)) return '0'; const fixed = value.toFixed(decimals); return fixed.replace(/\.?0+$/, ''); } function formatPercent(value: number): string { if (!Number.isFinite(value)) return '-'; const clamped = Math.max(0, Math.min(1, value)); return `${(clamped * 100).toFixed(1)}%`; } function renderCaLayerStats(state: AppState): void { if (!caLayerStatsEl) return; caLayerStatsEl.innerHTML = ''; const stats = state.caLayerStats ?? []; if (stats.length <= 0) { caLayerStatsEl.textContent = 'CA macro layers are not active.'; return; } stats.forEach((entry: WorkerCaLayerStats) => { const row = document.createElement('div'); row.className = 'layer-entry'; const title = document.createElement('strong'); title.textContent = entry.label || entry.layerId; row.appendChild(title); const statRow = document.createElement('div'); statRow.className = 'layer-stats'; const coverage = document.createElement('span'); coverage.textContent = `coverage ${formatPercent(entry.activeRatio)}`; const components = document.createElement('span'); components.textContent = `components ${entry.componentCount}`; const mismatch = document.createElement('span'); mismatch.textContent = `symm mismatch ${entry.symmetryMismatch}`; statRow.append(coverage, components, mismatch); row.appendChild(statRow); caLayerStatsEl.appendChild(row); }); } function updateCaLayerStats(state: AppState, stats?: WorkerCaLayerStats[]): void { state.caLayerStats = Array.isArray(stats) ? stats : []; renderCaLayerStats(state); } function refreshCaPostprocessEditor(state: AppState): void { const caConfig = ensureCaConfigForEditing(state); const postprocess = normalizeCaPostprocessForEditor(caConfig.postprocess); caConfig.postprocess = postprocess; controls.caPostEnabled.value = postprocess.enabled === true ? '1' : '0'; controls.caPostIterations.value = String(Math.floor(clampCaNumber(Number(postprocess.smoothingIterations ?? 0), 0, 8))); controls.caPostStrength.value = formatEditorNumber(clampCaNumber(Number(postprocess.smoothingStrength ?? 0), 0, 1), 3); controls.caPostPreserveMasks.value = postprocess.preserveGameplayMasks === false ? '0' : '1'; controls.caPostMaxDrift.value = formatEditorNumber(clampCaNumber(Number(postprocess.criticalMaskMaxDrift ?? 0), 0, 64), 2); controls.caDetailEnabled.value = postprocess.detailRecoveryEnabled === true ? '1' : '0'; controls.caDetailTreeDensity.value = formatEditorNumber(clampCaNumber(Number(postprocess.detailTreeDensity ?? 1), 0, 2), 2); controls.caDetailRockDensity.value = formatEditorNumber(clampCaNumber(Number(postprocess.detailRockDensity ?? 1), 0, 2), 2); controls.caDetailMaterialVariation.value = formatEditorNumber(clampCaNumber(Number(postprocess.detailMaterialVariation ?? 1), 0, 2), 2); const smoothingSummary = postprocess.enabled ? `${Math.floor(postprocess.smoothingIterations ?? 0)} iterations @ ${(postprocess.smoothingStrength ?? 0).toFixed(2)}` : 'off'; const maskClampSummary = postprocess.preserveGameplayMasks === false ? 'off' : `<= ${Number(postprocess.criticalMaskMaxDrift ?? 0).toFixed(1)}`; const detailSummary = postprocess.detailRecoveryEnabled === true ? `on (T ${Number(postprocess.detailTreeDensity ?? 1).toFixed(2)}, R ${Number(postprocess.detailRockDensity ?? 1).toFixed(2)}, M ${Number(postprocess.detailMaterialVariation ?? 1).toFixed(2)})` : 'off'; controls.caPostReadout.textContent = `CA smoothing ${smoothingSummary} | gameplay clamp ${maskClampSummary} | detail ${detailSummary}`; } function refreshCaLayerEditor(state: AppState): void { refreshCaPostprocessEditor(state); const caConfig = ensureCaConfigForEditing(state); const layers = caConfig.layers ?? []; const editorDisabled = layers.length <= 0; controls.caLayerEditor.toggleAttribute('data-disabled', editorDisabled); if (editorDisabled) { controls.caLayerSelect.innerHTML = ''; controls.caRuleSelect.innerHTML = ''; controls.caRuleRemove.disabled = true; controls.caLayerReadout.textContent = 'CA layer editor unavailable.'; return; } const selectedLayerIndex = parseEditorIndex(controls.caLayerSelect.value, layers.length); controls.caLayerSelect.innerHTML = ''; for (let i = 0; i < layers.length; i += 1) { const layer = layers[i]; const option = document.createElement('option'); option.value = String(i); option.textContent = layer.label ?? layer.id; controls.caLayerSelect.appendChild(option); } controls.caLayerSelect.value = String(selectedLayerIndex); const layer = layers[selectedLayerIndex]; const rules = Array.isArray(layer.rules) && layer.rules.length > 0 ? layer.rules : [normalizeCaRuleForEditor(undefined)]; layer.rules = rules; controls.caLayerEnabled.value = layer.enabled === false ? '0' : '1'; controls.caLayerSeedDensity.value = formatEditorNumber(Number(layer.seedDensity ?? 0.5), 3); controls.caLayerBaseHeightDelta.value = formatEditorNumber(Number(layer.baseHeightDelta ?? 0), 2); controls.caLayerSubdivision.value = String(Math.floor(clampCaNumber(Number(layer.subdivisionSteps ?? 0), 0, 6))); const selectedRuleIndex = parseEditorIndex(controls.caRuleSelect.value, rules.length); controls.caRuleSelect.innerHTML = ''; for (let i = 0; i < rules.length; i += 1) { const option = document.createElement('option'); option.value = String(i); option.textContent = `Rule ${i + 1}`; controls.caRuleSelect.appendChild(option); } controls.caRuleSelect.value = String(selectedRuleIndex); controls.caRuleRemove.disabled = rules.length <= 1; const rule = normalizeCaRuleForEditor(rules[selectedRuleIndex]); rules[selectedRuleIndex] = rule; controls.caRuleIterations.value = String(Math.floor(clampCaNumber(Number(rule.iterations ?? 2), 1, 12))); controls.caRuleBirthMin.value = String(Math.floor(clampCaNumber(Number(rule.birthMin ?? 4), 0, 8))); controls.caRuleBirthMax.value = String(Math.floor(clampCaNumber(Number(rule.birthMax ?? 5), 0, 8))); controls.caRuleSurviveMin.value = String(Math.floor(clampCaNumber(Number(rule.surviveMin ?? 3), 0, 8))); controls.caRuleSurviveMax.value = String(Math.floor(clampCaNumber(Number(rule.surviveMax ?? 6), 0, 8))); controls.caRuleRandomBirth.value = formatEditorNumber(clampCaNumber(Number(rule.randomBirthChance ?? 0), 0, 1), 3); controls.caRuleRandomKill.value = formatEditorNumber(clampCaNumber(Number(rule.randomKillChance ?? 0), 0, 1), 3); const presetLabel = asCaLayerPresetId(controls.caLayerPreset.value); controls.caLayerReadout.textContent = `Layer ${selectedLayerIndex + 1}/${layers.length} | id ${layer.id} | rules ${rules.length} | preset ${presetLabel}`; } function refreshMarkerGuidanceEditor(state: AppState): void { const config = ensureMarkerGuidanceConfigForEditing(state); controls.markerGuidanceEnabled.value = config.enabled === false ? '0' : '1'; controls.markerSpawnMinDistance.value = formatEditorNumber(Number(config.spawnMinDistanceMeters ?? 0), 1); controls.markerSpawnMaxSlope.value = formatEditorNumber(Number(config.spawnMaxSlope ?? 0), 3); controls.markerSpawnMinBuildable.value = formatEditorNumber(Number(config.spawnMinBuildableCoverage ?? 0), 3); controls.markerProbeRadius.value = formatEditorNumber(Number(config.spawnBuildableProbeRadiusMeters ?? 0), 1); controls.markerResourceMinDistance.value = formatEditorNumber(Number(config.resourceMinDistanceMeters ?? 0), 1); controls.markerResourceSpawnSeparation.value = formatEditorNumber(Number(config.resourceSpawnSeparationFactor ?? 0), 3); controls.markerCompetitiveSymmetryLock.value = config.competitiveSymmetryLock === false ? '0' : '1'; controls.markerGuidanceReadout.textContent = `Marker guidance ${config.enabled === false ? 'off' : 'on'} | spawn min ${Math.round(config.spawnMinDistanceMeters ?? 0)}m | slope <= ${(config.spawnMaxSlope ?? 0).toFixed(2)} | buildable >= ${(config.spawnMinBuildableCoverage ?? 0).toFixed(2)}`; } function applyCaEditorMutation( state: AppState, message: string, mutator: (config: RecipeCaConfig) => void ): void { const config = ensureCaConfigForEditing(state); mutator(config); controls.caLayerPreset.value = 'custom'; state.recipe.ca = config; refreshCaLayerEditor(state); scheduleInteractiveRegenerate(state, 0, 1200); setStatusThrottled(state, message, true); } function applyCaPresetSelectionToRecipe(state: AppState, preset: CaLayerPresetId): void { const config = ensureCaConfigForEditing(state); if (preset !== 'custom') { config.layers = clonePresetCaLayersForEditor(preset); state.recipe.ca = config; } refreshCaLayerEditor(state); } function applyMarkerGuidanceMutation( state: AppState, message: string, mutator: (config: RecipeMarkerGuidanceConfig) => void ): void { const config = ensureMarkerGuidanceConfigForEditing(state); mutator(config); state.recipe.markerGuidance = config; refreshMarkerGuidanceEditor(state); scheduleInteractiveRegenerate(state, 0, 1200); setStatusThrottled(state, message, true); } function cloneRecipeForEdit(recipe: StrategicMapRecipe): StrategicMapRecipe { const terrainImport = recipe.metadata?.terrainImport; return { ...recipe, featureControls: recipe.featureControls ? Object.fromEntries( Object.entries(recipe.featureControls).map(([key, value]) => [key, value ? { ...value } : value]) ) as StrategicMapRecipe['featureControls'] : {}, manualStamps: (recipe.manualStamps ?? []).map((stamp) => ({ ...stamp })), areaFeatures: (recipe.areaFeatures ?? []).map((feature) => ({ ...feature, points: (feature.points ?? []).map((point) => ({ ...point })), profile: feature.profile ? { ...feature.profile } : undefined })), splineFeatures: (recipe.splineFeatures ?? []).map((feature) => ({ ...feature, points: (feature.points ?? []).map((point) => ({ ...point })), profile: feature.profile ? { ...feature.profile } : undefined })), overlays: cloneOverlayBundleForEdit(recipe.overlays), ca: cloneCellularAutomataConfigForEdit(recipe.ca), markerGuidance: cloneMarkerGuidanceConfigForEdit(recipe.markerGuidance), featureFlags: recipe.featureFlags ? { ...recipe.featureFlags } : undefined, tuningOverrides: recipe.tuningOverrides ? { ...recipe.tuningOverrides } : undefined, metadata: recipe.metadata ? { ...recipe.metadata, ...(terrainImport ? { terrainImport: { ...terrainImport, gridSpec: { ...terrainImport.gridSpec, origin: { ...terrainImport.gridSpec.origin }, geo: terrainImport.gridSpec.geo ? { ...terrainImport.gridSpec.geo, bbox: { ...terrainImport.gridSpec.geo.bbox } } : undefined }, recipes: terrainImport.recipes.map(cloneImportRecipeEntry), attribution: terrainImport.attribution.map(cloneAttributionEntry) } } : {}) } : undefined }; } function recipesDiffer(a: StrategicMapRecipe, b: StrategicMapRecipe): boolean { return JSON.stringify(a) !== JSON.stringify(b); } function updateHistoryControls(state: AppState): void { controls.undo.disabled = state.historyPast.length <= 0; controls.redo.disabled = state.historyFuture.length <= 0; } function beginHistoryTransaction(state: AppState): void { if (state.historyPendingBefore) return; state.historyPendingBefore = cloneRecipeForEdit(state.recipe); } function commitHistoryTransaction(state: AppState): boolean { const before = state.historyPendingBefore; state.historyPendingBefore = null; if (!before) return false; if (!recipesDiffer(before, state.recipe)) { updateHistoryControls(state); return false; } state.historyPast.push(before); if (state.historyPast.length > HISTORY_MAX_ENTRIES) { state.historyPast.splice(0, state.historyPast.length - HISTORY_MAX_ENTRIES); } state.historyFuture = []; updateHistoryControls(state); return true; } function applyRecipeFromHistory(state: AppState, recipe: StrategicMapRecipe, message: string): void { state.recipe = ensureFeatureCollections(cloneRecipeForEdit(recipe)); clearManualStampSpatialIndex(state); syncTerrainImportStateFromRecipe(state, state.recipe); state.shapeDraft = null; state.brushStroke = null; state.influencePaintStroke = null; state.geometryEditDrag = null; state.propertyHandleDrag = null; state.splineGizmoDrag = null; state.selectedFeatureRef = null; state.selectedFeaturePointIndex = null; applyRecipeToControls(state); updateControlReadouts(); refreshFeatureEditor(state); syncToolModeUi(state); requestRender(state); scheduleInteractiveRegenerate(state, 0, 900); setStatus(message); } function undoHistory(state: AppState): boolean { const previous = state.historyPast.pop(); if (!previous) { updateHistoryControls(state); return false; } state.historyFuture.push(cloneRecipeForEdit(state.recipe)); if (state.historyFuture.length > HISTORY_MAX_ENTRIES) { state.historyFuture.splice(0, state.historyFuture.length - HISTORY_MAX_ENTRIES); } state.historyPendingBefore = null; applyRecipeFromHistory(state, previous, 'Undo applied.'); updateHistoryControls(state); return true; } function redoHistory(state: AppState): boolean { const next = state.historyFuture.pop(); if (!next) { updateHistoryControls(state); return false; } state.historyPast.push(cloneRecipeForEdit(state.recipe)); if (state.historyPast.length > HISTORY_MAX_ENTRIES) { state.historyPast.splice(0, state.historyPast.length - HISTORY_MAX_ENTRIES); } state.historyPendingBefore = null; applyRecipeFromHistory(state, next, 'Redo applied.'); updateHistoryControls(state); return true; } function createBundleFromRoughConditions(rough: StrategicRoughHeightConditions): StrategicMapBundle | null { const width = Math.max(1, Math.floor(rough.width)); const height = Math.max(1, Math.floor(rough.height)); const expected = width * height; if (!Array.isArray(rough.normalizedHeightData) || rough.normalizedHeightData.length !== expected) { return null; } const minHeight = Number.isFinite(rough.minHeight) ? rough.minHeight : 0; const maxHeight = Number.isFinite(rough.maxHeight) ? rough.maxHeight : minHeight + 1; const span = Math.max(1e-6, maxHeight - minHeight); const heightData = new Float32Array(expected); for (let i = 0; i < expected; i += 1) { const normalized = Math.min(1, Math.max(0, Number(rough.normalizedHeightData[i] ?? 0))); heightData[i] = minHeight + normalized * span; } const mapSizeMeters = rough.worldMetrics?.mapSizeMeters ?? rough.recipe.mapSizeMeters ?? MAP_SIZE_PRESET_METERS.medium; const mapHalfSize = mapSizeMeters * 0.5; return { recipe: ensureFeatureCollections(cloneRecipeForEdit(rough.recipe)), heightData, width, height, worldMetrics: { mapSizeMeters, mapHalfSize, minX: -mapHalfSize, maxX: mapHalfSize, minZ: -mapHalfSize, maxZ: mapHalfSize, resolution: width }, hash: `rough-${rough.sourceBundleHash}`, generatedAt: rough.generatedAt }; } function readStoredRoughBundle(): StrategicMapBundle | null { try { const raw = window.localStorage.getItem(STORAGE_KEYS.latestRough); if (!raw) return null; const rough = deserializeStrategicRoughHeightConditions(raw); return createBundleFromRoughConditions(rough); } catch { return null; } } function hydrateFromCacheIfAvailable(state: AppState): void { if (state.bootHydrationDone) return; state.bootHydrationDone = true; const roughBundle = readStoredRoughBundle(); if (roughBundle) { state.bundle = roughBundle; state.recipe = ensureFeatureCollections(cloneRecipeForEdit(roughBundle.recipe)); clearManualStampSpatialIndex(state); syncTerrainImportStateFromRecipe(state, state.recipe); state.generationMode = 'preview'; state.historyPast = []; state.historyFuture = []; state.historyPendingBefore = null; applyRecipeToControls(state); updateControlReadouts(); refreshFeatureEditor(state); syncToolModeUi(state); updateHistoryControls(state); recomputeStats(state); upsertLayerFromBundleHeight(state, roughBundle); refreshLayerSelector(state); if (state.overlayAutoRange) { syncLayerRangeFromActive(state); } updateDataPanel(state); updateObjectiveReadout(state); requestRender(state); setStatus('Loaded cached MapLab preview.'); } void loadLatestBundleFromAnyStore() .then((bundle) => { if (!bundle) return; if (state.fullBundle && state.fullBundle.hash === bundle.hash) return; bundle.recipe = ensureFeatureCollections(bundle.recipe); state.bundle = bundle; state.fullBundle = bundle; state.generationMode = 'imported'; state.recipe = ensureFeatureCollections(cloneRecipeForEdit(bundle.recipe)); clearManualStampSpatialIndex(state); syncTerrainImportStateFromRecipe(state, state.recipe); state.historyPast = []; state.historyFuture = []; state.historyPendingBefore = null; applyRecipeToControls(state); updateControlReadouts(); refreshFeatureEditor(state); syncToolModeUi(state); updateHistoryControls(state); recomputeStats(state); upsertLayerFromBundleHeight(state, bundle); refreshLayerSelector(state); if (state.overlayAutoRange) { syncLayerRangeFromActive(state); } updateDataPanel(state); updateObjectiveReadout(state); requestRender(state); setStatus('Loaded cached MapLab full bundle.'); }) .catch(() => { // Best-effort cache hydration. }); } function toUint8Array(buffer: ArrayBuffer | undefined): Uint8Array | undefined { if (!buffer) return undefined; return new Uint8Array(buffer); } function transferToBundle(transfer: WorkerBundleTransfer): StrategicMapBundle { if (!transfer.heightDataBuffer) { throw new Error('Missing full heightDataBuffer in worker bundle transfer.'); } if (!transfer.recipe) { throw new Error('Missing recipe in worker bundle transfer.'); } const masks: StrategicMapMasks | undefined = transfer.masks ? { buildable: toUint8Array(transfer.masks.buildable), ramps: toUint8Array(transfer.masks.ramps), hydrology: toUint8Array(transfer.masks.hydrology), poi: toUint8Array(transfer.masks.poi), cover: toUint8Array(transfer.masks.cover), slow: toUint8Array(transfer.masks.slow), choke: toUint8Array(transfer.masks.choke), beach: toUint8Array(transfer.masks.beach) } : undefined; return { recipe: transfer.recipe, heightData: new Float32Array(transfer.heightDataBuffer), width: transfer.width, height: transfer.height, worldMetrics: transfer.worldMetrics, masks, analysis: transfer.analysis, graphSummary: transfer.graphSummary, pipelineStats: transfer.pipelineStats, hash: transfer.hash, generatedAt: transfer.generatedAt }; } function toOverlayColormapId(value: string): OverlayColormapId { if (value === 'thermal') return 1; if (value === 'terrain') return 2; return 0; } function markOverlayLayerDataChanged(state: AppState, layerId: string): void { const current = state.overlayLayerDataVersionById[layerId] ?? 0; state.overlayLayerDataVersionById[layerId] = current + 1; if (state.overlayFloatCache?.layerId === layerId) { state.overlayFloatCache = null; } } function upsertMapLabLayer(state: AppState, layer: Layer): void { state.layerStore.upsertLayer(layer); markOverlayLayerDataChanged(state, layer.meta.id); } function removeMapLabLayer(state: AppState, layerId: string): boolean { const removed = state.layerStore.removeLayer(layerId); if (removed) { delete state.overlayLayerDataVersionById[layerId]; if (state.overlayFloatCache?.layerId === layerId) { state.overlayFloatCache = null; } } return removed; } function upsertLayerFromBundleHeight( state: AppState, bundle: StrategicMapBundle, options?: { deriveSecondary?: boolean } ): void { const deriveSecondary = options?.deriveSecondary ?? true; const heightLayer: Layer = { meta: { id: 'height', label: 'Height', kind: 'float32', unit: 'm' }, data: bundle.heightData, width: bundle.width, height: bundle.height }; upsertMapLabLayer(state, heightLayer); if (deriveSecondary) { const cellSize = bundle.worldMetrics.mapSizeMeters / Math.max(1, bundle.width - 1); state.layerStore.deriveSlope('height', 'slope', cellSize); state.layerStore.derivePassability('slope', 'passability', 24); const noBuildLayer = state.layerStore.getLayerRef('intentNoBuild'); const passabilityLayer = state.layerStore.getLayerRef('passability'); if ( noBuildLayer && passabilityLayer && passabilityLayer.data instanceof Uint8Array && noBuildLayer.width === passabilityLayer.width && noBuildLayer.height === passabilityLayer.height ) { const noBuild = noBuildLayer.data instanceof Float32Array ? noBuildLayer.data : new Float32Array(noBuildLayer.data); const adjusted = new Uint8Array(passabilityLayer.data); for (let i = 0; i < adjusted.length; i += 1) { if (noBuild[i] >= NO_BUILD_BLOCK_THRESHOLD) { adjusted[i] = 0; } } upsertMapLabLayer(state, { meta: passabilityLayer.meta, data: adjusted, width: passabilityLayer.width, height: passabilityLayer.height }); } if (state.importedBaseLayer) { const base = resampleImportedBaseLayerToSize( state.importedBaseLayer, Math.max(1, state.importedBaseLayerWidth), Math.max(1, state.importedBaseLayerHeight), bundle.width, bundle.height ); const delta = new Float32Array(bundle.heightData.length); for (let i = 0; i < delta.length; i += 1) { delta[i] = bundle.heightData[i] - base[i]; } upsertMapLabLayer(state, { meta: { id: 'deltaBase', label: 'Delta vs Base', kind: 'float32', unit: 'm' }, data: delta, width: bundle.width, height: bundle.height }); } else { removeMapLabLayer(state, 'deltaBase'); } } else { removeMapLabLayer(state, 'slope'); removeMapLabLayer(state, 'passability'); removeMapLabLayer(state, 'deltaBase'); } if (deriveSecondary) { bundle.layerStats = state.layerStore.computeAllStats(); } } function refreshLayerSelector(state: AppState): void { const current = state.activeLayerId; const search = controls.layerSearch.value.trim().toLowerCase(); const allLayers = state.layerStore.listLayers(); const visible = search.length > 0 ? allLayers.filter((layer) => layer.id.toLowerCase().includes(search) || layer.label.toLowerCase().includes(search)) : allLayers; controls.layerActive.innerHTML = ''; for (const layer of visible) { const option = document.createElement('option'); option.value = layer.id; option.textContent = `${layer.label} (${layer.id})`; controls.layerActive.appendChild(option); } if (visible.length <= 0) { state.activeLayerId = ''; refreshOverlayStudioLayerSelector(state); return; } const next = visible.find((entry) => entry.id === current)?.id ?? visible[0].id; state.activeLayerId = next; controls.layerActive.value = next; refreshOverlayStudioLayerSelector(state); } function getOverlayStudioLayerOptions(state: AppState): Array<{ id: string; label: string }> { const options = new Map(); for (const preset of OVERLAY_STUDIO_DEFAULT_LAYERS) { options.set(preset.id, preset.label); } for (const layer of state.layerStore.listLayers()) { options.set(layer.id, layer.label); } const overlays = state.recipe.overlays; for (const layer of overlays?.raster ?? []) { if (!layer.id) continue; options.set(layer.id, layer.label || layer.id); } for (const key of Object.keys(overlays?.authority ?? {})) { if (!key) continue; if (!options.has(key)) { options.set(key, key); } } return Array.from(options.entries()) .map(([id, label]) => ({ id, label })) .sort((a, b) => a.label.localeCompare(b.label)); } function resolveOverlayLayerAuthority(state: AppState, layerId: string): StrategicOverlayAuthority { const explicit = state.recipe.overlays?.authority?.[layerId]; if (explicit === 'visual' || explicit === 'gameplay') return explicit; const fromPreset = OVERLAY_STUDIO_DEFAULT_LAYERS.find((entry) => entry.id === layerId)?.authority; return fromPreset ?? 'visual'; } function refreshOverlayStudioLayerSelector(state: AppState): void { const select = controls.overlayStudioLayer; const previous = select.value || state.activeLayerId || 'wetness'; const options = getOverlayStudioLayerOptions(state); select.innerHTML = ''; for (const optionDef of options) { const option = document.createElement('option'); option.value = optionDef.id; option.textContent = `${optionDef.label} (${optionDef.id})`; select.appendChild(option); } if (options.length <= 0) { const fallback = document.createElement('option'); fallback.value = 'wetness'; fallback.textContent = 'Wetness (wetness)'; select.appendChild(fallback); } const next = options.find((entry) => entry.id === previous)?.id ?? options[0]?.id ?? 'wetness'; select.value = next; controls.overlayStudioAuthority.value = resolveOverlayLayerAuthority(state, next); syncOverlayStudioReadout(state); } function syncOverlayStudioReadout(state: AppState): void { const readout = controls.overlayStudioReadout; if (!readout) return; const overlays = state.recipe.overlays; const selectedLayer = controls.overlayStudioLayer.value || 'wetness'; const primitives = (overlays?.primitives ?? []).filter((primitive) => primitive.layerId === selectedLayer); const byType = { area: primitives.filter((primitive) => primitive.type === 'area').length, spline: primitives.filter((primitive) => primitive.type === 'spline').length, waypoint: primitives.filter((primitive) => primitive.type === 'waypoint').length }; const authority = resolveOverlayLayerAuthority(state, selectedLayer); readout.textContent = [ `Layer: ${selectedLayer}`, `Authority: ${authority}`, `Primitives: ${primitives.length} (area ${byType.area} | spline ${byType.spline} | waypoint ${byType.waypoint})`, `Total overlay primitives: ${(overlays?.primitives ?? []).length}` ].join('\n'); } function syncLayerRangeFromActive(state: AppState): void { const layerId = state.activeLayerId; if (!layerId) return; const stats = state.layerStore.computeStats(layerId); if (!stats) return; state.overlayRangeMin = stats.min; state.overlayRangeMax = stats.max; controls.layerMin.value = stats.min.toFixed(3); controls.layerMax.value = stats.max.toFixed(3); } function applyLayerPreset(state: AppState, preset: string): void { if (preset === 'gameplay' && state.layerStore.hasLayer('passability')) { state.activeLayerId = 'passability'; } else if (preset === 'import-qa' && state.layerStore.hasLayer('deltaBase')) { state.activeLayerId = 'deltaBase'; } else { state.activeLayerId = state.layerStore.hasLayer('height') ? 'height' : state.activeLayerId; } refreshLayerSelector(state); syncLayerRangeFromActive(state); requestRender(state); } function renderGpuOverlay(state: AppState): boolean { if (!state.overlayRenderer || !state.bundle || !state.activeLayerId) return false; const layer = state.layerStore.getLayerRef(state.activeLayerId); const result = renderMapLabGpuOverlay({ renderer: state.overlayRenderer, layer, layerId: state.activeLayerId, layerDataVersion: state.overlayLayerDataVersionById[state.activeLayerId] ?? 0, min: state.overlayRangeMin, max: state.overlayRangeMax, alpha: state.overlayAlpha, colormapId: state.overlayColormap, floatCache: state.overlayFloatCache }); state.overlayFloatCache = result.floatCache; return result.rendered; } function updateCacheStatsUi(state: AppState): void { if (!cacheStatsEl) return; const total = state.cacheHits + state.cacheMisses; const hitRate = total > 0 ? (state.cacheHits / total) * 100 : 0; cacheStatsEl.textContent = `Cache: ${state.cacheHits} hits / ${state.cacheMisses} misses (${hitRate.toFixed(1)}% hit rate)`; } function updateImportPreview( min: number, max: number, histogram: number[], meta: Record ): void { if (!importPreviewEl) return; const maxBin = histogram.reduce((acc, value) => Math.max(acc, value), 1); const bars = histogram .map((value) => { const width = Math.max(1, Math.round((value / maxBin) * 16)); return `${'|'.repeat(width)} ${value}`; }) .slice(0, 24); importPreviewEl.textContent = [ `min: ${min.toFixed(2)} m`, `max: ${max.toFixed(2)} m`, `meta: ${JSON.stringify(meta)}`, 'histogram:', ...bars ].join('\n'); } function computePercentileRangeFromHistogram( min: number, max: number, histogram: number[], lowerQuantile: number, upperQuantile: number ): { min: number; max: number } { if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min || histogram.length <= 0) { return { min, max }; } const total = histogram.reduce((acc, value) => acc + Math.max(0, value), 0); if (total <= 0) return { min, max }; const lowTarget = total * Math.max(0, Math.min(1, lowerQuantile)); const highTarget = total * Math.max(0, Math.min(1, upperQuantile)); let cumulative = 0; let lowIdx = 0; let highIdx = histogram.length - 1; for (let i = 0; i < histogram.length; i += 1) { cumulative += Math.max(0, histogram[i]); if (cumulative >= lowTarget) { lowIdx = i; break; } } cumulative = 0; for (let i = 0; i < histogram.length; i += 1) { cumulative += Math.max(0, histogram[i]); if (cumulative >= highTarget) { highIdx = i; break; } } const span = max - min; const binCount = histogram.length; const rangedMin = min + (lowIdx / binCount) * span; const rangedMax = min + ((highIdx + 1) / binCount) * span; if (!Number.isFinite(rangedMin) || !Number.isFinite(rangedMax) || rangedMax <= rangedMin) { return { min, max }; } return { min: rangedMin, max: rangedMax }; } function applyTerrariumColorDefaults(state: AppState, msg: TerrainImportSuccessMessage): void { controls.layerColormap.value = 'terrain'; state.overlayColormap = toOverlayColormapId('terrain'); const nextAlpha = 0.92; state.overlayAlpha = nextAlpha; controls.layerAlpha.value = nextAlpha.toFixed(2); const robustRange = computePercentileRangeFromHistogram(msg.min, msg.max, msg.histogram, 0.03, 0.97); state.overlayAutoRange = false; state.overlayRangeMin = robustRange.min; state.overlayRangeMax = robustRange.max; controls.layerMin.value = robustRange.min.toFixed(2); controls.layerMax.value = robustRange.max.toFixed(2); OVERLAYS.heat.checked = true; syncToggleToolbar(OVERLAYS.heat); state.activeLayerId = 'height'; state.sidebarPanels.layerPinned = true; } function influenceModeToLayerId(mode: InfluenceMode): InfluenceLayerId | null { if (mode === 'lane') return 'intentMainLane'; if (mode === 'safe') return 'intentSafeExpansion'; if (mode === 'nobuild') return 'intentNoBuild'; if (mode === 'lock') return 'constraintLock'; return null; } function getInfluenceLayerMeta(layerId: InfluenceLayerId): Layer['meta'] { if (layerId === 'intentMainLane') { return { id: layerId, label: 'Intent: Main Lane', kind: 'float32', unit: 'pct', range: { min: 0, max: 1 } }; } if (layerId === 'intentSafeExpansion') { return { id: layerId, label: 'Intent: Safe Expansion', kind: 'float32', unit: 'pct', range: { min: 0, max: 1 } }; } if (layerId === 'intentNoBuild') { return { id: layerId, label: 'Intent: No-Build Zone', kind: 'float32', unit: 'pct', range: { min: 0, max: 1 } }; } return { id: layerId, label: 'Constraint: Height Lock', kind: 'uint8', unit: 'none', range: { min: 0, max: 1 } }; } function clamp01(value: number): number { if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(1, value)); } function resizeUint8Nearest( source: Uint8Array, sourceWidth: number, sourceHeight: number, targetWidth: number, targetHeight: number ): Uint8Array { if (sourceWidth === targetWidth && sourceHeight === targetHeight) { return new Uint8Array(source); } const out = new Uint8Array(targetWidth * targetHeight); const maxSourceX = Math.max(0, sourceWidth - 1); const maxSourceY = Math.max(0, sourceHeight - 1); const denomX = Math.max(1, targetWidth - 1); const denomY = Math.max(1, targetHeight - 1); for (let y = 0; y < targetHeight; y += 1) { const fy = y / denomY; const sy = Math.max(0, Math.min(maxSourceY, Math.round(fy * maxSourceY))); for (let x = 0; x < targetWidth; x += 1) { const fx = x / denomX; const sx = Math.max(0, Math.min(maxSourceX, Math.round(fx * maxSourceX))); out[y * targetWidth + x] = source[sy * sourceWidth + sx]; } } return out; } function ensureConstraintLocks( state: AppState, width: number, height: number ): { width: number; height: number; mask: Uint8Array; values: Float32Array; dirty: boolean; cachedIndices: Uint32Array | null; } { const size = Math.max(1, width * height); if (!state.constraintLocks) { state.constraintLocks = { width, height, mask: new Uint8Array(size), values: new Float32Array(size), dirty: true, cachedIndices: null }; return state.constraintLocks; } if (state.constraintLocks.width === width && state.constraintLocks.height === height) { return state.constraintLocks; } const resizedMask = resizeUint8Nearest( state.constraintLocks.mask, state.constraintLocks.width, state.constraintLocks.height, width, height ); const resizedValues = resampleImportedBaseLayerToSize( state.constraintLocks.values, state.constraintLocks.width, state.constraintLocks.height, width, height ); state.constraintLocks = { width, height, mask: resizedMask, values: resizedValues, dirty: true, cachedIndices: null }; return state.constraintLocks; } function rebuildConstraintLockIndices( locks: { mask: Uint8Array; dirty: boolean; cachedIndices: Uint32Array | null; } ): Uint32Array { if (!locks.dirty && locks.cachedIndices) { return locks.cachedIndices; } const dynamic: number[] = []; for (let i = 0; i < locks.mask.length; i += 1) { if (locks.mask[i] > 0) { dynamic.push(i); } } locks.cachedIndices = Uint32Array.from(dynamic); locks.dirty = false; return locks.cachedIndices; } function syncInfluenceLayersToSize(state: AppState, width: number, height: number): void { for (const layerId of INFLUENCE_FLOAT_LAYER_IDS) { const layer = state.layerStore.getLayerRef(layerId); if (!layer) continue; if (layer.width === width && layer.height === height) continue; const sourceFloat = layer.data instanceof Float32Array ? layer.data : new Float32Array(layer.data); const resized = resampleImportedBaseLayerToSize(sourceFloat, layer.width, layer.height, width, height); upsertMapLabLayer(state, { meta: getInfluenceLayerMeta(layerId), data: resized, width, height }); } if (state.constraintLocks) { ensureConstraintLocks(state, width, height); } const constraintLayer = state.layerStore.getLayerRef('constraintLock'); if ( !state.constraintLocks && constraintLayer && constraintLayer.width === width && constraintLayer.height === height ) { state.constraintLocks = { width, height, mask: constraintLayer.data instanceof Uint8Array ? new Uint8Array(constraintLayer.data) : new Uint8Array(constraintLayer.data), values: new Float32Array(width * height), dirty: true, cachedIndices: null }; } if (constraintLayer && (constraintLayer.width !== width || constraintLayer.height !== height)) { const resizedMask = resizeUint8Nearest( constraintLayer.data instanceof Uint8Array ? constraintLayer.data : new Uint8Array(constraintLayer.data), constraintLayer.width, constraintLayer.height, width, height ); const locks = ensureConstraintLocks(state, width, height); locks.mask.set(resizedMask); locks.dirty = true; locks.cachedIndices = null; upsertMapLabLayer(state, { meta: getInfluenceLayerMeta('constraintLock'), data: new Uint8Array(locks.mask), width, height }); } } function sampleLayerAtWorld(state: AppState, layerId: string, world: { x: number; z: number }): number | null { if (!state.bundle) return null; const layer = state.layerStore.getLayerRef(layerId); if (!layer) return null; const mapSize = Math.max(1, state.recipe.mapSizeMeters); const half = mapSize * 0.5; const fx = clamp01((world.x + half) / mapSize); const fy = clamp01((world.z + half) / mapSize); return layer.meta.kind === 'float32' ? state.layerStore.sampleBilinear(layerId, fx, fy) : state.layerStore.sampleNearest(layerId, fx, fy); } function updateObjectiveReadout(state: AppState): void { if (!objectivesReadoutEl) return; if (!state.bundle) { objectivesReadoutEl.textContent = 'No metrics yet.'; return; } const markerDiagnostics = computeMarkerValidationDiagnostics(state); const spawns = markerDiagnostics.spawns; const autoSpawnCandidates = [ ...markerDiagnostics.spawnSelection.accepted, ...markerDiagnostics.spawnSelection.rejected ]; const autoResourceCandidates = markerDiagnostics.resourceSelection.accepted; const autoSpawnCount = autoSpawnCandidates.length; const autoResourceCount = ( markerDiagnostics.resourceSelection.accepted.length + markerDiagnostics.resourceSelection.rejected.length ); const heights: number[] = []; const slopes: number[] = []; const lanes: number[] = []; const safes: number[] = []; const passability: number[] = []; const spawnScores: number[] = []; const resourceNearestDistances: number[] = []; const spawnLines: string[] = []; const nearestResourceDistanceForSpawn = (spawn: { x: number; z: number }): number | null => { if (autoResourceCandidates.length <= 0) return null; let best = Infinity; for (let i = 0; i < autoResourceCandidates.length; i += 1) { const candidate = autoResourceCandidates[i]; const dist = Math.hypot(candidate.x - spawn.x, candidate.z - spawn.z); if (dist < best) best = dist; } return Number.isFinite(best) ? best : null; }; for (let i = 0; i < spawns.length; i += 1) { const spawn = spawns[i]; const h = sampleLayerAtWorld(state, 'height', spawn) ?? 0; const slope = sampleLayerAtWorld(state, 'slope', spawn) ?? 0; const lane = sampleLayerAtWorld(state, 'intentMainLane', spawn) ?? 0; const safe = sampleLayerAtWorld(state, 'intentSafeExpansion', spawn) ?? 0; const pass = sampleLayerAtWorld(state, 'passability', spawn) ?? 0; const spawnScore = sampleLayerAtWorld(state, 'spawn_score', spawn) ?? 0; const nearestResource = nearestResourceDistanceForSpawn(spawn); heights.push(h); slopes.push(slope); lanes.push(lane); safes.push(safe); passability.push(pass); spawnScores.push(spawnScore); if (nearestResource !== null) { resourceNearestDistances.push(nearestResource); } spawnLines.push( `P${i + 1}: h=${h.toFixed(1)}m slope=${slope.toFixed(1)} lane=${lane.toFixed(2)} safe=${safe.toFixed(2)} pass=${pass.toFixed(2)} spawn=${spawnScore.toFixed(2)}${nearestResource !== null ? ` res=${Math.round(nearestResource)}m` : ''}` ); } const spread = (values: number[]): number => { if (values.length <= 0) return 0; let min = Infinity; let max = -Infinity; for (let i = 0; i < values.length; i += 1) { const value = values[i]; if (value < min) min = value; if (value > max) max = value; } return Math.max(0, max - min); }; const mean = (values: number[]): number => { if (values.length <= 0) return 0; let sum = 0; for (let i = 0; i < values.length; i += 1) sum += values[i]; return sum / values.length; }; const heightSpread = spread(heights); const slopeSpread = spread(slopes); const laneSpread = spread(lanes); const safeSpread = spread(safes); const passSpread = spread(passability); const spawnScoreSpread = spread(spawnScores); const resourceDistanceSpread = spread(resourceNearestDistances); const noBuildLayer = state.layerStore.getLayerRef('intentNoBuild'); let noBuildCoverage = 0; if (noBuildLayer) { const data = noBuildLayer.data instanceof Float32Array ? noBuildLayer.data : new Float32Array(noBuildLayer.data); let blocked = 0; for (let i = 0; i < data.length; i += 1) { if (data[i] >= NO_BUILD_BLOCK_THRESHOLD) blocked += 1; } noBuildCoverage = blocked / Math.max(1, data.length); } const locks = state.constraintLocks; const lockCount = locks ? rebuildConstraintLockIndices(locks).length : 0; const lockCoverage = locks ? lockCount / Math.max(1, locks.mask.length) : 0; const resourceNearestLine = resourceNearestDistances.length > 0 ? `Resource nearest: mean ${mean(resourceNearestDistances).toFixed(0)}m | spread ${resourceDistanceSpread.toFixed(0)}m` : 'Resource nearest: n/a (no auto resource candidates)'; const markerConfig = cloneMarkerGuidanceConfigForEdit(state.recipe.markerGuidance) ?? createDefaultMarkerGuidanceConfigForEdit(state.recipe.mapSizeMeters); const markerSummary = markerConfig.enabled === false ? 'Marker guidance: disabled' : `Marker guidance: on | spawn >= ${Math.round(markerConfig.spawnMinDistanceMeters ?? 0)}m | slope <= ${(markerConfig.spawnMaxSlope ?? 0).toFixed(2)} | buildable >= ${(markerConfig.spawnMinBuildableCoverage ?? 0).toFixed(2)}`; const markerBlockerLayer = state.layerStore.getLayerRef('marker_blockers'); let markerBlockerCoverage = 0; if (markerBlockerLayer) { const data = markerBlockerLayer.data instanceof Uint8Array ? markerBlockerLayer.data : new Uint8Array(markerBlockerLayer.data); let blocked = 0; for (let i = 0; i < data.length; i += 1) { if (data[i] !== 0) blocked += 1; } markerBlockerCoverage = blocked / Math.max(1, data.length); } const fairnessScore = Math.max( 0, Math.min( 100, ( (1 - Math.min(1, heightSpread / 140)) * 0.34 + (1 - Math.min(1, slopeSpread / 12)) * 0.16 + (1 - Math.min(1, laneSpread / 0.55)) * 0.22 + (1 - Math.min(1, safeSpread / 0.55)) * 0.16 + (1 - Math.min(1, passSpread)) * 0.12 ) * 100 ) ); objectivesReadoutEl.textContent = [ `Fairness: ${fairnessScore.toFixed(1)} / 100`, `Marker candidates: spawn ${autoSpawnCount} | resource ${autoResourceCount}`, `Marker validation: spawn ok ${markerDiagnostics.spawnSelection.accepted.length} / reject ${markerDiagnostics.spawnSelection.rejected.length} | resource ok ${markerDiagnostics.resourceSelection.accepted.length} / reject ${markerDiagnostics.resourceSelection.rejected.length}`, markerSummary, `Marker blockers: ${(markerBlockerCoverage * 100).toFixed(2)}% coverage`, `Spawn score: mean ${mean(spawnScores).toFixed(2)} | spread ${spawnScoreSpread.toFixed(2)}`, resourceNearestLine, `Spawn spreads: height ${heightSpread.toFixed(2)}m | slope ${slopeSpread.toFixed(2)}deg | pass ${passSpread.toFixed(2)}`, `Intent spreads: lane ${laneSpread.toFixed(2)} | safe ${safeSpread.toFixed(2)}`, `Intent means: lane ${mean(lanes).toFixed(2)} | safe ${mean(safes).toFixed(2)}`, `NoBuild coverage: ${(noBuildCoverage * 100).toFixed(2)}%`, `Locked cells: ${lockCount} (${(lockCoverage * 100).toFixed(2)}%)`, '---', ...spawnLines ].join('\n'); } function applyConstraintLocksToBundle(state: AppState, bundle: StrategicMapBundle): boolean { const locks = state.constraintLocks; if (!locks) return false; if (locks.width !== bundle.width || locks.height !== bundle.height) { ensureConstraintLocks(state, bundle.width, bundle.height); } const activeLocks = state.constraintLocks; if (!activeLocks) return false; const indices = rebuildConstraintLockIndices(activeLocks); if (indices.length <= 0) return false; for (let i = 0; i < indices.length; i += 1) { const idx = indices[i]; bundle.heightData[idx] = activeLocks.values[idx]; } return true; } function applyNoBuildMaskToBundle(state: AppState, bundle: StrategicMapBundle): void { const layer = state.layerStore.getLayerRef('intentNoBuild'); if (!layer) return; if (layer.width !== bundle.width || layer.height !== bundle.height) return; const data = layer.data instanceof Float32Array ? layer.data : new Float32Array(layer.data); if (!bundle.masks?.buildable || bundle.masks.buildable.length !== bundle.width * bundle.height) return; let blockedCount = 0; for (let i = 0; i < data.length; i += 1) { if (data[i] >= NO_BUILD_BLOCK_THRESHOLD) { if (bundle.masks.buildable[i] !== 0) { bundle.masks.buildable[i] = 0; } blockedCount += 1; } } if (!bundle.analysis) return; bundle.analysis.maskCoverage = { ...(bundle.analysis.maskCoverage ?? {}), buildable: (bundle.masks.buildable.reduce((sum, value) => sum + (value > 0 ? 1 : 0), 0)) / Math.max(1, data.length) }; } function applyInfluenceConstraintsToBundle( state: AppState, bundle: StrategicMapBundle, mode: RegenerationMode ): void { const hadLocks = applyConstraintLocksToBundle(state, bundle); if (hadLocks && mode !== 'preview') { recomputeBundleAnalysisForBaseImport(bundle); } if (mode !== 'preview') { applyNoBuildMaskToBundle(state, bundle); } } function ensureFloatInfluenceLayer( state: AppState, layerId: Exclude, width: number, height: number ): Float32Array { const existing = state.layerStore.getLayerRef(layerId); if (existing && existing.width === width && existing.height === height && existing.data instanceof Float32Array) { return new Float32Array(existing.data); } if (existing && existing.data instanceof Float32Array) { return resampleImportedBaseLayerToSize(existing.data, existing.width, existing.height, width, height); } return new Float32Array(width * height); } function beginInfluencePaintStroke( state: AppState, event: PointerEvent, mode: 'add' | 'remove' ): void { if (!state.bundle) return; const influenceMode = asInfluenceMode(controls.influenceMode.value); if (influenceMode === 'none') return; syncInfluenceLayersToSize(state, state.bundle.width, state.bundle.height); const layerId = influenceModeToLayerId(influenceMode); const floatLayerId = layerId && layerId !== 'constraintLock' ? layerId : null; state.influencePaintStroke = { pointerId: event.pointerId, mode, influenceMode, lastWorld: canvasClientToWorld(state, event.clientX, event.clientY), layerId: floatLayerId, workingValues: floatLayerId ? ensureFloatInfluenceLayer(state, floatLayerId, state.bundle.width, state.bundle.height) : null, width: state.bundle.width, height: state.bundle.height, dirty: false }; setStatusThrottled( state, mode === 'add' ? `Influence paint: ${influenceMode}` : `Influence erase: ${influenceMode}` ); paintInfluenceAtWorld(state, state.influencePaintStroke, state.influencePaintStroke.lastWorld); } function paintInfluenceAtWorld( state: AppState, stroke: NonNullable, world: { x: number; z: number } ): void { const mapSize = Math.max(1, state.recipe.mapSizeMeters); const half = mapSize * 0.5; const fx = clamp01((world.x + half) / mapSize); const fy = clamp01((world.z + half) / mapSize); const width = Math.max(1, stroke.width); const height = Math.max(1, stroke.height); const centerX = fx * (width - 1); const centerY = fy * (height - 1); const cellSize = mapSize / Math.max(1, width - 1); const radiusMeters = Math.max(10, Number.parseFloat(controls.influenceRadius.value) || 650); const radiusCells = Math.max(1, radiusMeters / Math.max(1e-6, cellSize)); const strength = clamp01(Number.parseFloat(controls.influenceStrength.value) || 0.35); const xMin = Math.max(0, Math.floor(centerX - radiusCells)); const xMax = Math.min(width - 1, Math.ceil(centerX + radiusCells)); const yMin = Math.max(0, Math.floor(centerY - radiusCells)); const yMax = Math.min(height - 1, Math.ceil(centerY + radiusCells)); if (stroke.layerId && stroke.workingValues) { const data = stroke.workingValues; let changed = false; for (let y = yMin; y <= yMax; y += 1) { const dy = (y - centerY) * cellSize; for (let x = xMin; x <= xMax; x += 1) { const dx = (x - centerX) * cellSize; const distance = Math.hypot(dx, dy); if (distance > radiusMeters) continue; const falloff = 1 - distance / radiusMeters; const amount = strength * falloff; const idx = y * width + x; const current = data[idx]; const next = stroke.mode === 'add' ? Math.min(1, current + amount) : Math.max(0, current - amount); if (Math.abs(next - current) > 0.0001) { data[idx] = next; changed = true; } } } if (changed) { stroke.dirty = true; } return; } const locks = ensureConstraintLocks(state, width, height); const bundle = state.bundle; if (!bundle || bundle.width !== width || bundle.height !== height) return; let changed = false; for (let y = yMin; y <= yMax; y += 1) { const dy = (y - centerY) * cellSize; for (let x = xMin; x <= xMax; x += 1) { const dx = (x - centerX) * cellSize; const distance = Math.hypot(dx, dy); if (distance > radiusMeters) continue; const idx = y * width + x; if (stroke.mode === 'add') { const sampled = bundle.heightData[idx]; if (locks.mask[idx] !== 1 || Math.abs(locks.values[idx] - sampled) > 0.001) { locks.mask[idx] = 1; locks.values[idx] = sampled; changed = true; } } else if (locks.mask[idx] !== 0 || locks.values[idx] !== 0) { locks.mask[idx] = 0; locks.values[idx] = 0; changed = true; } } } if (changed) { locks.dirty = true; locks.cachedIndices = null; stroke.dirty = true; } } function continueInfluencePaintStroke(state: AppState, event: PointerEvent): void { const stroke = state.influencePaintStroke; if (!stroke || stroke.pointerId !== event.pointerId) return; const nextWorld = canvasClientToWorld(state, event.clientX, event.clientY); const dx = nextWorld.x - stroke.lastWorld.x; const dz = nextWorld.z - stroke.lastWorld.z; const distance = Math.hypot(dx, dz); const radius = Math.max(10, Number.parseFloat(controls.influenceRadius.value) || 650); const pressureScale = (state.previewInFlight || state.perf.previewQueueDepth > 0) ? state.interactionLoadScale * 1.05 : state.interactionLoadScale; const spacing = Math.max(20, Math.min(260, radius * 0.22)) * pressureScale; if (distance < spacing) return; const steps = Math.min(INFLUENCE_MAX_STEPS_PER_POINTER_MOVE, Math.max(1, Math.floor(distance / spacing))); for (let step = 1; step <= steps; step += 1) { const t = step / steps; const world = { x: stroke.lastWorld.x + dx * t, z: stroke.lastWorld.z + dz * t }; paintInfluenceAtWorld(state, stroke, world); } stroke.lastWorld = nextWorld; } function clearInfluenceLayer(state: AppState, layerId: Exclude): boolean { const source = state.bundle; if (!source) return false; const width = source.width; const height = source.height; const existing = state.layerStore.getLayerRef(layerId); if (!existing) return false; if (existing.width !== width || existing.height !== height) { syncInfluenceLayersToSize(state, width, height); } upsertMapLabLayer(state, { meta: getInfluenceLayerMeta(layerId), data: new Float32Array(width * height), width, height }); if (state.overlayAutoRange && state.activeLayerId === layerId) { syncLayerRangeFromActive(state); } syncToolModeUi(state); updateObjectiveReadout(state); requestRender(state); return true; } function clearConstraintLocks(state: AppState): boolean { if (!state.bundle) return false; const hadAny = !!state.constraintLocks || state.layerStore.hasLayer('constraintLock'); if (!hadAny) return false; state.constraintLocks = null; removeMapLabLayer(state, 'constraintLock'); refreshLayerSelector(state); if (state.overlayAutoRange) { syncLayerRangeFromActive(state); } syncToolModeUi(state); updateObjectiveReadout(state); requestRender(state); return true; } function endInfluencePaintStroke(state: AppState, pointerId: number): void { const stroke = state.influencePaintStroke; if (!stroke || stroke.pointerId !== pointerId) return; state.influencePaintStroke = null; if (!stroke.dirty) return; if (stroke.layerId && stroke.workingValues) { upsertMapLabLayer(state, { meta: getInfluenceLayerMeta(stroke.layerId), data: stroke.workingValues, width: stroke.width, height: stroke.height }); if (stroke.layerId === 'intentNoBuild') { const passabilityLayer = state.layerStore.getLayerRef('passability'); if ( passabilityLayer && passabilityLayer.data instanceof Uint8Array && passabilityLayer.width === stroke.width && passabilityLayer.height === stroke.height ) { const adjusted = new Uint8Array(passabilityLayer.data); for (let i = 0; i < adjusted.length; i += 1) { if (stroke.workingValues[i] >= NO_BUILD_BLOCK_THRESHOLD) { adjusted[i] = 0; } } upsertMapLabLayer(state, { meta: passabilityLayer.meta, data: adjusted, width: passabilityLayer.width, height: passabilityLayer.height }); } } refreshLayerSelector(state); if (!state.activeLayerId || !state.layerStore.hasLayer(state.activeLayerId)) { state.activeLayerId = stroke.layerId; controls.layerActive.value = stroke.layerId; } if (state.overlayAutoRange && state.activeLayerId === stroke.layerId) { syncLayerRangeFromActive(state); } setStatus(`Influence layer updated: ${stroke.layerId}`); } else if (state.constraintLocks) { upsertMapLabLayer(state, { meta: getInfluenceLayerMeta('constraintLock'), data: new Uint8Array(state.constraintLocks.mask), width: state.constraintLocks.width, height: state.constraintLocks.height }); refreshLayerSelector(state); if (state.bundle) { applyConstraintLocksToBundle(state, state.bundle); upsertLayerFromBundleHeight(state, state.bundle, { deriveSecondary: state.generationMode !== 'preview' }); } if (state.fullBundle && state.fullBundle !== state.bundle) { applyConstraintLocksToBundle(state, state.fullBundle); upsertLayerFromBundleHeight(state, state.fullBundle, { deriveSecondary: true }); } recomputeStats(state); setStatus('Constraint locks updated.'); } updateDataPanel(state, true); updateObjectiveReadout(state); syncToolModeUi(state); requestRender(state); } function updateProbeReadout(state: AppState): void { if (!probeReadoutEl) return; if (!state.bundle) { probeReadoutEl.textContent = 'Probe: waiting for terrain.'; return; } if (!state.probe.active) { probeReadoutEl.textContent = 'Probe: move cursor over map.'; return; } const fx = state.probe.canvasX / Math.max(1, canvas.width - 1); const fyScreen = state.probe.canvasY / Math.max(1, canvas.height - 1); const fy = 1 - fyScreen; const world = canvasToWorld(state, state.probe.canvasX, state.probe.canvasY); const gridX = Math.max(0, Math.min(state.bundle.width - 1, Math.round(fx * (state.bundle.width - 1)))); const gridY = Math.max(0, Math.min(state.bundle.height - 1, Math.round(fy * (state.bundle.height - 1)))); const values: string[] = []; const layerOrder = [ 'height', 'slope', 'passability', 'intentMainLane', 'intentSafeExpansion', 'intentNoBuild', 'constraintLock', state.activeLayerId ]; const seen = new Set(); for (const id of layerOrder) { if (!id || seen.has(id)) continue; seen.add(id); const layer = state.layerStore.getLayerRef(id); if (!layer) continue; const value = layer.meta.kind === 'float32' ? state.layerStore.sampleBilinear(id, fx, fy) : state.layerStore.sampleNearest(id, fx, fy); if (value === null) continue; values.push(`${id}: ${value.toFixed(3)}`); } probeReadoutEl.textContent = [ `world: x=${world.x.toFixed(1)} z=${world.z.toFixed(1)}`, `grid: x=${gridX} y=${gridY}`, ...values ].join('\n'); } function createTerrainImportRecipeDocument(state: AppState): TerrainImportRecipeDocument { const gridSpec = createMapGridSpecFromRecipe(state.recipe); const layerRanges: Record = {}; const stats = state.layerStore.computeAllStats(); for (const [id, value] of Object.entries(stats as Record)) { layerRanges[id] = { min: value.min, max: value.max }; } return { version: MAPLAB_IMPORT_LAYER_DOC_VERSION, gridSpec, recipes: state.importRecipes.map(cloneImportRecipeEntry), attribution: state.importAttribution.map(cloneAttributionEntry), layerRanges, activeBaseLayerId: MAPLAB_BASE_LAYER_ID, exportedAt: new Date().toISOString(), sourceBundleHash: state.bundle?.hash }; } function createDefaultRecipe(): StrategicMapRecipe { return { version: 1, seed: 12345, players: 4, symmetry: 'point', biome: BiomeType.TEMPERATE, competitiveMode: true, mapSizePreset: 'medium', mapSizeMeters: MAP_SIZE_PRESET_METERS.medium, resolution: MAP_SIZE_PRESET_RESOLUTION.medium, featureControls: {}, manualStamps: [], areaFeatures: [], splineFeatures: [], ca: createDefaultCaConfigForEdit(), markerGuidance: createDefaultMarkerGuidanceConfigForEdit(MAP_SIZE_PRESET_METERS.medium), metadata: { name: 'MapLab Draft' } }; } function asShapeMode(value: string): ShapeMode { if (value === 'edit' || value === 'forest' || value === 'mountain' || value === 'river' || value === 'road') return value; return 'none'; } function asEditSpeedMode(value: string): EditSpeedMode { if (value === 'balanced' || value === 'quality') return value; return 'turbo'; } function asMouseAssistMode(value: string): MouseAssistMode { if (value === 'carve' || value === 'insert' || value === 'delete') return value; return 'default'; } function asBrushSymmetryMode(value: string): BrushSymmetryMode { if (value === 'mirror-x' || value === 'mirror-z' || value === 'quad') return value; return 'none'; } function asTerraformMode(value: string): TerraformMode { if ( value === 'raise' || value === 'lower' || value === 'smooth' || value === 'erode' || value === 'sharpen' || value === 'flatten-cursor' || value === 'flatten-height' ) return value; return 'off'; } function asInfluenceMode(value: string): InfluenceMode { if (value === 'lane' || value === 'safe' || value === 'nobuild' || value === 'lock') return value; return 'none'; } function getEditSpeedAdaptiveBoost(state: AppState): number { const isHighFrequencyEdit = !!state.brushStroke || !!state.geometryEditDrag || !!state.propertyHandleDrag || !!state.splineGizmoDrag; if (!isHighFrequencyEdit) return 0; if (state.editSpeedMode === 'turbo') return 2; if (state.editSpeedMode === 'balanced') return 1; return 0; } function getEditSpeedFullDelayScale(state: AppState): number { if (state.editSpeedMode === 'turbo') return 2.1; if (state.editSpeedMode === 'balanced') return 1.35; return 1; } function createManualStampId(state: AppState): string { const id = state.nextManualStampId; state.nextManualStampId = id >= Number.MAX_SAFE_INTEGER ? 1 : id + 1; return `manual-${id}`; } function buildManualStampCellKey(cellX: number, cellZ: number): string { return `${cellX},${cellZ}`; } function getManualStampCell(stamp: StrategicMapStamp, cellSize: number): { x: number; z: number } { return { x: Math.floor(stamp.x / cellSize), z: Math.floor(stamp.z / cellSize) }; } function clearManualStampSpatialIndex(state: AppState): void { state.manualStampSpatialIndex = null; } function ensureManualStampSpatialIndex(state: AppState): ManualStampSpatialIndex | null { const stamps = state.recipe.manualStamps; if (stamps.length < MANUAL_STAMP_SPATIAL_INDEX_MIN_COUNT) { state.manualStampSpatialIndex = null; return null; } const existing = state.manualStampSpatialIndex; if (existing && existing.source === stamps) { return existing; } const cellSize = MANUAL_STAMP_SPATIAL_INDEX_CELL_SIZE_METERS; const buckets = new Map(); for (let i = 0; i < stamps.length; i += 1) { const stamp = stamps[i]; const cell = getManualStampCell(stamp, cellSize); const key = buildManualStampCellKey(cell.x, cell.z); const bucket = buckets.get(key); if (bucket) { bucket.push(stamp); } else { buckets.set(key, [stamp]); } } const built: ManualStampSpatialIndex = { source: stamps, cellSize, buckets }; state.manualStampSpatialIndex = built; return built; } function addStampsToManualStampSpatialIndex(state: AppState, stamps: StrategicMapStamp[]): void { const index = state.manualStampSpatialIndex; if (!index || index.source !== state.recipe.manualStamps) return; const cellSize = index.cellSize; for (let i = 0; i < stamps.length; i += 1) { const stamp = stamps[i]; const cell = getManualStampCell(stamp, cellSize); const key = buildManualStampCellKey(cell.x, cell.z); const bucket = index.buckets.get(key); if (bucket) { bucket.push(stamp); } else { index.buckets.set(key, [stamp]); } } } function removeStampFromManualStampSpatialIndex(state: AppState, stamp: StrategicMapStamp): void { const index = state.manualStampSpatialIndex; if (!index || index.source !== state.recipe.manualStamps) return; const cell = getManualStampCell(stamp, index.cellSize); const key = buildManualStampCellKey(cell.x, cell.z); const bucket = index.buckets.get(key); if (!bucket) return; const idx = bucket.indexOf(stamp); if (idx < 0) return; bucket.splice(idx, 1); if (bucket.length <= 0) { index.buckets.delete(key); } } function readBrushRuntimeSettings(): BrushRuntimeSettings { const radius = Number.parseFloat(controls.brushRadius.value) || 1200; const signValue = controls.brushSign.value; const sign: 'auto' | 'raise' | 'lower' = signValue === 'raise' || signValue === 'lower' ? signValue : 'auto'; return { type: controls.brushType.value as StrategicStampType, radius, intensity: Number.parseFloat(controls.brushIntensity.value) || 12, length: Number.parseFloat(controls.brushLength.value) || radius * 2, width: Number.parseFloat(controls.brushWidth.value) || radius * 0.7, falloff: Math.max(0.2, Math.min(2.5, Number.parseFloat(controls.brushFalloff.value) || 1)), sign, orientationRad: normalizeRadians((Number.parseFloat(controls.brushOrientation.value) || 0) * (Math.PI / 180)), symmetryMode: asBrushSymmetryMode(controls.brushSymmetry.value), userSpacing: Math.max(0.55, Math.min(2.4, Number.parseFloat(controls.brushSpacing.value) || 1)), terraformMode: asTerraformMode(controls.terraformMode.value), terraformStrength: Math.max(0.3, Math.min(2.5, Number.parseFloat(controls.terraformStrength.value) || 1)), terraformTargetHeight: Number.parseFloat(controls.terraformTargetHeight.value) || 0 }; } function updateControlReadouts(): void { controls.brushRadiusValue.textContent = `${Math.round(Number.parseFloat(controls.brushRadius.value) || 0)}m`; controls.brushIntensityValue.textContent = `${Math.round(Number.parseFloat(controls.brushIntensity.value) || 0)}`; controls.brushLengthValue.textContent = `${Math.round(Number.parseFloat(controls.brushLength.value) || 0)}m`; controls.brushWidthValue.textContent = `${Math.round(Number.parseFloat(controls.brushWidth.value) || 0)}m`; controls.brushFalloffValue.textContent = `${(Number.parseFloat(controls.brushFalloff.value) || 0).toFixed(2)}`; controls.brushOrientationValue.textContent = `${Math.round(Number.parseFloat(controls.brushOrientation.value) || 0)}deg`; controls.brushSpacingValue.textContent = `${(Number.parseFloat(controls.brushSpacing.value) || 1).toFixed(2)}x`; controls.brushJitterValue.textContent = `${(Number.parseFloat(controls.brushJitter.value) || 0).toFixed(2)}`; controls.terraformStrengthValue.textContent = `${(Number.parseFloat(controls.terraformStrength.value) || 1).toFixed(2)}x`; controls.shapeWidthValue.textContent = `${Math.round(Number.parseFloat(controls.shapeWidth.value) || 0)}m`; controls.shapeIntensityValue.textContent = `${Math.round(Number.parseFloat(controls.shapeIntensity.value) || 0)}`; controls.shapePointWeightValue.textContent = `${(Number.parseFloat(controls.shapePointWeight.value) || 0).toFixed(2)}`; controls.shapeFalloffValue.textContent = `${(Number.parseFloat(controls.shapeFalloff.value) || 0).toFixed(2)}`; controls.influenceRadiusValue.textContent = `${Math.round(Number.parseFloat(controls.influenceRadius.value) || 0)}m`; controls.influenceStrengthValue.textContent = `${(Number.parseFloat(controls.influenceStrength.value) || 0).toFixed(2)}`; } function clampToInputRange(input: HTMLInputElement, value: number): number { const min = Number.parseFloat(input.min); const max = Number.parseFloat(input.max); let clamped = Number.isFinite(value) ? value : 0; if (Number.isFinite(min)) clamped = Math.max(min, clamped); if (Number.isFinite(max)) clamped = Math.min(max, clamped); return clamped; } function setNumericInputValue(input: HTMLInputElement, value: number): void { input.value = String(clampToInputRange(input, value)); } function setSelectValueSafely(select: HTMLSelectElement, value: string, fallback: string): void { const hasValue = Array.from(select.options).some((option) => option.value === value); if (hasValue) { select.value = value; return; } const hasFallback = Array.from(select.options).some((option) => option.value === fallback); select.value = hasFallback ? fallback : (select.options[0]?.value ?? ''); } function emitControlEvents(control: HTMLInputElement | HTMLSelectElement): void { control.dispatchEvent(new Event('input', { bubbles: true })); control.dispatchEvent(new Event('change', { bubbles: true })); } function syncSelectToolbar(select: HTMLSelectElement): void { const buttons = document.querySelectorAll(`button[data-bind-select="${select.id}"]`); for (const button of buttons) { button.classList.toggle('is-active', button.dataset.value === select.value); } } function syncToggleToolbar(toggle: HTMLInputElement): void { const buttons = document.querySelectorAll(`button[data-bind-toggle="${toggle.id}"]`); for (const button of buttons) { button.classList.toggle('is-active', toggle.checked); } } function syncToolbarStates(): void { syncSelectToolbar(controls.editSpeed); syncSelectToolbar(controls.mouseAssist); syncSelectToolbar(controls.brushType); syncSelectToolbar(controls.brushSymmetry); syncSelectToolbar(controls.terraformMode); syncSelectToolbar(controls.shapeMode); syncSelectToolbar(controls.influenceMode); for (const overlay of Object.values(OVERLAYS)) { syncToggleToolbar(overlay); } } function installToolbarBindings(): void { const selectTargets: HTMLSelectElement[] = [ controls.editSpeed, controls.mouseAssist, controls.brushType, controls.brushSymmetry, controls.terraformMode, controls.shapeMode, controls.influenceMode ]; for (const select of selectTargets) { const buttons = document.querySelectorAll(`button[data-bind-select="${select.id}"]`); for (const button of buttons) { button.addEventListener('click', () => { const value = button.dataset.value; if (!value || select.value === value) return; select.value = value; emitControlEvents(select); syncSelectToolbar(select); }); } select.addEventListener('change', () => syncSelectToolbar(select)); } for (const overlay of Object.values(OVERLAYS)) { const buttons = document.querySelectorAll(`button[data-bind-toggle="${overlay.id}"]`); for (const button of buttons) { button.addEventListener('click', () => { overlay.checked = !overlay.checked; emitControlEvents(overlay); syncToggleToolbar(overlay); }); } overlay.addEventListener('change', () => syncToggleToolbar(overlay)); } syncToolbarStates(); } function ensureFeatureCollections(recipe: StrategicMapRecipe): StrategicMapRecipe { const overlays = cloneOverlayBundleForEdit(recipe.overlays) ?? { version: 1 as const, raster: [], primitives: [], authority: {}, effects: {}, metadata: {} }; overlays.raster = (overlays.raster ?? []).map((layer) => ({ ...layer, width: Math.max(1, Math.floor(Number(layer.width) || 1)), height: Math.max(1, Math.floor(Number(layer.height) || 1)), data: Array.isArray(layer.data) ? layer.data.map((value) => (Number.isFinite(value) ? Number(value) : 0)) : [] })); overlays.primitives = (overlays.primitives ?? []).map((primitive) => ({ ...primitive, points: Array.isArray(primitive.points) ? primitive.points.map((point) => ({ ...point, weight: Number.isFinite(point.weight) ? Math.max(0.2, Math.min(3, Number(point.weight))) : undefined })) : [] })); overlays.authority = overlays.authority ?? {}; overlays.effects = overlays.effects ?? {}; overlays.metadata = overlays.metadata ?? {}; return { ...recipe, // MapLab is editor-authored only: auto feature counts are disabled. featureControls: {}, areaFeatures: Array.isArray(recipe.areaFeatures) ? recipe.areaFeatures.map((feature) => ({ ...feature, points: Array.isArray(feature.points) ? feature.points.map((point) => ({ ...point, weight: Number.isFinite(point.weight) ? Math.max(0.2, Math.min(3, Number(point.weight))) : undefined })) : [] })) : [], splineFeatures: Array.isArray(recipe.splineFeatures) ? recipe.splineFeatures.map((feature) => ({ ...feature, points: Array.isArray(feature.points) ? feature.points.map((point) => ({ ...point, weight: Number.isFinite(point.weight) ? Math.max(0.2, Math.min(3, Number(point.weight))) : undefined })) : [] })) : [], overlays }; } function shapeModeToAreaType(mode: ShapeMode): StrategicAreaFeatureType | null { if (mode === 'forest' || mode === 'mountain') return mode; return null; } function shapeModeToSplineType(mode: ShapeMode): StrategicSplineFeatureType | null { if (mode === 'river' || mode === 'road') return mode; return null; } function readStoredRecipe(): StrategicMapRecipe | null { try { const raw = window.localStorage.getItem(STORAGE_KEYS.latestRecipe); if (!raw) return null; return ensureFeatureCollections(JSON.parse(raw) as StrategicMapRecipe); } catch { return null; } } const FEATURE_PROFILE_MODES: FeatureProfileMode[] = [ 'default', 'raise', 'lower', 'flatten', 'terrace', 'channel', 'embankment' ]; function asFeatureProfileMode(value: string): FeatureProfileMode { if ((FEATURE_PROFILE_MODES as string[]).includes(value)) { return value as FeatureProfileMode; } return 'default'; } function captureMapLabEditorState(state: AppState): MapLabEditorState { const brushSignValue = controls.brushSign.value; const brushSign: 'auto' | 'raise' | 'lower' = brushSignValue === 'raise' || brushSignValue === 'lower' ? brushSignValue : 'auto'; return { version: MAPLAB_EDITOR_STATE_VERSION, editSpeed: state.editSpeedMode, mouseAssist: asMouseAssistMode(controls.mouseAssist.value), brushType: controls.brushType.value as StrategicStampType, brushSign, brushRadius: Number.parseFloat(controls.brushRadius.value) || 1200, brushIntensity: Number.parseFloat(controls.brushIntensity.value) || 18, brushLength: Number.parseFloat(controls.brushLength.value) || 2800, brushWidth: Number.parseFloat(controls.brushWidth.value) || 900, brushFalloff: Number.parseFloat(controls.brushFalloff.value) || 1, brushOrientation: Number.parseFloat(controls.brushOrientation.value) || 45, brushSymmetry: asBrushSymmetryMode(controls.brushSymmetry.value), brushSpacing: Number.parseFloat(controls.brushSpacing.value) || 1, brushJitter: Number.parseFloat(controls.brushJitter.value) || 0, terraformMode: asTerraformMode(controls.terraformMode.value), terraformStrength: Number.parseFloat(controls.terraformStrength.value) || 1, terraformTargetHeight: Number.parseFloat(controls.terraformTargetHeight.value) || 0, shapeMode: asShapeMode(controls.shapeMode.value), shapeWidth: Number.parseFloat(controls.shapeWidth.value) || 500, shapeIntensity: Number.parseFloat(controls.shapeIntensity.value) || 16, shapePointWeight: Number.parseFloat(controls.shapePointWeight.value) || 1, shapeFalloff: Number.parseFloat(controls.shapeFalloff.value) || 1, caLayerPreset: asCaLayerPresetId(controls.caLayerPreset.value), caLayerIndex: Math.max(0, Number.parseInt(controls.caLayerSelect.value, 10) || 0), caRuleIndex: Math.max(0, Number.parseInt(controls.caRuleSelect.value, 10) || 0), featureProfile: asFeatureProfileMode(controls.featureProfile.value), overlayLayerId: controls.overlayStudioLayer.value || 'wetness', overlayAuthority: controls.overlayStudioAuthority.value === 'gameplay' ? 'gameplay' : 'visual', overlayTool: controls.overlayStudioTool.value === 'spline' ? 'spline' : controls.overlayStudioTool.value === 'waypoint' ? 'waypoint' : 'area' }; } function applyMapLabEditorStateFromRecipe(state: AppState, recipe: StrategicMapRecipe): void { const raw = recipe.metadata?.maplabEditorState; if (!raw || typeof raw !== 'object') return; const stored = raw as Partial; if (Number(stored.version) !== MAPLAB_EDITOR_STATE_VERSION) return; const storedBrushType = typeof stored.brushType === 'string' ? stored.brushType : ''; if ((BRUSH_STAMP_TYPES as string[]).includes(storedBrushType)) { controls.brushType.value = storedBrushType; } const storedBrushSign = typeof stored.brushSign === 'string' ? stored.brushSign : ''; if ((BRUSH_SIGN_VALUES as string[]).includes(storedBrushSign)) { controls.brushSign.value = storedBrushSign; } if (typeof stored.brushRadius === 'number' && Number.isFinite(stored.brushRadius)) { setNumericInputValue(controls.brushRadius, stored.brushRadius); } if (typeof stored.brushIntensity === 'number' && Number.isFinite(stored.brushIntensity)) { setNumericInputValue(controls.brushIntensity, stored.brushIntensity); } if (typeof stored.brushLength === 'number' && Number.isFinite(stored.brushLength)) { setNumericInputValue(controls.brushLength, stored.brushLength); } if (typeof stored.brushWidth === 'number' && Number.isFinite(stored.brushWidth)) { setNumericInputValue(controls.brushWidth, stored.brushWidth); } if (typeof stored.brushFalloff === 'number' && Number.isFinite(stored.brushFalloff)) { setNumericInputValue(controls.brushFalloff, stored.brushFalloff); } if (typeof stored.brushOrientation === 'number' && Number.isFinite(stored.brushOrientation)) { setNumericInputValue(controls.brushOrientation, stored.brushOrientation); } if (typeof stored.brushSymmetry === 'string') { controls.brushSymmetry.value = asBrushSymmetryMode(stored.brushSymmetry); } if (typeof stored.brushSpacing === 'number' && Number.isFinite(stored.brushSpacing)) { setNumericInputValue(controls.brushSpacing, stored.brushSpacing); } if (typeof stored.brushJitter === 'number' && Number.isFinite(stored.brushJitter)) { setNumericInputValue(controls.brushJitter, stored.brushJitter); } if (typeof stored.terraformMode === 'string') { controls.terraformMode.value = asTerraformMode(stored.terraformMode); } if (typeof stored.terraformStrength === 'number' && Number.isFinite(stored.terraformStrength)) { setNumericInputValue(controls.terraformStrength, stored.terraformStrength); } if (typeof stored.terraformTargetHeight === 'number' && Number.isFinite(stored.terraformTargetHeight)) { controls.terraformTargetHeight.value = stored.terraformTargetHeight.toFixed(2); } if (typeof stored.shapeWidth === 'number' && Number.isFinite(stored.shapeWidth)) { setNumericInputValue(controls.shapeWidth, stored.shapeWidth); } if (typeof stored.shapeIntensity === 'number' && Number.isFinite(stored.shapeIntensity)) { setNumericInputValue(controls.shapeIntensity, stored.shapeIntensity); } if (typeof stored.shapePointWeight === 'number' && Number.isFinite(stored.shapePointWeight)) { setNumericInputValue(controls.shapePointWeight, stored.shapePointWeight); } if (typeof stored.shapeFalloff === 'number' && Number.isFinite(stored.shapeFalloff)) { setNumericInputValue(controls.shapeFalloff, stored.shapeFalloff); } if (typeof stored.caLayerPreset === 'string') { controls.caLayerPreset.value = asCaLayerPresetId(stored.caLayerPreset); } if (typeof stored.caLayerIndex === 'number' && Number.isFinite(stored.caLayerIndex)) { controls.caLayerSelect.value = String(Math.max(0, Math.floor(stored.caLayerIndex))); } if (typeof stored.caRuleIndex === 'number' && Number.isFinite(stored.caRuleIndex)) { controls.caRuleSelect.value = String(Math.max(0, Math.floor(stored.caRuleIndex))); } if (typeof stored.featureProfile === 'string') { controls.featureProfile.value = asFeatureProfileMode(stored.featureProfile); } if (typeof stored.shapeMode === 'string') { controls.shapeMode.value = asShapeMode(stored.shapeMode); } if (typeof stored.overlayLayerId === 'string' && stored.overlayLayerId.trim().length > 0) { controls.overlayStudioLayer.value = stored.overlayLayerId; } if (stored.overlayAuthority === 'visual' || stored.overlayAuthority === 'gameplay') { controls.overlayStudioAuthority.value = stored.overlayAuthority; } if (stored.overlayTool === 'area' || stored.overlayTool === 'spline' || stored.overlayTool === 'waypoint') { controls.overlayStudioTool.value = stored.overlayTool; } state.editSpeedMode = asEditSpeedMode(typeof stored.editSpeed === 'string' ? stored.editSpeed : controls.editSpeed.value); controls.editSpeed.value = state.editSpeedMode; controls.mouseAssist.value = asMouseAssistMode(typeof stored.mouseAssist === 'string' ? stored.mouseAssist : controls.mouseAssist.value); } function listEditableFeatures( state: AppState ): Array<{ kind: 'area' | 'spline'; id: string; label: string }> { const shortId = (id: string): string => (id.length > 22 ? `${id.slice(0, 22)}...` : id); const entries: Array<{ kind: 'area' | 'spline'; id: string; label: string }> = []; for (const feature of state.recipe.areaFeatures ?? []) { entries.push({ kind: 'area', id: feature.id, label: `Area | ${feature.type} | ${shortId(feature.id)}` }); } for (const feature of state.recipe.splineFeatures ?? []) { entries.push({ kind: 'spline', id: feature.id, label: `Spline | ${feature.type} | ${shortId(feature.id)}` }); } return entries; } function findFeatureByRef( state: AppState, ref: FeatureRef | null ): StrategicAreaFeature | StrategicSplineFeature | null { if (!ref) return null; if (ref.kind === 'area') { return (state.recipe.areaFeatures ?? []).find((feature) => feature.id === ref.id) ?? null; } return (state.recipe.splineFeatures ?? []).find((feature) => feature.id === ref.id) ?? null; } function findSelectedFeature( state: AppState ): StrategicAreaFeature | StrategicSplineFeature | null { return findFeatureByRef(state, state.selectedFeatureRef); } function syncOverlayRecipeToBundles(state: AppState): void { const overlays = cloneOverlayBundleForEdit(state.recipe.overlays); if (state.bundle) { state.bundle.recipe.overlays = cloneOverlayBundleForEdit(overlays); } if (state.fullBundle) { state.fullBundle.recipe.overlays = cloneOverlayBundleForEdit(overlays); } } function normalizeOverlayStudioTool(value: string): StrategicOverlayPrimitiveType { if (value === 'spline' || value === 'waypoint') return value; return 'area'; } function makeOverlayPrimitiveId(prefix: StrategicOverlayPrimitiveType): string { return `overlay-${prefix}-${Date.now()}-${Math.floor(Math.random() * 10000)}`; } function addOverlayPrimitiveFromSelectedFeature(state: AppState): boolean { const feature = findSelectedFeature(state); if (!feature || !state.selectedFeatureRef) { setStatus('Select an area or spline feature first.'); return false; } const layerId = controls.overlayStudioLayer.value || 'wetness'; const authority: StrategicOverlayAuthority = controls.overlayStudioAuthority.value === 'gameplay' ? 'gameplay' : 'visual'; const selectedTool = normalizeOverlayStudioTool(controls.overlayStudioTool.value); const expectedKind = selectedTool === 'area' ? 'area' : 'spline'; if (selectedTool !== 'waypoint' && state.selectedFeatureRef.kind !== expectedKind) { setStatus(`Selected feature must be a ${expectedKind} for tool ${selectedTool}.`); return false; } const points = feature.points.map((point) => ({ x: Number(point.x) || 0, z: Number(point.z) || 0, weight: Number.isFinite(point.weight) ? Number(point.weight) : undefined })); if (points.length <= 0) { setStatus('Selected feature has no control points.'); return false; } beginHistoryTransaction(state); const overlays = ensureOverlayBundle(state.recipe); overlays.primitives!.push({ id: makeOverlayPrimitiveId(selectedTool === 'waypoint' ? 'waypoint' : selectedTool), layerId, type: selectedTool === 'waypoint' ? 'waypoint' : selectedTool, points, authority, closed: selectedTool === 'area', radius: selectedTool === 'spline' && 'width' in feature ? Math.max(20, Number(feature.width) || 20) : undefined, label: selectedTool === 'area' && 'type' in feature ? String(feature.type) : undefined }); overlays.authority![layerId] = authority; commitHistoryTransaction(state); syncOverlayRecipeToBundles(state); syncOverlayStudioReadout(state); setStatus(`Projected ${selectedTool} primitive to overlay layer ${layerId}.`); saveLatest(state, { persistBundle: false }); return true; } function addOverlayWaypointAtCursor(state: AppState): boolean { const layerId = controls.overlayStudioLayer.value || 'wetness'; const authority: StrategicOverlayAuthority = controls.overlayStudioAuthority.value === 'gameplay' ? 'gameplay' : 'visual'; const canvasX = state.probe.active ? state.probe.canvasX : canvas.width * 0.5; const canvasY = state.probe.active ? state.probe.canvasY : canvas.height * 0.5; const world = canvasToWorld(state, canvasX, canvasY); beginHistoryTransaction(state); const overlays = ensureOverlayBundle(state.recipe); overlays.primitives!.push({ id: makeOverlayPrimitiveId('waypoint'), layerId, type: 'waypoint', authority, points: [{ x: world.x, z: world.z, weight: 1 }], radius: Math.max(20, Number.parseFloat(controls.influenceRadius.value) * 0.25 || 120), label: 'Waypoint' }); overlays.authority![layerId] = authority; commitHistoryTransaction(state); syncOverlayRecipeToBundles(state); syncOverlayStudioReadout(state); setStatus(`Added waypoint primitive to overlay layer ${layerId}.`); saveLatest(state, { persistBundle: false }); return true; } function clearOverlayPrimitivesForActiveLayer(state: AppState): number { const layerId = controls.overlayStudioLayer.value || 'wetness'; const overlays = ensureOverlayBundle(state.recipe); const before = overlays.primitives!.length; beginHistoryTransaction(state); overlays.primitives = overlays.primitives!.filter((primitive) => primitive.layerId !== layerId); const removed = before - overlays.primitives.length; commitHistoryTransaction(state); syncOverlayRecipeToBundles(state); syncOverlayStudioReadout(state); saveLatest(state, { persistBundle: false }); return removed; } function refreshFeatureEditor(state: AppState): void { const entries = listEditableFeatures(state); const select = controls.featureSelect; const previous = state.selectedFeatureRef ? `${state.selectedFeatureRef.kind}:${state.selectedFeatureRef.id}` : ''; select.innerHTML = ''; if (entries.length === 0) { const option = document.createElement('option'); option.value = ''; option.textContent = 'No features'; select.appendChild(option); setSelectedFeatureRef(state, null); loadSelectedFeatureToEditor(state); return; } let selectedValue = ''; for (const entry of entries) { const option = document.createElement('option'); option.value = `${entry.kind}:${entry.id}`; option.textContent = entry.label; if (!selectedValue || option.value === previous) { selectedValue = option.value; } select.appendChild(option); } if (!selectedValue && select.options.length > 0) { selectedValue = select.options[0].value; } select.value = selectedValue; const [kind, id] = selectedValue.split(':'); setSelectedFeatureRef(state, kind && id ? { kind: kind as 'area' | 'spline', id } : null); loadSelectedFeatureToEditor(state); } function loadSelectedFeatureToEditor(state: AppState): void { const feature = findSelectedFeature(state); const profile = feature?.profile; controls.featureProfile.value = profile?.mode ?? 'default'; controls.featureEdge.value = String(profile?.edgeSoftness ?? 1); controls.featureSharpness.value = String(profile?.sharpness ?? 1); controls.featureBank.value = String(profile?.bankStrength ?? 0); controls.featureTarget.value = String(profile?.targetHeight ?? 0); controls.featureTerraceSteps.value = String(profile?.terraceSteps ?? 8); controls.featureTerraceStrength.value = String(profile?.terraceStrength ?? 0.7); if (feature) { setNumericInputValue(controls.shapeIntensity, Math.max(1, Number(feature.intensity) || 16)); setNumericInputValue(controls.shapeFalloff, Math.max(0.2, Number(feature.falloff) || 1)); if ('width' in feature) { setNumericInputValue(controls.shapeWidth, Math.max(80, Number(feature.width) || 500)); } if (feature.points.length > 0) { if ('width' in feature) { sanitizeSelectedSplinePointIndex(state); } const selectedPoint = 'width' in feature && state.selectedFeaturePointIndex !== null && feature.points[state.selectedFeaturePointIndex] ? feature.points[state.selectedFeaturePointIndex] : null; if (selectedPoint) { setNumericInputValue( controls.shapePointWeight, Math.max(0.2, Math.min(3, Number.isFinite(selectedPoint.weight) ? Number(selectedPoint.weight) : 1)) ); } else { let weightSum = 0; for (let i = 0; i < feature.points.length; i += 1) { const point = feature.points[i]; weightSum += Number.isFinite(point.weight) ? Number(point.weight) : 1; } setNumericInputValue(controls.shapePointWeight, weightSum / feature.points.length); } } if ('width' in feature && state.selectedFeaturePointIndex !== null) { const displayIndex = state.selectedFeaturePointIndex + 1; controls.featureMeta.textContent = `${feature.type} (${feature.points.length} pts) | Point ${displayIndex}`; } else { controls.featureMeta.textContent = `${feature.type} (${feature.points.length} pts)`; } } else { state.selectedFeaturePointIndex = null; controls.featureMeta.textContent = 'No feature selected.'; } if (feature && 'width' in feature) { controls.featureWeights.value = feature.points .map((point) => (Number.isFinite(point.weight) ? Number(point.weight) : 1).toFixed(2)) .join(', '); } else { controls.featureWeights.value = ''; } updateControlReadouts(); syncToolModeUi(state); } function updateSelectedFeatureFromEditor( state: AppState, options?: { applyWeights?: boolean; schedule?: boolean } ): void { const feature = findSelectedFeature(state); if (!feature) return; const mode = asFeatureProfileMode(controls.featureProfile.value); const edgeSoftness = Math.max(0.25, Number.parseFloat(controls.featureEdge.value) || 1); const sharpness = Math.max(0.35, Number.parseFloat(controls.featureSharpness.value) || 1); const bankStrength = Math.max(0, Number.parseFloat(controls.featureBank.value) || 0); const targetHeight = Number.parseFloat(controls.featureTarget.value) || 0; const terraceSteps = Math.max(2, Number.parseInt(controls.featureTerraceSteps.value, 10) || 8); const terraceStrength = Math.max(0, Math.min(1, Number.parseFloat(controls.featureTerraceStrength.value) || 0)); feature.profile = { mode, edgeSoftness, sharpness, bankStrength, targetHeight, terraceSteps, terraceStrength }; if (options?.applyWeights && 'width' in feature) { const parsed = controls.featureWeights.value .split(',') .map((value) => Number.parseFloat(value.trim())) .filter((value) => Number.isFinite(value)); if (parsed.length > 0) { for (let i = 0; i < feature.points.length; i += 1) { const weight = parsed[Math.min(parsed.length - 1, i)]; feature.points[i].weight = Math.max(0.2, Math.min(3, weight)); } controls.featureWeights.value = feature.points .map((point) => (Number.isFinite(point.weight) ? Number(point.weight) : 1).toFixed(2)) .join(', '); } } if (options?.schedule !== false) { scheduleInteractiveRegenerate(state, 0, 1200); } } function updateSelectedFeatureGeometryFromShapeInputs( state: AppState, options?: { applyPointWeights?: boolean; schedule?: 'none' | 'preview' | 'interactive' } ): boolean { const feature = findSelectedFeature(state); if (!feature) return false; const intensity = Math.max(1, Number.parseFloat(controls.shapeIntensity.value) || 16); const falloff = Math.max(0.2, Math.min(2.5, Number.parseFloat(controls.shapeFalloff.value) || 1)); feature.intensity = intensity; feature.falloff = falloff; if ('width' in feature) { feature.width = Math.max(80, Number.parseFloat(controls.shapeWidth.value) || 500); } if (options?.applyPointWeights) { const weight = Math.max(0.2, Math.min(3, Number.parseFloat(controls.shapePointWeight.value) || 1)); const selectedPointIndex = 'width' in feature && state.selectedFeaturePointIndex !== null ? state.selectedFeaturePointIndex : null; if ( selectedPointIndex !== null && selectedPointIndex >= 0 && selectedPointIndex < feature.points.length ) { feature.points[selectedPointIndex].weight = weight; } else { for (let i = 0; i < feature.points.length; i += 1) { feature.points[i].weight = weight; } } if ('width' in feature) { controls.featureWeights.value = feature.points.map((point) => Number(point.weight ?? 1).toFixed(2)).join(', '); } } const scheduleMode = options?.schedule ?? 'interactive'; if (scheduleMode === 'preview') { schedulePreviewRegenerate(state, 0, { interactive: false }); } else if (scheduleMode === 'interactive') { scheduleInteractiveRegenerate(state, 0, 1200); } return true; } function readDraftProfileFromControls(defaultMode: FeatureProfileMode) { const selectedMode = asFeatureProfileMode(controls.featureProfile.value); const mode = selectedMode === 'default' ? defaultMode : selectedMode; return { mode, edgeSoftness: Math.max(0.25, Number.parseFloat(controls.featureEdge.value) || 1), sharpness: Math.max(0.35, Number.parseFloat(controls.featureSharpness.value) || 1), bankStrength: Math.max(0, Number.parseFloat(controls.featureBank.value) || 0), targetHeight: Number.parseFloat(controls.featureTarget.value) || 0, terraceSteps: Math.max(2, Number.parseInt(controls.featureTerraceSteps.value, 10) || 8), terraceStrength: Math.max(0, Math.min(1, Number.parseFloat(controls.featureTerraceStrength.value) || 0)) }; } function syncSidebarPanelButtons(state: AppState): void { controls.canvasPanelImport.classList.toggle('is-active', state.sidebarPanels.importPinned); controls.canvasPanelLayer.classList.toggle('is-active', state.sidebarPanels.layerPinned); controls.canvasPanelOverlays.classList.toggle('is-active', state.sidebarPanels.overlaysPinned); } function asDockTabId(value: string | undefined): DockTabId { if (value === 'session' || value === 'view' || value === 'import' || value === 'export') return value; return 'edit'; } function syncDockTabButtons(state: AppState): void { for (const button of controls.dockTabButtons) { button.classList.toggle('is-active', asDockTabId(button.dataset.dockTab) === state.dockTab); } } function syncDockTabVisibility(state: AppState): void { const panels = controls.sidebarRoot.querySelectorAll('[data-dock-group]'); for (const panel of panels) { const panelGroup = asDockTabId(panel.dataset.dockGroup); panel.classList.toggle('is-tab-hidden', panelGroup !== state.dockTab); } } function setDockTab(state: AppState, tab: DockTabId): void { state.dockTab = tab; syncDockTabButtons(state); syncDockTabVisibility(state); } function mountSidebarAsCanvasOverlay(): void { if (!controls.canvasShell || !controls.sidebarRoot) return; if (controls.sidebarRoot.parentElement === controls.canvasShell) return; controls.canvasShell.appendChild(controls.sidebarRoot); controls.sidebarRoot.classList.add('maplab-sidebar-overlay'); } function setSidebarDockOpen(state: AppState, open: boolean): void { state.sidebarDockOpen = open; controls.sidebarRoot.classList.toggle('is-collapsed', !open); controls.canvasUiToggle.classList.toggle('is-active', open); if (open) { syncDockTabButtons(state); syncDockTabVisibility(state); } } function toggleSidebarDock(state: AppState): void { setSidebarDockOpen(state, !state.sidebarDockOpen); } function ensureSidebarDockOpen(state: AppState): void { if (state.sidebarDockOpen) return; setSidebarDockOpen(state, true); } function syncSidebarPanelsVisibility(state: AppState): void { const hasBundle = !!state.bundle; const hasImportedBase = !!state.importedBaseLayer; const importInFlight = state.pendingImportRequestId !== 0; const showImportAuto = importInFlight || !hasImportedBase; const showLayerAuto = hasBundle && (hasImportedBase || state.activeLayerId !== 'height'); const showInfluenceAuto = hasBundle && asInfluenceMode(controls.influenceMode.value) !== 'none'; const showObjectivesAuto = hasBundle; const showOverlaysAuto = false; controls.importPanel.toggleAttribute('hidden', !(state.sidebarPanels.importPinned || showImportAuto)); controls.layerPanel.toggleAttribute('hidden', !(state.sidebarPanels.layerPinned || showLayerAuto)); controls.influencePanel.toggleAttribute('hidden', !showInfluenceAuto); controls.objectivesPanel.toggleAttribute('hidden', !showObjectivesAuto); controls.viewOverlaysPanel.toggleAttribute('hidden', !(state.sidebarPanels.overlaysPinned || showOverlaysAuto)); syncSidebarPanelButtons(state); syncDockTabVisibility(state); } function setOverlayChecked(overlay: HTMLInputElement, checked: boolean): void { if (overlay.checked === checked) return; overlay.checked = checked; emitControlEvents(overlay); syncToggleToolbar(overlay); } function toggleImportPanelPin(state: AppState): void { ensureSidebarDockOpen(state); setDockTab(state, 'import'); state.sidebarPanels.importPinned = !state.sidebarPanels.importPinned; syncSidebarPanelsVisibility(state); if (state.sidebarPanels.importPinned) { controls.importPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } function toggleLayerPanelPin(state: AppState): void { ensureSidebarDockOpen(state); setDockTab(state, 'view'); const nextPinned = !state.sidebarPanels.layerPinned; state.sidebarPanels.layerPinned = nextPinned; if (nextPinned) { setOverlayChecked(OVERLAYS.heat, true); } else { syncSidebarPanelsVisibility(state); } if (state.sidebarPanels.layerPinned) { controls.layerPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } function toggleOverlaysPanelPin(state: AppState): void { ensureSidebarDockOpen(state); setDockTab(state, 'view'); state.sidebarPanels.overlaysPinned = !state.sidebarPanels.overlaysPinned; syncSidebarPanelsVisibility(state); if (state.sidebarPanels.overlaysPinned) { controls.viewOverlaysPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } function showShortcutsDialog(): void { const dialog = controls.shortcutsDialog; if (!dialog) return; if (!dialog.open) { dialog.showModal(); } } function closeShortcutsDialog(): void { const dialog = controls.shortcutsDialog; if (!dialog || !dialog.open) return; dialog.close(); } function getFullscreenDocument(): Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => Promise | void; } { return document as Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => Promise | void; }; } function isCanvasShellFullscreen(): boolean { const doc = getFullscreenDocument(); const fullscreenElement = doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null; return fullscreenElement === controls.canvasShell; } function syncCanvasFullscreenUi(): void { const active = isCanvasShellFullscreen(); controls.canvasShell.classList.toggle('is-fullscreen', active); controls.canvasFullscreen.classList.toggle('is-active', active); controls.canvasFullscreen.textContent = active ? 'EXIT' : 'FULL'; controls.canvasFullscreen.title = active ? 'Exit fullscreen editor' : 'Enter fullscreen editor'; } async function requestCanvasShellFullscreen(): Promise { const shell = controls.canvasShell as HTMLElement & { webkitRequestFullscreen?: () => Promise | void; }; if (shell.requestFullscreen) { await shell.requestFullscreen(); return true; } if (shell.webkitRequestFullscreen) { await shell.webkitRequestFullscreen(); return true; } return false; } async function exitCanvasShellFullscreen(): Promise { const doc = getFullscreenDocument(); if (doc.exitFullscreen) { await doc.exitFullscreen(); return true; } if (doc.webkitExitFullscreen) { await doc.webkitExitFullscreen(); return true; } return false; } async function toggleCanvasShellFullscreen(state: AppState): Promise { try { if (isCanvasShellFullscreen()) { const exited = await exitCanvasShellFullscreen(); if (!exited) { setStatus('Fullscreen is not supported in this browser.'); return; } setStatus('Exited fullscreen editor mode.'); } else { const entered = await requestCanvasShellFullscreen(); if (!entered) { setStatus('Fullscreen is not supported in this browser.'); return; } setStatus('Entered fullscreen editor mode.'); } syncCanvasFullscreenUi(); requestRender(state); } catch (error) { setStatus(`Fullscreen failed: ${error instanceof Error ? error.message : String(error)}`); } } function updateContextHint(state: AppState): void { if (!controls.contextHint) return; const influenceMode = asInfluenceMode(controls.influenceMode.value); if (influenceMode !== 'none') { controls.contextHint.textContent = `Hint: Influence ${influenceMode}. Paint with LMB, erase with RMB, tune radius/strength in the panel.`; return; } const mode = asShapeMode(controls.shapeMode.value); if (mode === 'none') { const terraform = asTerraformMode(controls.terraformMode.value); if (terraform !== 'off') { controls.contextHint.textContent = `Hint: Terraform ${terraform}. Drag LMB to terraform, RMB to erase. Adjust strength/target in Brush Profile.`; return; } controls.contextHint.textContent = 'Hint: Brush mode. Drag LMB to paint, RMB to erase. Use 7/8/9/0 for symmetry and S/V presets for fast sculpting.'; return; } if (mode === 'edit') { controls.contextHint.textContent = 'Hint: Edit mode. Drag points/feature bodies, Alt-click inserts points, right-click removes point.'; return; } if (state.shapeDraft) { controls.contextHint.textContent = `Hint: ${mode} draft active. Add points on canvas, then press Finish.`; return; } controls.contextHint.textContent = `Hint: ${mode} mode. Press New to start a draft, then click map to place control points.`; } function applyWorkflowPreset(state: AppState, preset: WorkflowPresetId): void { controls.influenceMode.value = 'none'; controls.terraformMode.value = 'off'; setNumericInputValue(controls.terraformStrength, 1); controls.brushSymmetry.value = 'none'; setNumericInputValue(controls.brushSpacing, 1); setNumericInputValue(controls.brushJitter, 0); if (preset === 'sculpt') { controls.shapeMode.value = 'none'; controls.brushType.value = 'plateau'; controls.brushSign.value = 'raise'; setNumericInputValue(controls.brushRadius, 1300); setNumericInputValue(controls.brushIntensity, 18); setNumericInputValue(controls.brushLength, 2800); setNumericInputValue(controls.brushWidth, 950); setNumericInputValue(controls.brushFalloff, 1.0); setNumericInputValue(controls.brushOrientation, 45); setNumericInputValue(controls.brushSpacing, 0.95); setNumericInputValue(controls.brushJitter, 0.04); controls.mouseAssist.value = 'default'; setOverlayChecked(OVERLAYS.features, true); setOverlayChecked(OVERLAYS.heat, true); setStatus('Preset: Sculpt terrain.'); } else if (preset === 'carve') { controls.shapeMode.value = 'none'; controls.brushType.value = 'valley'; controls.brushSign.value = 'lower'; setNumericInputValue(controls.brushRadius, 980); setNumericInputValue(controls.brushIntensity, 22); setNumericInputValue(controls.brushLength, 3400); setNumericInputValue(controls.brushWidth, 1050); setNumericInputValue(controls.brushFalloff, 1.15); setNumericInputValue(controls.brushOrientation, 35); setNumericInputValue(controls.brushSpacing, 1.05); setNumericInputValue(controls.brushJitter, 0.02); controls.mouseAssist.value = 'carve'; setOverlayChecked(OVERLAYS.features, true); setOverlayChecked(OVERLAYS.heat, true); setStatus('Preset: Carve valleys / trenches.'); } else if (preset === 'river') { controls.shapeMode.value = 'river'; setNumericInputValue(controls.shapeWidth, 520); setNumericInputValue(controls.shapeIntensity, 18); setNumericInputValue(controls.shapeFalloff, 0.9); setNumericInputValue(controls.shapePointWeight, 1.0); controls.mouseAssist.value = 'insert'; setOverlayChecked(OVERLAYS.features, true); state.sidebarPanels.overlaysPinned = true; setStatus('Preset: River authoring.'); } else { controls.shapeMode.value = 'edit'; controls.mouseAssist.value = 'insert'; setOverlayChecked(OVERLAYS.features, true); state.sidebarPanels.overlaysPinned = true; setStatus('Preset: Geometry edit.'); } emitControlEvents(controls.shapeMode); emitControlEvents(controls.brushType); emitControlEvents(controls.brushSign); emitControlEvents(controls.brushSymmetry); emitControlEvents(controls.terraformMode); emitControlEvents(controls.mouseAssist); emitControlEvents(controls.influenceMode); updateControlReadouts(); setDockTab(state, 'edit'); syncToolbarStates(); syncToolModeUi(state); schedulePreviewRegenerate(state, 0, { interactive: false }); requestRender(state); } function syncToolModeUi(state: AppState): void { const mode = asShapeMode(controls.shapeMode.value); const influenceMode = asInfluenceMode(controls.influenceMode.value); const terraformMode = asTerraformMode(controls.terraformMode.value); const brushActive = mode === 'none'; const selectedFeature = findSelectedFeature(state); const geometryDraftMode = mode === 'mountain' || mode === 'forest' || mode === 'river' || mode === 'road'; const featureEditorVisible = mode === 'edit' || !!selectedFeature; const shapeProfileVisible = geometryDraftMode || (mode === 'edit' && !!selectedFeature); controls.brushToolGroup.toggleAttribute('hidden', !brushActive); controls.brushProfilePanel.toggleAttribute('hidden', !brushActive); controls.canvasBrushBar.toggleAttribute('hidden', !brushActive); controls.canvasMouseBar.toggleAttribute('hidden', false); controls.shapeProfilePanel.toggleAttribute('hidden', !shapeProfileVisible); controls.featureSdfPanel.toggleAttribute('hidden', !featureEditorVisible); controls.shapeStart.disabled = !geometryDraftMode; controls.shapeFinish.disabled = !geometryDraftMode; controls.shapeCancel.disabled = !geometryDraftMode || !state.shapeDraft; controls.shapeClear.disabled = !geometryDraftMode; const hasGeometryTarget = !!state.shapeDraft || !!selectedFeature; const widthRelevant = state.shapeDraft ? state.shapeDraft.mode === 'river' || state.shapeDraft.mode === 'road' : !!selectedFeature && 'width' in selectedFeature; const pointWeightRelevant = widthRelevant; controls.shapeIntensity.disabled = !hasGeometryTarget; controls.shapeFalloff.disabled = !hasGeometryTarget; controls.shapePointWeight.disabled = !pointWeightRelevant; controls.shapeWidth.disabled = !widthRelevant; controls.featureDelete.disabled = !selectedFeature; controls.featureApplyWeights.disabled = !(selectedFeature && 'width' in selectedFeature); controls.featureWeights.disabled = !(selectedFeature && 'width' in selectedFeature); controls.influenceClearLayer.disabled = influenceMode === 'none'; controls.influenceClearLocks.disabled = !state.constraintLocks; controls.terraformMode.disabled = !brushActive; controls.terraformStrength.disabled = !brushActive; controls.terraformCaptureHeight.disabled = !brushActive; controls.terraformTargetHeight.disabled = !brushActive || (terraformMode !== 'flatten-height' && terraformMode !== 'flatten-cursor'); const assistMode = asMouseAssistMode(controls.mouseAssist.value); const incompatibleAssist = (brushActive && (assistMode === 'insert' || assistMode === 'delete')) || (mode === 'edit' && assistMode === 'carve') || (!brushActive && mode !== 'edit' && assistMode !== 'default'); if (incompatibleAssist) { controls.mouseAssist.value = 'default'; syncSelectToolbar(controls.mouseAssist); } syncSidebarPanelsVisibility(state); updateContextHint(state); } function getMouseAssistStatus(mode: MouseAssistMode): string { if (mode === 'carve') return 'Mouse assist: Carve. In brush mode, LMB lowers terrain.'; if (mode === 'insert') return 'Mouse assist: Insert. In edit mode, LMB inserts points on nearby segments.'; if (mode === 'delete') return 'Mouse assist: Delete. In edit mode, LMB removes points.'; return 'Mouse assist: Default mouse behavior.'; } function applyFeatureProfileInput(state: AppState, options?: { applyWeights?: boolean }): void { const mode = asShapeMode(controls.shapeMode.value); const draftingGeometry = mode !== 'none' && mode !== 'edit' && !!state.shapeDraft; if (draftingGeometry) { setStatusThrottled(state, 'Geometry draft SDF profile updated (applies on finish).'); return; } if (!findSelectedFeature(state)) { setStatusThrottled(state, 'Select a feature to edit SDF profile.'); return; } updateSelectedFeatureFromEditor(state, { applyWeights: options?.applyWeights === true }); } function applyRecipeToControls(state: AppState): void { controls.seed.value = String(state.recipe.seed); controls.players.value = String(state.recipe.players); controls.symmetry.value = state.recipe.symmetry; controls.biome.value = state.recipe.biome; controls.sizePreset.value = state.recipe.mapSizePreset; controls.resolution.value = String(state.recipe.resolution); controls.competitive.checked = state.recipe.competitiveMode; const caConfig = cloneCellularAutomataConfigForEdit(state.recipe.ca) ?? createDefaultCaConfigForEdit(); controls.caEnabled.value = caConfig.enabled === true ? '1' : '0'; setSelectValueSafely( controls.caBaseResolution, String(Math.max(16, Math.floor(Number(caConfig.gridBaseResolution) || DEFAULT_CA_BASE_RESOLUTION))), String(DEFAULT_CA_BASE_RESOLUTION) ); setSelectValueSafely( controls.caSubdivision, String(Math.max(0, Math.floor(Number(caConfig.subdivisionSteps) || DEFAULT_CA_SUBDIVISION_STEPS))), String(DEFAULT_CA_SUBDIVISION_STEPS) ); setSelectValueSafely( controls.caLayerPreset, resolveCaPresetIdForRecipe(state.recipe), 'paper-default' ); ensureMarkerGuidanceConfigForEditing(state); applyMapLabEditorStateFromRecipe(state, state.recipe); refreshCaLayerEditor(state); refreshMarkerGuidanceEditor(state); refreshOverlayStudioLayerSelector(state); syncOverlayStudioReadout(state); syncCaMiniControls(); } function readRecipeFromControls(state: AppState): StrategicMapRecipe { const preset = controls.sizePreset.value as MapSizePreset; const mapSizeMeters = MAP_SIZE_PRESET_METERS[preset]; const resolution = Number.parseInt(controls.resolution.value, 10) || MAP_SIZE_PRESET_RESOLUTION[preset]; const currentCa = ensureCaConfigForEditing(state); const currentMarkerGuidance = ensureMarkerGuidanceConfigForEditing(state); const selectedPreset = asCaLayerPresetId(controls.caLayerPreset.value); const nextLayers = selectedPreset === 'custom' ? cloneCaLayersForEdit(currentCa.layers as RecipeCaLayer[] | undefined) : clonePresetCaLayersForEditor(selectedPreset); const nextCa: RecipeCaConfig = { enabled: controls.caEnabled.value === '1', gridBaseResolution: Math.max(16, Math.min(512, Number.parseInt(controls.caBaseResolution.value, 10) || DEFAULT_CA_BASE_RESOLUTION)), subdivisionSteps: Math.max(0, Math.min(6, Number.parseInt(controls.caSubdivision.value, 10) || DEFAULT_CA_SUBDIVISION_STEPS)), symmetryMode: currentCa.symmetryMode ?? 'follow-recipe', ruleSeedOffset: Number.isFinite(currentCa.ruleSeedOffset) ? Number(currentCa.ruleSeedOffset) : DEFAULT_CA_RULE_SEED_OFFSET, postprocess: { ...normalizeCaPostprocessForEditor(currentCa.postprocess), enabled: controls.caPostEnabled.value === '1', smoothingIterations: Math.floor(clampCaNumber(Number.parseFloat(controls.caPostIterations.value), 0, 8)), smoothingStrength: clampCaNumber(Number.parseFloat(controls.caPostStrength.value), 0, 1), preserveGameplayMasks: controls.caPostPreserveMasks.value !== '0', criticalMaskMaxDrift: clampCaNumber(Number.parseFloat(controls.caPostMaxDrift.value), 0, 64), detailRecoveryEnabled: controls.caDetailEnabled.value === '1', detailTreeDensity: clampCaNumber(Number.parseFloat(controls.caDetailTreeDensity.value), 0, 2), detailRockDensity: clampCaNumber(Number.parseFloat(controls.caDetailRockDensity.value), 0, 2), detailMaterialVariation: clampCaNumber(Number.parseFloat(controls.caDetailMaterialVariation.value), 0, 2) }, layers: nextLayers }; const nextMarkerGuidance: RecipeMarkerGuidanceConfig = { ...currentMarkerGuidance, enabled: controls.markerGuidanceEnabled.value !== '0', spawnMinDistanceMeters: clampCaNumber( Number.parseFloat(controls.markerSpawnMinDistance.value), 200, mapSizeMeters * 0.62 ), spawnMaxSlope: clampCaNumber( Number.parseFloat(controls.markerSpawnMaxSlope.value), 0.02, 1 ), spawnMinBuildableCoverage: clampCaNumber( Number.parseFloat(controls.markerSpawnMinBuildable.value), 0, 1 ), spawnBuildableProbeRadiusMeters: clampCaNumber( Number.parseFloat(controls.markerProbeRadius.value), 60, mapSizeMeters * 0.2 ), resourceMinDistanceMeters: clampCaNumber( Number.parseFloat(controls.markerResourceMinDistance.value), 60, mapSizeMeters * 0.3 ), resourceSpawnSeparationFactor: clampCaNumber( Number.parseFloat(controls.markerResourceSpawnSeparation.value), 0.1, 1.2 ), competitiveSymmetryLock: controls.markerCompetitiveSymmetryLock.value !== '0' }; return { ...state.recipe, seed: Number.parseInt(controls.seed.value, 10) || 0, players: Math.max(2, Number.parseInt(controls.players.value, 10) || 2), symmetry: controls.symmetry.value as StrategicMapRecipe['symmetry'], biome: controls.biome.value as BiomeType, competitiveMode: controls.competitive.checked, mapSizePreset: preset, mapSizeMeters, resolution, ca: nextCa, markerGuidance: nextMarkerGuidance, featureControls: {} }; } function buildPersistableRecipeSnapshot(state: AppState): StrategicMapRecipe { const recipe = ensureFeatureCollections(readRecipeFromControls(state)); const terrainImport = state.importRecipes.length > 0 ? { gridSpec: createMapGridSpecFromRecipe(recipe), activeBaseLayerId: MAPLAB_BASE_LAYER_ID, recipes: state.importRecipes.map(cloneImportRecipeEntry), attribution: state.importAttribution.map(cloneAttributionEntry), importedAt: new Date().toISOString() } : undefined; return { ...recipe, metadata: { ...(recipe.metadata ?? {}), updatedAt: new Date().toISOString(), maplabEditorState: captureMapLabEditorState(state) as unknown as Record, ...(terrainImport ? { terrainImport } : {}) } }; } function resolvePreviewResolutionAdaptive(targetResolution: number, adaptiveLevel: number): number { const normalized = Number.isFinite(targetResolution) ? targetResolution : 2048; let baseIndex = 2; if (normalized <= 1024) baseIndex = 1; else if (normalized <= 2048) baseIndex = 2; else baseIndex = 3; const clampedAdaptive = Math.max(0, Math.min(PREVIEW_ADAPTIVE_MAX_LEVEL, Math.floor(adaptiveLevel))); const tierIndex = Math.max(0, baseIndex - clampedAdaptive); return PREVIEW_RESOLUTION_TIERS[tierIndex]; } function buildRecipeForMode( recipe: StrategicMapRecipe, mode: RegenerationMode, adaptiveLevel = 0 ): StrategicMapRecipe { if (mode !== 'preview') { return recipe; } return { ...recipe, resolution: resolvePreviewResolutionAdaptive(recipe.resolution, adaptiveLevel) }; } function buildPreviewRecipeForState(state: AppState, recipe: StrategicMapRecipe): StrategicMapRecipe { const adaptive = Math.min( PREVIEW_ADAPTIVE_MAX_LEVEL, state.previewAdaptiveLevel + getEditSpeedAdaptiveBoost(state) ); return buildRecipeForMode(recipe, 'preview', adaptive); } function recomputeStats(state: AppState): void { if (!state.bundle) { state.stats = { min: 0, max: 1 }; return; } let min = Infinity; let max = -Infinity; const data = state.bundle.heightData; for (let i = 0; i < data.length; i += 1) { const value = data[i]; if (value < min) min = value; if (value > max) max = value; } state.stats = { min, max }; } function sampleHeight(state: AppState, fx: number, fz: number): number { if (!state.bundle) return 0; const width = state.bundle.width; const height = state.bundle.height; const x = Math.min(width - 1, Math.max(0, Math.round(fx * (width - 1)))); const z = Math.min(height - 1, Math.max(0, Math.round((1 - fz) * (height - 1)))); return state.bundle.heightData[z * width + x]; } function applyHeightPatchToBundle(bundle: StrategicMapBundle, patch: WorkerHeightPatchTransfer): void { const patchData = new Float32Array(patch.dataBuffer); if (patchData.length !== patch.width * patch.height) return; for (let row = 0; row < patch.height; row += 1) { const srcStart = row * patch.width; const dstStart = (patch.y + row) * bundle.width + patch.x; bundle.heightData.set(patchData.subarray(srcStart, srcStart + patch.width), dstStart); } } function ensureOverlayBundle(recipe: StrategicMapRecipe): StrategicOverlayBundle { if (recipe.overlays) { recipe.overlays.raster = recipe.overlays.raster ?? []; recipe.overlays.primitives = recipe.overlays.primitives ?? []; recipe.overlays.authority = recipe.overlays.authority ?? {}; recipe.overlays.effects = recipe.overlays.effects ?? {}; recipe.overlays.metadata = recipe.overlays.metadata ?? {}; return recipe.overlays; } const bundle: StrategicOverlayBundle = { version: 1, raster: [], primitives: [], authority: {}, effects: {}, metadata: {} }; recipe.overlays = bundle; return bundle; } function cloneLayerDataForPatch(kind: LayerKind, length: number): Float32Array | Uint8Array | Uint16Array { if (kind === 'uint8') return new Uint8Array(length); if (kind === 'uint16') return new Uint16Array(length); return new Float32Array(length); } function decodeOverlayPatchData(patch: WorkerOverlayLayerPatchTransfer): Float32Array | Uint8Array | Uint16Array | null { const expected = patch.width * patch.height; if (expected <= 0) return null; if (patch.kind === 'uint8') { const data = new Uint8Array(patch.dataBuffer); return data.length === expected ? data : null; } if (patch.kind === 'uint16') { const data = new Uint16Array(patch.dataBuffer); return data.length === expected ? data : null; } const data = new Float32Array(patch.dataBuffer); return data.length === expected ? data : null; } function cloneLayerData(data: Layer['data']): Layer['data'] { if (data instanceof Uint8Array) return new Uint8Array(data); if (data instanceof Uint16Array) return new Uint16Array(data); return new Float32Array(data); } function applySingleOverlayLayerPatch(state: AppState, patch: WorkerOverlayLayerPatchTransfer): boolean { const fullWidth = Math.max(1, Math.floor(patch.fullWidth)); const fullHeight = Math.max(1, Math.floor(patch.fullHeight)); const expectedFull = fullWidth * fullHeight; if (expectedFull <= 0) return false; const incoming = decodeOverlayPatchData(patch); if (!incoming) return false; const existing = state.layerStore.getLayerRef(patch.layerId); let data: Layer['data']; if ( existing && existing.width === fullWidth && existing.height === fullHeight && existing.meta.kind === patch.kind ) { data = cloneLayerData(existing.data); } else { data = cloneLayerDataForPatch(patch.kind, expectedFull); } const clampedX = Math.max(0, Math.min(fullWidth - 1, Math.floor(patch.x))); const clampedY = Math.max(0, Math.min(fullHeight - 1, Math.floor(patch.y))); const patchWidth = Math.max(1, Math.min(patch.width, fullWidth - clampedX)); const patchHeight = Math.max(1, Math.min(patch.height, fullHeight - clampedY)); if (incoming.length !== patch.width * patch.height) { return false; } for (let row = 0; row < patchHeight; row += 1) { const srcStart = row * patch.width; const dstStart = (clampedY + row) * fullWidth + clampedX; data.set(incoming.subarray(srcStart, srcStart + patchWidth), dstStart); } upsertMapLabLayer(state, { meta: { id: patch.layerId, label: patch.label, kind: patch.kind, unit: patch.unit, range: Number.isFinite(patch.rangeMin) && Number.isFinite(patch.rangeMax) ? { min: Number(patch.rangeMin), max: Number(patch.rangeMax) } : undefined }, data, width: fullWidth, height: fullHeight }); const overlayBundle = ensureOverlayBundle(state.recipe); overlayBundle.authority![patch.layerId] = patch.authority; syncOverlayRecipeToBundles(state); return !existing; } function applyOverlayLayerPatchesFromWorker(state: AppState, msg: WorkerSuccessMessage): void { const patches = msg.overlayLayerPatches ?? []; if (patches.length <= 0) return; let addedLayer = false; for (const patch of patches) { if (applySingleOverlayLayerPatch(state, patch)) { addedLayer = true; } } if (addedLayer) { refreshLayerSelector(state); if (state.overlayAutoRange) { syncLayerRangeFromActive(state); } } syncOverlayStudioReadout(state); } function applyOverlayPrimitivesPatchFromWorker(state: AppState, msg: WorkerSuccessMessage): void { const patch = msg.overlayPrimitivesPatch; if (!patch || patch.mode !== 'replace') return; const overlayBundle = ensureOverlayBundle(state.recipe); overlayBundle.primitives = patch.primitives.map((primitive) => ({ id: primitive.id, layerId: primitive.layerId, type: primitive.type, authority: primitive.authority, points: primitive.points.map((point) => ({ x: Number(point.x) || 0, z: Number(point.z) || 0, weight: Number.isFinite(point.weight) ? Number(point.weight) : undefined })), closed: primitive.closed === true, radius: Number.isFinite(primitive.radius) ? Number(primitive.radius) : undefined, label: primitive.label })); syncOverlayRecipeToBundles(state); syncOverlayStudioReadout(state); } function applyHeightPatchesToBundle(bundle: StrategicMapBundle, msg: WorkerSuccessMessage): void { const patches = msg.heightPatches && msg.heightPatches.length > 0 ? msg.heightPatches : (msg.heightPatch ? [msg.heightPatch] : []); for (const patch of patches) { applyHeightPatchToBundle(bundle, patch); } } function applyWorkerHeatmapPatch(state: AppState, patch: WorkerHeatmapPatchTransfer | undefined): void { if (!patch || !heatmapCtx) return; if (heatmapCanvas.width !== patch.fullWidth || heatmapCanvas.height !== patch.fullHeight) { heatmapCanvas.width = patch.fullWidth; heatmapCanvas.height = patch.fullHeight; } const image = new ImageData(new Uint8ClampedArray(patch.rgbaBuffer), patch.width, patch.height); heatmapCtx.putImageData(image, patch.x, patch.y); state.workerHeatmap = { width: patch.fullWidth, height: patch.fullHeight }; } function applyWorkerHeatmapPatches(state: AppState, msg: WorkerSuccessMessage): void { const patches = msg.heatmapPatches && msg.heatmapPatches.length > 0 ? msg.heatmapPatches : (msg.heatmapPatch ? [msg.heatmapPatch] : []); for (const patch of patches) { applyWorkerHeatmapPatch(state, patch); } } function applyWorkerContourPatch(state: AppState, patch: WorkerContourPatchTransfer | undefined): void { if (!patch || !contourCtx) return; if (contourCanvas.width !== patch.fullWidth || contourCanvas.height !== patch.fullHeight) { contourCanvas.width = patch.fullWidth; contourCanvas.height = patch.fullHeight; } const image = new ImageData(new Uint8ClampedArray(patch.rgbaBuffer), patch.width, patch.height); contourCtx.putImageData(image, patch.x, patch.y); state.workerContour = { width: patch.fullWidth, height: patch.fullHeight }; } function applyWorkerContourPatches(state: AppState, msg: WorkerSuccessMessage): void { const patches = msg.contourPatches && msg.contourPatches.length > 0 ? msg.contourPatches : (msg.contourPatch ? [msg.contourPatch] : []); for (const patch of patches) { applyWorkerContourPatch(state, patch); } } function drawWorkerHeatmap(state: AppState): boolean { if (!ctx || !state.workerHeatmap || !state.bundle) return false; if (state.workerHeatmap.width !== state.bundle.width || state.workerHeatmap.height !== state.bundle.height) { return false; } ctx.save(); ctx.imageSmoothingEnabled = true; // Worker heatmap rows are in heightmap row order (origin at bottom-left), so flip for UI top-down view. ctx.translate(0, canvas.height); ctx.scale(1, -1); ctx.drawImage(heatmapCanvas, 0, 0, canvas.width, canvas.height); ctx.restore(); return true; } function drawWorkerContours(state: AppState): boolean { if (!ctx || !state.workerContour || !state.bundle) return false; if (state.workerContour.width !== state.bundle.width || state.workerContour.height !== state.bundle.height) { return false; } ctx.save(); // Keep contour edges crisp when upscaling; smoothing creates haze-like overdraw. ctx.imageSmoothingEnabled = false; // Worker contour rows are in heightmap row order (origin at bottom-left), so flip for UI top-down view. ctx.translate(0, canvas.height); ctx.scale(1, -1); ctx.drawImage(contourCanvas, 0, 0, canvas.width, canvas.height); ctx.restore(); return true; } function resizeCanvasToDisplaySize(state?: AppState): void { if (!ctx) return; const dpr = Math.max(1, window.devicePixelRatio || 1); const rect = canvas.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width * dpr)); const height = Math.max(1, Math.floor(rect.height * dpr)); let resized = false; if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; resized = true; } if (gpuCanvas.width !== width || gpuCanvas.height !== height) { gpuCanvas.width = width; gpuCanvas.height = height; resized = true; } if (resized && state?.overlayRenderer) { state.overlayRenderer.resize(); } } function drawHeatmap(state: AppState): void { if (!ctx || !state.bundle) return; const targetWidth = Math.max(64, Math.min(HEATMAP_RENDER_MAX_DIM, canvas.width)); const targetHeight = Math.max( 64, Math.min( HEATMAP_RENDER_MAX_DIM, Math.round(targetWidth * (canvas.height / Math.max(1, canvas.width))) ) ); if (!heatmapImageCache || heatmapImageCache.width !== targetWidth || heatmapImageCache.height !== targetHeight) { heatmapImageCache = ctx.createImageData(targetWidth, targetHeight); } if ( !heatmapLookupCache || heatmapLookupCache.targetWidth !== targetWidth || heatmapLookupCache.targetHeight !== targetHeight || heatmapLookupCache.sourceWidth !== state.bundle.width || heatmapLookupCache.sourceHeight !== state.bundle.height ) { const xLut = new Int32Array(targetWidth); const zLut = new Int32Array(targetHeight); const maxX = Math.max(1, targetWidth - 1); const maxY = Math.max(1, targetHeight - 1); for (let x = 0; x < targetWidth; x += 1) { xLut[x] = Math.min( state.bundle.width - 1, Math.max(0, Math.round((x / maxX) * (state.bundle.width - 1))) ); } for (let y = 0; y < targetHeight; y += 1) { zLut[y] = Math.min( state.bundle.height - 1, Math.max(0, Math.round((1 - y / maxY) * (state.bundle.height - 1))) ); } heatmapLookupCache = { targetWidth, targetHeight, sourceWidth: state.bundle.width, sourceHeight: state.bundle.height, xLut, zLut }; } const image = heatmapImageCache; const lookup = heatmapLookupCache; if (!image || !lookup) return; const min = state.stats.min; const span = Math.max(1e-6, state.stats.max - min); const heightData = state.bundle.heightData; const sourceWidth = state.bundle.width; const buildableMask = state.bundle.masks?.buildable; const rampsMask = state.bundle.masks?.ramps; const chokeMask = state.bundle.masks?.choke; const hasBuildable = OVERLAYS.buildable.checked && !!state.bundle.masks?.buildable; const hasRamps = OVERLAYS.ramps.checked && !!state.bundle.masks?.ramps; const hasChokes = OVERLAYS.chokes.checked && !!state.bundle.masks?.choke; const pixels = image.data; for (let y = 0; y < targetHeight; y += 1) { const z = lookup.zLut[y]; const rowOffset = z * sourceWidth; for (let x = 0; x < targetWidth; x += 1) { const srcIndex = rowOffset + lookup.xLut[x]; const h = heightData[srcIndex]; const t = Math.max(0, Math.min(1, (h - min) / span)); const ridge = Math.max(0, Math.min(1, (t - 0.55) * 2.2)); const basin = Math.max(0, Math.min(1, (0.45 - t) * 2.2)); let r = Math.round(35 + ridge * 180); let g = Math.round(65 + (1 - Math.abs(t - 0.5) * 2) * 110); let b = Math.round(45 + basin * 190); if (hasBuildable && buildableMask && buildableMask[srcIndex]) { g = Math.min(255, g + 35); } if (hasRamps && rampsMask && rampsMask[srcIndex]) { r = Math.min(255, r + 25); g = Math.min(255, g + 25); } if (hasChokes && chokeMask && chokeMask[srcIndex]) { r = Math.min(255, r + 50); } const idx = (y * targetWidth + x) * 4; pixels[idx] = r; pixels[idx + 1] = g; pixels[idx + 2] = b; pixels[idx + 3] = 255; } } if (!heatmapCtx) { ctx.putImageData(image, 0, 0); return; } if (heatmapCanvas.width !== targetWidth || heatmapCanvas.height !== targetHeight) { heatmapCanvas.width = targetWidth; heatmapCanvas.height = targetHeight; } heatmapCtx.putImageData(image, 0, 0); ctx.save(); ctx.imageSmoothingEnabled = true; ctx.drawImage(heatmapCanvas, 0, 0, canvas.width, canvas.height); ctx.restore(); } function drawContoursCpu(state: AppState): void { if (!ctx || !state.bundle || !OVERLAYS.contours.checked) return; const rowStep = Math.max(8, Math.floor(canvas.height / CONTOUR_CPU_ROW_TARGET)); const colStep = Math.max(6, Math.floor(canvas.width / CONTOUR_CPU_COL_TARGET)); ctx.save(); ctx.strokeStyle = `rgba(255,255,255,${CONTOUR_CPU_STROKE_ALPHA.toFixed(2)})`; ctx.lineWidth = 1; for (let y = 0; y < canvas.height; y += rowStep) { ctx.beginPath(); let drawing = false; for (let x = 0; x < canvas.width; x += colStep) { const fx = x / Math.max(1, canvas.width - 1); const fz = y / Math.max(1, canvas.height - 1); const h = sampleHeight(state, fx, fz); const contourBand = Math.floor(h / CONTOUR_INTERVAL_METERS); if ((Math.floor(x / colStep) + Math.abs(contourBand)) % CONTOUR_CPU_SAMPLE_MOD === 0) { if (!drawing) { ctx.moveTo(x, y); drawing = true; } else { ctx.lineTo(x, y); } } else { drawing = false; } } ctx.stroke(); } ctx.restore(); } function drawContours(state: AppState): void { if (!OVERLAYS.contours.checked) return; if (state.brushStroke && state.editSpeedMode !== 'quality') return; const usedWorkerContours = state.generationMode === 'preview' && drawWorkerContours(state); if (usedWorkerContours) return; drawContoursCpu(state); } function worldToCanvas(state: AppState, x: number, z: number): { x: number; y: number } { const half = state.recipe.mapSizeMeters * 0.5; const fx = (x + half) / state.recipe.mapSizeMeters; const fz = (z + half) / state.recipe.mapSizeMeters; return { x: fx * canvas.width, y: (1 - fz) * canvas.height }; } function canvasToWorld(state: AppState, px: number, py: number): { x: number; z: number } { const fx = px / Math.max(1, canvas.width); const fz = 1 - py / Math.max(1, canvas.height); const half = state.recipe.mapSizeMeters * 0.5; return { x: fx * state.recipe.mapSizeMeters - half, z: fz * state.recipe.mapSizeMeters - half }; } function clampWorldToMap(state: AppState, world: { x: number; z: number }): { x: number; z: number } { const half = state.recipe.mapSizeMeters * 0.5; return { x: Math.max(-half, Math.min(half, world.x)), z: Math.max(-half, Math.min(half, world.z)) }; } function pointDistanceSq(ax: number, ay: number, bx: number, by: number): number { const dx = ax - bx; const dy = ay - by; return dx * dx + dy * dy; } function distancePointToSegmentSq( px: number, py: number, ax: number, ay: number, bx: number, by: number ): { distanceSq: number; t: number; x: number; y: number } { const abx = bx - ax; const aby = by - ay; const denom = abx * abx + aby * aby; const t = denom <= 1e-6 ? 0 : Math.max(0, Math.min(1, ((px - ax) * abx + (py - ay) * aby) / denom)); const x = ax + abx * t; const y = ay + aby * t; return { distanceSq: pointDistanceSq(px, py, x, y), t, x, y }; } function isPointInPolygon(px: number, py: number, polygon: Array<{ x: number; y: number }>): boolean { let inside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const xi = polygon[i].x; const yi = polygon[i].y; const xj = polygon[j].x; const yj = polygon[j].y; const intersects = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / Math.max(1e-6, yj - yi) + xi); if (intersects) inside = !inside; } return inside; } function setSelectedFeatureRef(state: AppState, ref: FeatureRef | null): void { if ( state.propertyHandleDrag && ( !ref || ref.kind !== state.propertyHandleDrag.target.kind || ref.id !== state.propertyHandleDrag.target.id ) ) { state.propertyHandleDrag = null; } if ( state.splineGizmoDrag && ( !ref || ref.kind !== state.splineGizmoDrag.target.kind || ref.id !== state.splineGizmoDrag.target.id ) ) { state.splineGizmoDrag = null; } if (!ref || ref.kind !== 'spline') { state.selectedFeaturePointIndex = null; } state.selectedFeatureRef = ref; if (!ref) { controls.featureSelect.value = ''; updateContextHint(state); return; } controls.featureSelect.value = `${ref.kind}:${ref.id}`; updateContextHint(state); } function getSelectedSplineFeature(state: AppState): StrategicSplineFeature | null { const feature = findSelectedFeature(state); if (!feature || !('width' in feature)) return null; return feature; } function sanitizeSelectedSplinePointIndex(state: AppState): void { const spline = getSelectedSplineFeature(state); if (!spline || spline.points.length === 0) { state.selectedFeaturePointIndex = null; return; } const index = state.selectedFeaturePointIndex; if (!Number.isInteger(index) || index === null) { state.selectedFeaturePointIndex = 0; return; } state.selectedFeaturePointIndex = Math.max(0, Math.min(spline.points.length - 1, index)); } function setSelectedSplinePointIndex(state: AppState, pointIndex: number | null): void { const spline = getSelectedSplineFeature(state); if (!spline || pointIndex === null) { state.selectedFeaturePointIndex = null; return; } state.selectedFeaturePointIndex = Math.max(0, Math.min(spline.points.length - 1, pointIndex)); } interface FeaturePropertyHandleDescriptor { target: FeatureRef; key: FeaturePropertyHandleKey; label: string; color: string; x: number; y: number; anchorX: number; anchorY: number; radius: number; value: number; min: number; max: number; decimals: number; suffix: string; } function getInputRange(input: HTMLInputElement, fallbackMin: number, fallbackMax: number): { min: number; max: number } { const parsedMin = Number.parseFloat(input.min); const parsedMax = Number.parseFloat(input.max); const min = Number.isFinite(parsedMin) ? parsedMin : fallbackMin; const max = Number.isFinite(parsedMax) ? parsedMax : fallbackMax; if (max <= min) { return { min: fallbackMin, max: fallbackMax }; } return { min, max }; } function clampCanvasPoint(x: number, y: number, pad = 24): { x: number; y: number } { return { x: Math.max(pad, Math.min(Math.max(pad, canvas.width - pad), x)), y: Math.max(pad, Math.min(Math.max(pad, canvas.height - pad), y)) }; } function featureAnchorOnCanvas( state: AppState, feature: StrategicAreaFeature | StrategicSplineFeature ): { x: number; y: number } { if (feature.points.length === 0) { return { x: canvas.width * 0.5, y: canvas.height * 0.5 }; } let sumX = 0; let sumY = 0; for (let i = 0; i < feature.points.length; i += 1) { const p = worldToCanvas(state, feature.points[i].x, feature.points[i].z); sumX += p.x; sumY += p.y; } return clampCanvasPoint(sumX / feature.points.length, sumY / feature.points.length); } function buildSelectedFeaturePropertyHandles(state: AppState): FeaturePropertyHandleDescriptor[] { if (!state.selectedFeatureRef) return []; const feature = findSelectedFeature(state); if (!feature) return []; const target: FeatureRef = { ...state.selectedFeatureRef }; const anchor = featureAnchorOnCanvas(state, feature); const orbit = Math.max(40, Math.min(58, Math.min(canvas.width, canvas.height) * 0.09)); const radius = 10.5; const intensityRange = getInputRange(controls.shapeIntensity, 1, 80); const falloffRange = getInputRange(controls.shapeFalloff, 0.2, 2.5); const handles: FeaturePropertyHandleDescriptor[] = [ { target, key: 'intensity', label: 'INT', color: '#6fe9ff', x: anchor.x, y: anchor.y - orbit, anchorX: anchor.x, anchorY: anchor.y, radius, value: Math.max(intensityRange.min, Math.min(intensityRange.max, Number(feature.intensity) || 16)), min: intensityRange.min, max: intensityRange.max, decimals: 0, suffix: '' }, { target, key: 'falloff', label: 'FAL', color: '#9effbd', x: anchor.x + orbit, y: anchor.y, anchorX: anchor.x, anchorY: anchor.y, radius, value: Math.max(falloffRange.min, Math.min(falloffRange.max, Number(feature.falloff) || 1)), min: falloffRange.min, max: falloffRange.max, decimals: 2, suffix: '' } ]; if ('width' in feature) { const widthRange = getInputRange(controls.shapeWidth, 80, 2500); handles.push({ target, key: 'width', label: 'WID', color: '#ffd29c', x: anchor.x, y: anchor.y + orbit, anchorX: anchor.x, anchorY: anchor.y, radius, value: Math.max(widthRange.min, Math.min(widthRange.max, Number(feature.width) || 500)), min: widthRange.min, max: widthRange.max, decimals: 0, suffix: 'm' }); } return handles.map((handle) => { const clamped = clampCanvasPoint(handle.x, handle.y, 22); return { ...handle, x: clamped.x, y: clamped.y }; }); } function pickSelectedFeaturePropertyHandle( state: AppState, canvasX: number, canvasY: number, maxDistancePx = GEOMETRY_PICK_PROPERTY_HANDLE_RADIUS_PX ): FeaturePropertyHandleDescriptor | null { const handles = buildSelectedFeaturePropertyHandles(state); if (handles.length === 0) return null; let best: FeaturePropertyHandleDescriptor | null = null; let bestDistSq = maxDistancePx * maxDistancePx; for (let i = 0; i < handles.length; i += 1) { const handle = handles[i]; const distanceSq = pointDistanceSq(canvasX, canvasY, handle.x, handle.y); if (distanceSq <= bestDistSq) { best = handle; bestDistSq = distanceSq; } } return best; } function formatFeatureHandleValue(value: number, decimals: number, suffix: string): string { const text = decimals <= 0 ? `${Math.round(value)}` : value.toFixed(decimals); return `${text}${suffix}`; } function getCurrentFeaturePropertyInputValue(key: FeaturePropertyHandleKey): number { if (key === 'intensity') { return Number.parseFloat(controls.shapeIntensity.value) || 16; } if (key === 'falloff') { return Number.parseFloat(controls.shapeFalloff.value) || 1; } return Number.parseFloat(controls.shapeWidth.value) || 500; } function applyFeaturePropertyValueFromHandle( state: AppState, key: FeaturePropertyHandleKey, value: number, schedule: 'preview' | 'interactive' | 'none' ): boolean { if (key === 'intensity') { setNumericInputValue(controls.shapeIntensity, value); } else if (key === 'falloff') { setNumericInputValue(controls.shapeFalloff, value); } else { setNumericInputValue(controls.shapeWidth, value); } updateControlReadouts(); const updated = updateSelectedFeatureGeometryFromShapeInputs(state, { schedule }); if (updated) { syncToolModeUi(state); } return updated; } function beginFeaturePropertyHandleDrag( state: AppState, pointerId: number, handle: FeaturePropertyHandleDescriptor, startCanvasY: number ): boolean { beginHistoryTransaction(state); setSelectedFeatureRef(state, handle.target); const feature = findFeatureByRef(state, handle.target); if (!feature) { state.historyPendingBefore = null; return false; } state.propertyHandleDrag = { pointerId, target: handle.target, key: handle.key, startCanvasY, startValue: handle.value, min: handle.min, max: handle.max, moved: false }; setStatus(`Adjusting ${handle.label}. Drag up/down.`); return true; } function updateFeaturePropertyHandleDrag( state: AppState, pointerId: number, canvasY: number ): void { const drag = state.propertyHandleDrag; if (!drag || drag.pointerId !== pointerId) return; const selected = state.selectedFeatureRef; if (!selected || selected.kind !== drag.target.kind || selected.id !== drag.target.id) { state.propertyHandleDrag = null; return; } const range = Math.max(1e-6, drag.max - drag.min); const sensitivity = range / PROPERTY_HANDLE_DRAG_PIXELS_FOR_FULL_RANGE; const nextValue = Math.max( drag.min, Math.min(drag.max, drag.startValue + (drag.startCanvasY - canvasY) * sensitivity) ); const currentValue = getCurrentFeaturePropertyInputValue(drag.key); const epsilon = drag.key === 'falloff' ? 0.005 : 0.08; if (Math.abs(currentValue - nextValue) < epsilon) return; drag.moved = true; if (!applyFeaturePropertyValueFromHandle(state, drag.key, nextValue, 'preview')) { state.propertyHandleDrag = null; return; } requestRender(state); setStatusThrottled(state, `Editing ${drag.key}: ${formatFeatureHandleValue(nextValue, drag.key === 'falloff' ? 2 : 0, drag.key === 'width' ? 'm' : '')}`); } function endFeaturePropertyHandleDrag(state: AppState, pointerId: number): void { const drag = state.propertyHandleDrag; if (!drag || drag.pointerId !== pointerId) return; state.propertyHandleDrag = null; if (!drag.moved) { state.historyPendingBefore = null; requestRender(state); return; } schedulePreviewRegenerate(state, 0, { interactive: false }); scheduleFullRegenerate(state, 1200); commitHistoryTransaction(state); setStatus('Feature handle edit applied.'); } function pickFeaturePoint( state: AppState, canvasX: number, canvasY: number, maxDistancePx = 14 ): { target: FeatureRef; pointIndex: number } | null { let best: { target: FeatureRef; pointIndex: number; distanceSq: number } | null = null; const maxDistanceSq = maxDistancePx * maxDistancePx; const selectedFirst: Array = []; if (state.selectedFeatureRef) { selectedFirst.push(state.selectedFeatureRef); } for (const area of state.recipe.areaFeatures ?? []) { const ref: FeatureRef = { kind: 'area', id: area.id }; if (!selectedFirst.some((item) => item.kind === ref.kind && item.id === ref.id)) { selectedFirst.push(ref); } } for (const spline of state.recipe.splineFeatures ?? []) { const ref: FeatureRef = { kind: 'spline', id: spline.id }; if (!selectedFirst.some((item) => item.kind === ref.kind && item.id === ref.id)) { selectedFirst.push(ref); } } for (const ref of selectedFirst) { const feature = findFeatureByRef(state, ref); if (!feature) continue; for (let i = 0; i < feature.points.length; i += 1) { const p = worldToCanvas(state, feature.points[i].x, feature.points[i].z); const distanceSq = pointDistanceSq(canvasX, canvasY, p.x, p.y); if (distanceSq > maxDistanceSq) continue; if (!best || distanceSq < best.distanceSq) { best = { target: ref, pointIndex: i, distanceSq }; } } } if (!best) return null; return { target: best.target, pointIndex: best.pointIndex }; } function pickFeatureBody( state: AppState, canvasX: number, canvasY: number, maxDistancePx = 16 ): { target: FeatureRef } | null { const maxDistanceSq = maxDistancePx * maxDistancePx; let best: { target: FeatureRef; distanceSq: number } | null = null; const featureRefs: FeatureRef[] = []; if (state.selectedFeatureRef) { featureRefs.push(state.selectedFeatureRef); } for (const area of state.recipe.areaFeatures ?? []) { const ref: FeatureRef = { kind: 'area', id: area.id }; if (!featureRefs.some((item) => item.kind === ref.kind && item.id === ref.id)) { featureRefs.push(ref); } } for (const spline of state.recipe.splineFeatures ?? []) { const ref: FeatureRef = { kind: 'spline', id: spline.id }; if (!featureRefs.some((item) => item.kind === ref.kind && item.id === ref.id)) { featureRefs.push(ref); } } for (const ref of featureRefs) { const feature = findFeatureByRef(state, ref); if (!feature || feature.points.length < 2) continue; if (ref.kind === 'area') { const polygon = feature.points.map((point) => worldToCanvas(state, point.x, point.z)); if (polygon.length >= 3 && isPointInPolygon(canvasX, canvasY, polygon)) { return { target: ref }; } for (let i = 0; i < polygon.length; i += 1) { const a = polygon[i]; const b = polygon[(i + 1) % polygon.length]; const hit = distancePointToSegmentSq(canvasX, canvasY, a.x, a.y, b.x, b.y); if (hit.distanceSq <= maxDistanceSq && (!best || hit.distanceSq < best.distanceSq)) { best = { target: ref, distanceSq: hit.distanceSq }; } } } else { const spline = feature as StrategicSplineFeature; const widthPx = Math.max(2, (spline.width / state.recipe.mapSizeMeters) * canvas.width); const splineMaxSq = Math.max(maxDistanceSq, (widthPx * 0.75 + 8) ** 2); for (let i = 0; i < spline.points.length - 1; i += 1) { const a = worldToCanvas(state, spline.points[i].x, spline.points[i].z); const b = worldToCanvas(state, spline.points[i + 1].x, spline.points[i + 1].z); const hit = distancePointToSegmentSq(canvasX, canvasY, a.x, a.y, b.x, b.y); if (hit.distanceSq <= splineMaxSq && (!best || hit.distanceSq < best.distanceSq)) { best = { target: ref, distanceSq: hit.distanceSq }; } } } } if (!best) return null; return { target: best.target }; } function pickFeatureSegment( state: AppState, target: FeatureRef, canvasX: number, canvasY: number, maxDistancePx = 16 ): { index: number; world: { x: number; z: number } } | null { const feature = findFeatureByRef(state, target); if (!feature || feature.points.length < 2) return null; let best: { index: number; distanceSq: number; world: { x: number; z: number } } | null = null; const maxDistanceSq = maxDistancePx * maxDistancePx; const segmentCount = target.kind === 'area' ? feature.points.length : feature.points.length - 1; for (let i = 0; i < segmentCount; i += 1) { const nextIndex = target.kind === 'area' ? (i + 1) % feature.points.length : i + 1; const a = worldToCanvas(state, feature.points[i].x, feature.points[i].z); const b = worldToCanvas(state, feature.points[nextIndex].x, feature.points[nextIndex].z); const hit = distancePointToSegmentSq(canvasX, canvasY, a.x, a.y, b.x, b.y); if (hit.distanceSq > maxDistanceSq) continue; if (!best || hit.distanceSq < best.distanceSq) { const world = canvasToWorld(state, hit.x, hit.y); best = { index: i + 1, distanceSq: hit.distanceSq, world }; } } if (!best) return null; return { index: best.index, world: clampWorldToMap(state, best.world) }; } function normalizeWorldDirection(dx: number, dz: number): { x: number; z: number } { const length = Math.hypot(dx, dz); if (length <= 1e-6) return { x: 1, z: 0 }; return { x: dx / length, z: dz / length }; } function getSplinePointTangent(feature: StrategicSplineFeature, pointIndex: number): { x: number; z: number } { const points = feature.points; const current = points[pointIndex]; if (!current) return { x: 1, z: 0 }; const prev = points[pointIndex - 1]; const next = points[pointIndex + 1]; if (prev && next) { return normalizeWorldDirection(next.x - prev.x, next.z - prev.z); } if (next) { return normalizeWorldDirection(next.x - current.x, next.z - current.z); } if (prev) { return normalizeWorldDirection(current.x - prev.x, current.z - prev.z); } return { x: 1, z: 0 }; } function worldDirectionToCanvas(direction: { x: number; z: number }): { x: number; y: number } { const x = direction.x; const y = -direction.z; const length = Math.hypot(x, y); if (length <= 1e-6) return { x: 1, y: 0 }; return { x: x / length, y: y / length }; } function syncSplineFeatureWeightsInput(feature: StrategicSplineFeature): void { controls.featureWeights.value = feature.points .map((point) => (Number.isFinite(point.weight) ? Number(point.weight) : 1).toFixed(2)) .join(', '); } interface SplinePointGizmoDescriptor { target: FeatureRef; pointIndex: number; kind: SplinePointGizmoKind; label: string; color: string; x: number; y: number; anchorX: number; anchorY: number; radius: number; tangent: { x: number; z: number }; normal: { x: number; z: number }; } function buildSelectedSplinePointGizmos(state: AppState): SplinePointGizmoDescriptor[] { if (asShapeMode(controls.shapeMode.value) !== 'edit') return []; if (!state.selectedFeatureRef || state.selectedFeatureRef.kind !== 'spline') return []; const spline = getSelectedSplineFeature(state); if (!spline || spline.points.length < 2) return []; sanitizeSelectedSplinePointIndex(state); const pointIndex = state.selectedFeaturePointIndex; if (pointIndex === null) return []; const point = spline.points[pointIndex]; if (!point) return []; const anchor = worldToCanvas(state, point.x, point.z); const tangent = getSplinePointTangent(spline, pointIndex); const normal = { x: -tangent.z, z: tangent.x }; const tangentCanvas = worldDirectionToCanvas(tangent); const normalCanvas = worldDirectionToCanvas(normal); const pointWeight = Math.max(0.2, Math.min(3, Number.isFinite(point.weight) ? Number(point.weight) : 1)); const widthPx = Math.max(14, (spline.width / state.recipe.mapSizeMeters) * canvas.width); const tangentLen = Math.max(26, Math.min(108, widthPx * 1.15 + 12)); const normalLen = Math.max(28, Math.min(112, 22 + pointWeight * 24)); const radius = 8.8; const target: FeatureRef = { ...state.selectedFeatureRef }; const tangentHandle = clampCanvasPoint( anchor.x + tangentCanvas.x * tangentLen, anchor.y + tangentCanvas.y * tangentLen, 18 ); const normalHandle = clampCanvasPoint( anchor.x + normalCanvas.x * normalLen, anchor.y + normalCanvas.y * normalLen, 18 ); return [ { target, pointIndex, kind: 'tangent', label: 'T', color: '#85f7ff', x: tangentHandle.x, y: tangentHandle.y, anchorX: anchor.x, anchorY: anchor.y, radius, tangent, normal }, { target, pointIndex, kind: 'normal', label: 'N', color: '#ffc987', x: normalHandle.x, y: normalHandle.y, anchorX: anchor.x, anchorY: anchor.y, radius, tangent, normal } ]; } function pickSelectedSplinePointGizmo( state: AppState, canvasX: number, canvasY: number, maxDistancePx = GEOMETRY_PICK_SPLINE_GIZMO_RADIUS_PX ): SplinePointGizmoDescriptor | null { const gizmos = buildSelectedSplinePointGizmos(state); if (gizmos.length === 0) return null; let best: SplinePointGizmoDescriptor | null = null; let bestDistSq = maxDistancePx * maxDistancePx; for (let i = 0; i < gizmos.length; i += 1) { const gizmo = gizmos[i]; const distSq = pointDistanceSq(canvasX, canvasY, gizmo.x, gizmo.y); if (distSq <= bestDistSq) { best = gizmo; bestDistSq = distSq; } } return best; } function beginSplineGizmoDrag( state: AppState, pointerId: number, gizmo: SplinePointGizmoDescriptor ): boolean { beginHistoryTransaction(state); setSelectedFeatureRef(state, gizmo.target); setSelectedSplinePointIndex(state, gizmo.pointIndex); const spline = getSelectedSplineFeature(state); if (!spline) { state.historyPendingBefore = null; return false; } const point = spline.points[gizmo.pointIndex]; if (!point) { state.historyPendingBefore = null; return false; } state.splineGizmoDrag = { pointerId, target: gizmo.target, pointIndex: gizmo.pointIndex, kind: gizmo.kind, startPoint: { x: point.x, z: point.z }, tangent: gizmo.tangent, normal: gizmo.normal, startWeight: Math.max(0.2, Math.min(3, Number.isFinite(point.weight) ? Number(point.weight) : 1)), moved: false }; setStatus(gizmo.kind === 'tangent' ? 'Spline tangent drag active.' : 'Spline normal drag active.'); return true; } function updateSplineGizmoDrag(state: AppState, pointerId: number, world: { x: number; z: number }): void { const drag = state.splineGizmoDrag; if (!drag || drag.pointerId !== pointerId) return; const ref = state.selectedFeatureRef; if (!ref || ref.kind !== 'spline' || ref.id !== drag.target.id) { state.splineGizmoDrag = null; return; } const spline = getSelectedSplineFeature(state); if (!spline) { state.splineGizmoDrag = null; return; } const point = spline.points[drag.pointIndex]; if (!point) { state.splineGizmoDrag = null; return; } const offsetX = world.x - drag.startPoint.x; const offsetZ = world.z - drag.startPoint.z; if (drag.kind === 'tangent') { const t = offsetX * drag.tangent.x + offsetZ * drag.tangent.z; const nextPoint = clampWorldToMap(state, { x: drag.startPoint.x + drag.tangent.x * t, z: drag.startPoint.z + drag.tangent.z * t }); if ( Math.abs(nextPoint.x - point.x) < GEOMETRY_MOVE_EPSILON_METERS && Math.abs(nextPoint.z - point.z) < GEOMETRY_MOVE_EPSILON_METERS ) { return; } drag.moved = true; point.x = nextPoint.x; point.z = nextPoint.z; scheduleGeometryEditPreview(state); requestRender(state); setStatusThrottled(state, 'Spline tangent adjusted.'); return; } const n = offsetX * drag.normal.x + offsetZ * drag.normal.z; const weightStepMeters = Math.max(80, Math.min(640, spline.width * 0.75)); const nextWeight = Math.max(0.2, Math.min(3, drag.startWeight + n / Math.max(1, weightStepMeters))); if (Math.abs((point.weight ?? 1) - nextWeight) < 0.01) return; drag.moved = true; point.weight = nextWeight; if (state.selectedFeaturePointIndex === drag.pointIndex) { setNumericInputValue(controls.shapePointWeight, nextWeight); updateControlReadouts(); } syncSplineFeatureWeightsInput(spline); scheduleGeometryEditPreview(state); requestRender(state); setStatusThrottled(state, `Spline point weight: ${nextWeight.toFixed(2)}`); } function endSplineGizmoDrag(state: AppState, pointerId: number): void { const drag = state.splineGizmoDrag; if (!drag || drag.pointerId !== pointerId) return; state.splineGizmoDrag = null; if (!drag.moved) { state.historyPendingBefore = null; requestRender(state); return; } refreshFeatureEditor(state); schedulePreviewRegenerate(state, 0, { interactive: false }); scheduleFullRegenerate(state, 1300); commitHistoryTransaction(state); setStatus('Spline gizmo edit applied.'); } function computeFallbackSpawnRing(state: AppState, count: number): Array<{ x: number; z: number }> { const playerCount = Math.max(2, Math.floor(count)); const radius = state.recipe.mapSizeMeters * 0.34; const out: Array<{ x: number; z: number }> = []; if (state.recipe.symmetry === 'axis-x') { for (let i = 0; i < playerCount; i += 1) { const t = playerCount <= 1 ? 0.5 : i / (playerCount - 1); out.push({ x: (t * 2 - 1) * radius, z: i % 2 === 0 ? radius : -radius }); } return out; } if (state.recipe.symmetry === 'axis-z') { for (let i = 0; i < playerCount; i += 1) { const t = playerCount <= 1 ? 0.5 : i / (playerCount - 1); out.push({ x: i % 2 === 0 ? radius : -radius, z: (t * 2 - 1) * radius }); } return out; } for (let i = 0; i < playerCount; i += 1) { const angle = (i / playerCount) * Math.PI * 2; out.push({ x: Math.cos(angle) * radius, z: Math.sin(angle) * radius }); } return out; } function collectAutoWaypointCandidates( state: AppState, layerId: string, idPrefix: string ): Array<{ x: number; z: number; weight: number }> { const primitives = state.recipe.overlays?.primitives ?? []; const candidates: Array<{ x: number; z: number; weight: number }> = []; for (const primitive of primitives) { if (primitive.type !== 'waypoint' || primitive.layerId !== layerId) continue; if (typeof primitive.id !== 'string' || !primitive.id.startsWith(idPrefix)) continue; const point = primitive.points?.[0]; if (!point) continue; if (!Number.isFinite(point.x) || !Number.isFinite(point.z)) continue; candidates.push({ x: Number(point.x), z: Number(point.z), weight: Number.isFinite(point.weight) ? Number(point.weight) : 0 }); } candidates.sort((a, b) => { if (b.weight !== a.weight) return b.weight - a.weight; if (a.z !== b.z) return a.z - b.z; return a.x - b.x; }); return candidates; } function collectAutoSpawnCandidates(state: AppState): Array<{ x: number; z: number; weight: number }> { return collectAutoWaypointCandidates(state, 'spawn_score', 'auto_spawn_'); } function collectAutoResourceCandidates(state: AppState): Array<{ x: number; z: number; weight: number }> { return collectAutoWaypointCandidates(state, 'resource_score', 'auto_resource_'); } interface MarkerCandidateSelection { accepted: Array<{ x: number; z: number; weight: number }>; rejected: Array<{ x: number; z: number; weight: number }>; } interface MarkerValidationDiagnostics { spawns: Array<{ x: number; z: number }>; spawnSelection: MarkerCandidateSelection; resourceSelection: MarkerCandidateSelection; } function getMarkerGuidanceConfigForEvaluation(state: AppState): RecipeMarkerGuidanceConfig { const mapSizeMeters = Math.max(1, state.recipe.mapSizeMeters || MAP_SIZE_PRESET_METERS.medium); return normalizeMarkerGuidanceConfigForEditor(mapSizeMeters, state.recipe.markerGuidance); } function sampleMarkerScore( state: AppState, layerId: 'spawn_score' | 'resource_score', candidate: { x: number; z: number; weight: number } ): number { const sampled = sampleLayerAtWorld(state, layerId, candidate); const raw = sampled === null ? candidate.weight : sampled; return clamp01(Number.isFinite(raw) ? raw : 0); } function isMarkerCandidateBlocked(state: AppState, candidate: { x: number; z: number }): boolean { const blocker = sampleLayerAtWorld(state, 'marker_blockers', candidate); if (blocker === null) return false; return blocker >= MARKER_BLOCKER_REJECT_THRESHOLD; } function computeMarkerValidationDiagnostics(state: AppState): MarkerValidationDiagnostics { const count = Math.max(2, state.recipe.players); const config = getMarkerGuidanceConfigForEvaluation(state); const spawnScoreThreshold = clamp01(Number(config.spawnScoreThreshold ?? DEFAULT_MARKER_SPAWN_SCORE_THRESHOLD)); const resourceScoreThreshold = clamp01(Number(config.resourceScoreThreshold ?? DEFAULT_MARKER_RESOURCE_SCORE_THRESHOLD)); const spawnMinDistanceMeters = Math.max(120, Number(config.spawnMinDistanceMeters ?? DEFAULT_MARKER_SPAWN_MIN_DISTANCE_METERS)); const resourceMinDistanceMeters = Math.max(60, Number(config.resourceMinDistanceMeters ?? DEFAULT_MARKER_RESOURCE_MIN_DISTANCE_METERS)); const resourceSpawnSeparation = Math.max( 10, spawnMinDistanceMeters * Math.max( 0.1, Math.min(1.2, Number(config.resourceSpawnSeparationFactor ?? DEFAULT_MARKER_RESOURCE_SPAWN_SEPARATION_FACTOR)) ) ); const autoCandidates = collectAutoSpawnCandidates(state); const spawnAccepted: Array<{ x: number; z: number; weight: number }> = []; const spawnRejected: Array<{ x: number; z: number; weight: number }> = []; const selectedFromAuto: Array<{ x: number; z: number }> = []; if (autoCandidates.length > 0) { for (const candidate of autoCandidates) { const weight = sampleMarkerScore(state, 'spawn_score', candidate); if (weight < spawnScoreThreshold || isMarkerCandidateBlocked(state, candidate)) { spawnRejected.push({ x: candidate.x, z: candidate.z, weight }); continue; } if (selectedFromAuto.length >= count) { spawnRejected.push({ x: candidate.x, z: candidate.z, weight }); continue; } const tooClose = selectedFromAuto.some((existing) => ( Math.hypot(existing.x - candidate.x, existing.z - candidate.z) < spawnMinDistanceMeters )); if (tooClose) { spawnRejected.push({ x: candidate.x, z: candidate.z, weight }); continue; } selectedFromAuto.push({ x: candidate.x, z: candidate.z }); spawnAccepted.push({ x: candidate.x, z: candidate.z, weight }); } } const spawns: Array<{ x: number; z: number }> = [...selectedFromAuto]; if (spawns.length < count) { const fallback = computeFallbackSpawnRing(state, count); for (const spawn of fallback) { if (spawns.length >= count) break; const tooClose = spawns.some((existing) => ( Math.hypot(existing.x - spawn.x, existing.z - spawn.z) < spawnMinDistanceMeters * 0.7 )); if (!tooClose) { spawns.push(spawn); } } if (spawns.length < count) { const finalFallback = computeFallbackSpawnRing(state, count); for (const spawn of finalFallback) { if (spawns.length >= count) break; spawns.push(spawn); } } } const autoResources = collectAutoResourceCandidates(state); const resourceAccepted: Array<{ x: number; z: number; weight: number }> = []; const resourceRejected: Array<{ x: number; z: number; weight: number }> = []; for (const candidate of autoResources) { const weight = sampleMarkerScore(state, 'resource_score', candidate); if (weight < resourceScoreThreshold || isMarkerCandidateBlocked(state, candidate)) { resourceRejected.push({ x: candidate.x, z: candidate.z, weight }); continue; } const nearSpawn = spawns.some((spawn) => ( Math.hypot(candidate.x - spawn.x, candidate.z - spawn.z) < resourceSpawnSeparation )); if (nearSpawn) { resourceRejected.push({ x: candidate.x, z: candidate.z, weight }); continue; } const tooCloseResource = resourceAccepted.some((existing) => ( Math.hypot(candidate.x - existing.x, candidate.z - existing.z) < resourceMinDistanceMeters )); if (tooCloseResource) { resourceRejected.push({ x: candidate.x, z: candidate.z, weight }); continue; } resourceAccepted.push({ x: candidate.x, z: candidate.z, weight }); } return { spawns, spawnSelection: { accepted: spawnAccepted, rejected: spawnRejected }, resourceSelection: { accepted: resourceAccepted, rejected: resourceRejected } }; } function drawAreaFeature(state: AppState, feature: StrategicAreaFeature, selected = false): void { if (!ctx || feature.points.length < 3) return; const fill = feature.type === 'mountain' ? 'rgba(148, 124, 96, 0.24)' : 'rgba(54, 133, 74, 0.24)'; const stroke = feature.type === 'mountain' ? 'rgba(226, 184, 126, 0.95)' : 'rgba(126, 221, 148, 0.95)'; ctx.save(); ctx.fillStyle = fill; ctx.strokeStyle = stroke; ctx.lineWidth = selected ? 3 : 2; ctx.beginPath(); feature.points.forEach((point, index) => { const p = worldToCanvas(state, point.x, point.z); if (index === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); }); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore(); } function drawSplineFeature(state: AppState, feature: StrategicSplineFeature): void { if (!ctx || feature.points.length < 2) return; const stroke = feature.type === 'river' ? 'rgba(84, 176, 255, 0.95)' : 'rgba(255, 198, 108, 0.95)'; const widthPx = Math.max(1.5, (feature.width / state.recipe.mapSizeMeters) * canvas.width); const selected = state.selectedFeatureRef?.kind === 'spline' && state.selectedFeatureRef.id === feature.id; ctx.save(); ctx.strokeStyle = stroke; ctx.lineWidth = selected ? widthPx + 2 : widthPx; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); feature.points.forEach((point, index) => { const p = worldToCanvas(state, point.x, point.z); if (index === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); }); ctx.stroke(); ctx.restore(); } function drawShapeDraft(state: AppState): void { if (!ctx || !state.shapeDraft || state.shapeDraft.mode === 'none') return; const draft = state.shapeDraft; if (draft.points.length === 0) return; const isPolygon = draft.mode === 'forest' || draft.mode === 'mountain'; ctx.save(); ctx.setLineDash([6, 4]); ctx.lineWidth = 1.7; ctx.strokeStyle = isPolygon ? 'rgba(255,255,255,0.95)' : 'rgba(132,196,255,0.95)'; ctx.beginPath(); draft.points.forEach((point, index) => { const p = worldToCanvas(state, point.x, point.z); if (index === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); }); if (isPolygon && draft.points.length > 2) { ctx.closePath(); } ctx.stroke(); ctx.setLineDash([]); for (const point of draft.points) { const p = worldToCanvas(state, point.x, point.z); const pointWeight = Math.max(0.2, Math.min(3, Number.isFinite(point.weight) ? Number(point.weight) : 1)); ctx.beginPath(); ctx.arc(p.x, p.y, 2.2 + pointWeight * 1.9, 0, Math.PI * 2); ctx.fillStyle = '#fdf5e6'; ctx.fill(); } ctx.restore(); } function drawSelectedFeatureHandles(state: AppState): void { if (!ctx || !state.selectedFeatureRef) return; const feature = findSelectedFeature(state); if (!feature) return; ctx.save(); for (let i = 0; i < feature.points.length; i += 1) { const point = feature.points[i]; const p = worldToCanvas(state, point.x, point.z); const selected = (state.geometryEditDrag?.mode === 'point' && state.geometryEditDrag.pointIndex === i) || (state.selectedFeatureRef.kind === 'spline' && state.selectedFeaturePointIndex === i); const radius = selected ? 6.2 : 4.5; ctx.beginPath(); ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); ctx.fillStyle = selected ? 'rgba(255, 186, 84, 0.96)' : 'rgba(250, 251, 255, 0.94)'; ctx.fill(); ctx.lineWidth = 1.2; ctx.strokeStyle = selected ? 'rgba(88, 44, 14, 0.95)' : 'rgba(5, 13, 21, 0.86)'; ctx.stroke(); } ctx.restore(); } function drawSelectedSplinePointGizmos(state: AppState): void { if (!ctx || asShapeMode(controls.shapeMode.value) !== 'edit') return; const gizmos = buildSelectedSplinePointGizmos(state); if (gizmos.length === 0) return; const activeKind = state.splineGizmoDrag?.kind ?? null; const activePoint = state.splineGizmoDrag?.pointIndex ?? -1; const anchor = { x: gizmos[0].anchorX, y: gizmos[0].anchorY }; ctx.save(); ctx.lineWidth = 1.15; ctx.strokeStyle = 'rgba(255,255,255,0.44)'; ctx.beginPath(); ctx.moveTo(anchor.x, anchor.y); for (let i = 0; i < gizmos.length; i += 1) { ctx.lineTo(gizmos[i].x, gizmos[i].y); ctx.moveTo(anchor.x, anchor.y); } ctx.stroke(); ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const spline = getSelectedSplineFeature(state); const weight = spline && state.selectedFeaturePointIndex !== null && spline.points[state.selectedFeaturePointIndex] ? Math.max(0.2, Math.min(3, Number(spline.points[state.selectedFeaturePointIndex].weight ?? 1))) : null; for (let i = 0; i < gizmos.length; i += 1) { const gizmo = gizmos[i]; const isActive = activeKind === gizmo.kind && activePoint === gizmo.pointIndex; ctx.beginPath(); ctx.arc(gizmo.x, gizmo.y, gizmo.radius + (isActive ? 1.4 : 0), 0, Math.PI * 2); ctx.fillStyle = 'rgba(5, 14, 24, 0.94)'; ctx.fill(); ctx.strokeStyle = gizmo.color; ctx.lineWidth = isActive ? 2.3 : 1.6; ctx.stroke(); ctx.fillStyle = gizmo.color; ctx.fillText(gizmo.label, gizmo.x, gizmo.y - 0.4); if (gizmo.kind === 'normal' && weight !== null) { ctx.fillStyle = 'rgba(250, 241, 225, 0.95)'; ctx.fillText(weight.toFixed(2), gizmo.x, gizmo.y + gizmo.radius + 9); } } ctx.restore(); } function drawSelectedFeaturePropertyHandles(state: AppState): void { if (!ctx || asShapeMode(controls.shapeMode.value) !== 'edit') return; const handles = buildSelectedFeaturePropertyHandles(state); if (handles.length === 0) return; const activeKey = state.propertyHandleDrag?.key ?? null; ctx.save(); ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (let i = 0; i < handles.length; i += 1) { const handle = handles[i]; ctx.beginPath(); ctx.moveTo(handle.anchorX, handle.anchorY); ctx.lineTo(handle.x, handle.y); ctx.strokeStyle = 'rgba(220, 235, 255, 0.55)'; ctx.lineWidth = 1.1; ctx.stroke(); const isActive = activeKey === handle.key; ctx.beginPath(); ctx.arc(handle.x, handle.y, handle.radius + (isActive ? 1.8 : 0), 0, Math.PI * 2); ctx.fillStyle = 'rgba(7, 16, 25, 0.93)'; ctx.fill(); ctx.strokeStyle = handle.color; ctx.lineWidth = isActive ? 2.4 : 1.6; ctx.stroke(); ctx.fillStyle = handle.color; ctx.fillText(handle.label, handle.x, handle.y - 0.5); const valueText = formatFeatureHandleValue(handle.value, handle.decimals, handle.suffix); const textY = handle.y + handle.radius + 12; ctx.fillStyle = 'rgba(245, 250, 255, 0.95)'; ctx.fillText(valueText, handle.x, textY); } ctx.restore(); } function drawToolPreviewLabel(x: number, y: number, text: string): void { if (!ctx) return; ctx.save(); ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'; const metrics = ctx.measureText(text); const padX = 8; const padY = 5; const boxW = Math.ceil(metrics.width + padX * 2); const boxH = 22; const anchor = clampCanvasPoint(x + 18, y - 18, 12); const boxX = Math.max(6, Math.min(canvas.width - boxW - 6, anchor.x)); const boxY = Math.max(6, Math.min(canvas.height - boxH - 6, anchor.y - boxH)); ctx.fillStyle = 'rgba(8, 15, 24, 0.84)'; ctx.strokeStyle = 'rgba(214, 235, 255, 0.34)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(boxX, boxY, boxW, boxH, 6); ctx.fill(); ctx.stroke(); ctx.fillStyle = 'rgba(239, 247, 255, 0.96)'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(text, boxX + padX, boxY + boxH * 0.5); ctx.restore(); } function drawActiveToolCursorPreview(state: AppState): void { if (!ctx || !state.bundle || !state.probe.active) return; const cursorX = state.probe.canvasX; const cursorY = state.probe.canvasY; if (!Number.isFinite(cursorX) || !Number.isFinite(cursorY)) return; if (cursorX < 0 || cursorX > canvas.width || cursorY < 0 || cursorY > canvas.height) return; const mapSize = Math.max(1, state.recipe.mapSizeMeters); const scaleX = canvas.width / mapSize; const scaleY = canvas.height / mapSize; const influenceMode = asInfluenceMode(controls.influenceMode.value); if (influenceMode !== 'none') { const radiusMeters = Math.max(10, Number.parseFloat(controls.influenceRadius.value) || 650); const strength = clamp01(Number.parseFloat(controls.influenceStrength.value) || 0.35); const intensityAlpha = 0.08 + strength * 0.18; const ringAlpha = 0.44 + strength * 0.4; const radiusX = Math.max(4, radiusMeters * scaleX); const radiusY = Math.max(4, radiusMeters * scaleY); const palette = influenceMode === 'lane' ? { fill: `rgba(255, 142, 76, ${intensityAlpha.toFixed(3)})`, stroke: `rgba(255, 192, 126, ${ringAlpha.toFixed(3)})` } : influenceMode === 'safe' ? { fill: `rgba(104, 225, 144, ${intensityAlpha.toFixed(3)})`, stroke: `rgba(168, 255, 193, ${ringAlpha.toFixed(3)})` } : influenceMode === 'nobuild' ? { fill: `rgba(250, 106, 129, ${intensityAlpha.toFixed(3)})`, stroke: `rgba(255, 179, 190, ${ringAlpha.toFixed(3)})` } : { fill: `rgba(113, 177, 255, ${intensityAlpha.toFixed(3)})`, stroke: `rgba(180, 214, 255, ${ringAlpha.toFixed(3)})` }; ctx.save(); ctx.fillStyle = palette.fill; ctx.strokeStyle = palette.stroke; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.ellipse(cursorX, cursorY, radiusX, radiusY, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.setLineDash([5, 4]); ctx.lineWidth = 1.1; ctx.beginPath(); ctx.ellipse(cursorX, cursorY, radiusX * 0.62, radiusY * 0.62, 0, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); ctx.beginPath(); ctx.arc(cursorX, cursorY, 3.2, 0, Math.PI * 2); ctx.fillStyle = palette.stroke; ctx.fill(); ctx.restore(); drawToolPreviewLabel( cursorX, cursorY, `INFL ${influenceMode.toUpperCase()} R ${Math.round(radiusMeters)}m S ${strength.toFixed(2)}` ); return; } const shapeMode = asShapeMode(controls.shapeMode.value); if (shapeMode !== 'none' && shapeMode !== 'edit') { const widthMeters = Math.max(40, Number.parseFloat(controls.shapeWidth.value) || 500); const intensity = Math.max(1, Number.parseFloat(controls.shapeIntensity.value) || 16); const widthX = Math.max(4, (widthMeters * 0.5) * scaleX); const widthY = Math.max(4, (widthMeters * 0.5) * scaleY); const intensityRange = getInputRange(controls.shapeIntensity, 1, 80); const t = clamp01((intensity - intensityRange.min) / Math.max(1e-6, intensityRange.max - intensityRange.min)); ctx.save(); ctx.fillStyle = `rgba(124, 195, 255, ${(0.08 + t * 0.16).toFixed(3)})`; ctx.strokeStyle = `rgba(195, 230, 255, ${(0.4 + t * 0.45).toFixed(3)})`; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.ellipse(cursorX, cursorY, widthX, widthY, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.moveTo(cursorX - widthX, cursorY); ctx.lineTo(cursorX + widthX, cursorY); ctx.moveTo(cursorX, cursorY - widthY); ctx.lineTo(cursorX, cursorY + widthY); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); drawToolPreviewLabel( cursorX, cursorY, `${shapeMode.toUpperCase()} W ${Math.round(widthMeters)}m I ${intensity.toFixed(0)}` ); return; } if (shapeMode !== 'none') return; const radiusMeters = Math.max(40, Number.parseFloat(controls.brushRadius.value) || 1200); const intensity = Number.parseFloat(controls.brushIntensity.value) || 12; const lengthMeters = Math.max(80, Number.parseFloat(controls.brushLength.value) || radiusMeters * 2); const widthMeters = Math.max(40, Number.parseFloat(controls.brushWidth.value) || radiusMeters * 0.7); const falloff = Math.max(0.05, Math.min(2.5, Number.parseFloat(controls.brushFalloff.value) || 1)); const intensityRange = getInputRange(controls.brushIntensity, 1, 80); const tIntensity = clamp01((intensity - intensityRange.min) / Math.max(1e-6, intensityRange.max - intensityRange.min)); const outerX = Math.max(4, radiusMeters * scaleX); const outerY = Math.max(4, radiusMeters * scaleY); const coreFactor = 0.34 + (1 - clamp01((falloff - 0.05) / 2.45)) * 0.26; const coreX = outerX * coreFactor; const coreY = outerY * coreFactor; const bodyHalfLenX = Math.max(4, (lengthMeters * 0.5) * scaleX); const bodyHalfWidY = Math.max(3, (widthMeters * 0.5) * scaleY); const world = canvasToWorld(state, cursorX, cursorY); let orientation = (Number.parseFloat(controls.brushOrientation.value) || 0) * (Math.PI / 180); if (state.brushStroke) { const dx = world.x - state.brushStroke.lastWorld.x; const dz = world.z - state.brushStroke.lastWorld.z; if (Math.hypot(dx, dz) > 2) { orientation = Math.atan2(dz, dx); } } const symmetryMode = asBrushSymmetryMode(controls.brushSymmetry.value); const spacingFactor = Math.max(0.55, Math.min(2.4, Number.parseFloat(controls.brushSpacing.value) || 1)); const jitterFactor = clamp01(Number.parseFloat(controls.brushJitter.value) || 0); const terraformMode = asTerraformMode(controls.terraformMode.value); const terraformTarget = Number.parseFloat(controls.terraformTargetHeight.value) || 0; const flattenCursorTarget = state.brushStroke?.flattenTargetHeight ?? terraformTarget; const sign = controls.brushSign.value === 'raise' ? '+' : controls.brushSign.value === 'lower' ? '-' : '+/-'; const symmetryTargets = buildBrushSymmetryTargets(state, world, orientation, symmetryMode); if (symmetryTargets.length > 1) { ctx.save(); for (let i = 1; i < symmetryTargets.length; i += 1) { const targetCanvas = worldToCanvas(state, symmetryTargets[i].world.x, symmetryTargets[i].world.z); ctx.strokeStyle = 'rgba(190, 205, 220, 0.42)'; ctx.fillStyle = 'rgba(116, 146, 172, 0.08)'; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.ellipse(targetCanvas.x, targetCanvas.y, outerX, outerY, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.save(); ctx.translate(targetCanvas.x, targetCanvas.y); ctx.rotate(-symmetryTargets[i].orientation); ctx.strokeStyle = 'rgba(225, 188, 140, 0.42)'; ctx.beginPath(); ctx.ellipse(0, 0, bodyHalfLenX, bodyHalfWidY, 0, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } ctx.restore(); } ctx.save(); ctx.fillStyle = `rgba(124, 211, 255, ${(0.06 + tIntensity * 0.14).toFixed(3)})`; ctx.strokeStyle = `rgba(184, 233, 255, ${(0.45 + tIntensity * 0.4).toFixed(3)})`; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.ellipse(cursorX, cursorY, outerX, outerY, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.ellipse(cursorX, cursorY, coreX, coreY, 0, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); ctx.translate(cursorX, cursorY); ctx.rotate(-orientation); ctx.fillStyle = `rgba(255, 213, 138, ${(0.1 + tIntensity * 0.16).toFixed(3)})`; ctx.strokeStyle = `rgba(255, 230, 182, ${(0.42 + tIntensity * 0.46).toFixed(3)})`; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.ellipse(0, 0, bodyHalfLenX, bodyHalfWidY, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-bodyHalfLenX, 0); ctx.lineTo(bodyHalfLenX, 0); ctx.moveTo(0, -bodyHalfWidY); ctx.lineTo(0, bodyHalfWidY); ctx.strokeStyle = 'rgba(255, 240, 210, 0.55)'; ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); drawToolPreviewLabel( cursorX, cursorY, `STAMP ${controls.brushType.value.toUpperCase()} ${sign} R ${Math.round(radiusMeters)}m I ${Math.abs(intensity).toFixed(0)} F ${falloff.toFixed(2)} SYM ${symmetryMode} SPC ${spacingFactor.toFixed(2)} JIT ${jitterFactor.toFixed(2)} TF ${terraformMode}${terraformMode === 'flatten-height' ? `@${terraformTarget.toFixed(1)}m` : terraformMode === 'flatten-cursor' ? `@${flattenCursorTarget.toFixed(1)}m` : ''}` ); } function drawMarkerValidationOverlay(state: AppState, diagnostics: MarkerValidationDiagnostics): void { if (!ctx) return; ctx.save(); ctx.lineWidth = 1.25; for (const candidate of diagnostics.spawnSelection.accepted) { const p = worldToCanvas(state, candidate.x, candidate.z); ctx.beginPath(); ctx.arc(p.x, p.y, 2.8, 0, Math.PI * 2); ctx.fillStyle = 'rgba(112, 232, 148, 0.9)'; ctx.fill(); } for (const candidate of diagnostics.spawnSelection.rejected) { const p = worldToCanvas(state, candidate.x, candidate.z); const r = 3.7; ctx.strokeStyle = 'rgba(255, 120, 120, 0.92)'; ctx.beginPath(); ctx.moveTo(p.x - r, p.y - r); ctx.lineTo(p.x + r, p.y + r); ctx.moveTo(p.x - r, p.y + r); ctx.lineTo(p.x + r, p.y - r); ctx.stroke(); } for (const candidate of diagnostics.resourceSelection.accepted) { const p = worldToCanvas(state, candidate.x, candidate.z); const r = 3.2; ctx.beginPath(); ctx.moveTo(p.x, p.y - r); ctx.lineTo(p.x + r, p.y); ctx.lineTo(p.x, p.y + r); ctx.lineTo(p.x - r, p.y); ctx.closePath(); ctx.fillStyle = 'rgba(118, 211, 255, 0.9)'; ctx.fill(); } for (const candidate of diagnostics.resourceSelection.rejected) { const p = worldToCanvas(state, candidate.x, candidate.z); const r = 3.1; ctx.strokeStyle = 'rgba(255, 189, 108, 0.9)'; ctx.beginPath(); ctx.moveTo(p.x - r, p.y - r); ctx.lineTo(p.x + r, p.y + r); ctx.moveTo(p.x - r, p.y + r); ctx.lineTo(p.x + r, p.y - r); ctx.stroke(); } ctx.restore(); } function drawOverlays(state: AppState): void { if (!ctx) return; const liveEdit = state.brushStroke !== null; const drawSpawn = OVERLAYS.spawn.checked && !(liveEdit && state.editSpeedMode !== 'quality'); const geometryMode = asShapeMode(controls.shapeMode.value) === 'edit' || state.geometryEditDrag !== null; const drawFeatures = (OVERLAYS.features.checked || geometryMode) && !(liveEdit && state.editSpeedMode === 'turbo'); ctx.save(); if (drawSpawn) { const markerDiagnostics = computeMarkerValidationDiagnostics(state); const spawns = markerDiagnostics.spawns; drawMarkerValidationOverlay(state, markerDiagnostics); ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#0a0a0a'; for (const spawn of spawns) { const p = worldToCanvas(state, spawn.x, spawn.z); ctx.beginPath(); ctx.arc(p.x, p.y, 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } } if (drawFeatures) { for (const area of state.recipe.areaFeatures ?? []) { const selected = state.selectedFeatureRef?.kind === 'area' && state.selectedFeatureRef.id === area.id; drawAreaFeature(state, area, selected); } for (const spline of state.recipe.splineFeatures ?? []) { drawSplineFeature(state, spline); } drawSelectedFeatureHandles(state); drawSelectedSplinePointGizmos(state); drawSelectedFeaturePropertyHandles(state); } drawShapeDraft(state); drawActiveToolCursorPreview(state); ctx.restore(); } function render(state: AppState): void { if (!ctx) return; const renderStart = performance.now(); resizeCanvasToDisplaySize(state); ctx.clearRect(0, 0, canvas.width, canvas.height); if (!state.bundle) { ctx.fillStyle = 'rgba(20,30,40,0.9)'; ctx.fillRect(0, 0, canvas.width, canvas.height); gpuCanvas.style.opacity = '0'; if (state.probe.active) { probeReadoutEl && (probeReadoutEl.textContent = 'Probe: waiting for terrain.'); } state.perf.lastRenderMs = performance.now() - renderStart; updateInteractionLoadScale(state); updatePerfPanel(state); return; } let usedGpuOverlay = false; const canUseGpuOverlay = OVERLAYS.heat.checked && state.overlayRendererReady && state.generationMode !== 'preview'; if (canUseGpuOverlay) { usedGpuOverlay = renderGpuOverlay(state); } if (OVERLAYS.heat.checked && !usedGpuOverlay) { const usedWorkerHeatmap = state.generationMode === 'preview' && drawWorkerHeatmap(state); if (!usedWorkerHeatmap) { drawHeatmap(state); } } else if (!OVERLAYS.heat.checked) { ctx.fillStyle = '#1b2a32'; ctx.fillRect(0, 0, canvas.width, canvas.height); } gpuCanvas.style.opacity = usedGpuOverlay ? '1' : '0'; drawContours(state); drawOverlays(state); if (state.probe.active) { updateProbeReadout(state); } state.perf.lastRenderMs = performance.now() - renderStart; updateInteractionLoadScale(state); updatePerfPanel(state); } function requestRender(state: AppState): void { if (state.renderFrameHandle !== null) { state.renderQueued = true; return; } state.renderFrameHandle = window.requestAnimationFrame(() => { state.renderFrameHandle = null; render(state); if (state.renderQueued) { state.renderQueued = false; requestRender(state); } }); } function updateDataPanel(state: AppState, force = false): void { if (!state.bundle) { dataEl.textContent = 'No generation run yet.'; return; } const now = performance.now(); if (!force && state.generationMode === 'preview' && now < state.nextDataPanelAt) { return; } if (state.generationMode === 'preview') { state.nextDataPanelAt = now + (state.editSpeedMode === 'turbo' ? 360 : 220); } else { state.nextDataPanelAt = 0; } const analysis = state.bundle.analysis; const masks = analysis?.maskCoverage ?? {}; const areaFeatures = state.recipe.areaFeatures ?? []; const splineFeatures = state.recipe.splineFeatures ?? []; const forestCount = areaFeatures.filter((feature) => feature.type === 'forest').length; const mountainCount = areaFeatures.filter((feature) => feature.type === 'mountain').length; const riverCount = splineFeatures.filter((feature) => feature.type === 'river').length; const roadCount = splineFeatures.filter((feature) => feature.type === 'road').length; const laneStats = state.layerStore.computeStats('intentMainLane'); const safeStats = state.layerStore.computeStats('intentSafeExpansion'); const noBuildStats = state.layerStore.computeStats('intentNoBuild'); const lockCount = state.constraintLocks ? rebuildConstraintLockIndices(state.constraintLocks).length : 0; const lockCoverage = state.constraintLocks ? (lockCount / Math.max(1, state.constraintLocks.mask.length)) * 100 : 0; const modeLabel = state.generationMode === 'preview' ? 'preview' : state.generationMode === 'imported' ? 'imported' : 'full'; dataEl.textContent = [ `Hash: ${state.bundle.hash}`, `Mode: ${modeLabel}`, `Map: ${state.bundle.recipe.mapSizePreset} (${state.bundle.recipe.mapSizeMeters}m)`, `Resolution: ${state.bundle.width}x${state.bundle.height} (target ${state.recipe.resolution})`, `Height range: ${state.stats.min.toFixed(2)} .. ${state.stats.max.toFixed(2)}`, `Slope warnings: ${analysis?.slopeWarnings ?? 0}`, `Buildable coverage: ${((masks.buildable ?? 0) * 100).toFixed(1)}%`, `Ramp coverage: ${((masks.ramps ?? 0) * 100).toFixed(1)}%`, `Choke coverage: ${((masks.choke ?? 0) * 100).toFixed(1)}%`, `Stamps: ${state.recipe.manualStamps.length}`, `Mountains: ${mountainCount} | Forests: ${forestCount}`, `Rivers: ${riverCount} | Roads: ${roadCount}`, `Intent mean: lane ${(laneStats?.mean ?? 0).toFixed(3)} | safe ${(safeStats?.mean ?? 0).toFixed(3)} | nobuild ${(noBuildStats?.mean ?? 0).toFixed(3)}`, `Locks: ${lockCount} cells (${lockCoverage.toFixed(2)}%)`, `Players: ${state.recipe.players} | Symmetry: ${state.recipe.symmetry}`, `Pipeline stages: ${state.bundle.pipelineStats?.stageCount ?? 0}` ].join('\n'); } function setStatus(message: string): void { statusEl.textContent = message; } function setStatusThrottled(state: AppState, message: string, force = false): void { const now = performance.now(); if (!force && now - state.lastPatchStatusAt < STATUS_THROTTLE_MS) { return; } if (!force && message === state.lastStatusMessage) { return; } state.lastPatchStatusAt = now; state.lastStatusMessage = message; setStatus(message); } function formatMs(value: number): string { if (!Number.isFinite(value) || value <= 0) return '-'; return `${value.toFixed(2)}ms`; } function formatBytes(value: number): string { if (!Number.isFinite(value) || value <= 0) return '-'; if (value < 1024) return `${value}B`; const kb = value / 1024; if (kb < 1024) return `${kb.toFixed(1)}KB`; return `${(kb / 1024).toFixed(2)}MB`; } function refreshBroadcastRate(state: AppState): void { const now = performance.now(); const windowMs = 5000; state.perf.broadcastTimes = state.perf.broadcastTimes.filter((time) => now - time <= windowMs); state.perf.broadcastRate = state.perf.broadcastTimes.length / (windowMs / 1000); } function updatePerfPanel(state: AppState, force = false): void { if (!perfEl) return; const now = performance.now(); if (!force && now - state.lastPerfPanelUpdateAt < PERF_PANEL_MIN_UPDATE_MS) { return; } state.lastPerfPanelUpdateAt = now; refreshBroadcastRate(state); const activePreviewResolution = resolvePreviewResolutionAdaptive(state.recipe.resolution, state.previewAdaptiveLevel); perfEl.textContent = [ `worker: ${formatMs(state.perf.lastWorkerMs)} | blit: ${formatMs(state.perf.lastBlitMs)} | render: ${formatMs(state.perf.lastRenderMs)}`, `transfer: ${formatBytes(state.perf.lastTransferBytes)} | rough tx: ${state.perf.broadcastRate.toFixed(2)}/s`, `height: ${state.perf.lastHeightPatchDims} (${formatBytes(state.perf.lastHeightPatchBytes)})`, `heat: ${state.perf.lastHeatPatchDims} (${formatBytes(state.perf.lastHeatPatchBytes)})`, `contour: ${state.perf.lastContourPatchDims} (${formatBytes(state.perf.lastContourPatchBytes)})`, `overlay patches: ${state.perf.lastOverlayPatchCount} (${formatBytes(state.perf.lastOverlayPatchBytes)})`, `preview: ${activePreviewResolution}px | adaptive L${state.previewAdaptiveLevel} | queue ${state.perf.previewQueueDepth}`, `coalesced: ${state.perf.coalescedPreviewCount} | load x${state.interactionLoadScale.toFixed(2)}`, `listener: ${hasActivePreviewListener(state) ? 'active' : 'idle'}` ].join('\n'); } function maybeAdjustPreviewAdaptiveLevel(state: AppState): void { const workerMs = state.perf.lastWorkerMs; const renderMs = state.perf.lastRenderMs; const slow = workerMs >= PREVIEW_ADAPTIVE_SLOW_WORKER_MS || renderMs >= PREVIEW_ADAPTIVE_SLOW_RENDER_MS; const fast = workerMs > 0 && renderMs > 0 && workerMs <= PREVIEW_ADAPTIVE_FAST_WORKER_MS && renderMs <= PREVIEW_ADAPTIVE_FAST_RENDER_MS; if (slow) { state.previewAdaptiveSlowStreak += 1; state.previewAdaptiveFastStreak = 0; } else if (fast) { state.previewAdaptiveFastStreak += 1; state.previewAdaptiveSlowStreak = 0; } else { state.previewAdaptiveSlowStreak = Math.max(0, state.previewAdaptiveSlowStreak - 1); state.previewAdaptiveFastStreak = Math.max(0, state.previewAdaptiveFastStreak - 1); } if (state.previewAdaptiveSlowStreak >= 3 && state.previewAdaptiveLevel < PREVIEW_ADAPTIVE_MAX_LEVEL) { state.previewAdaptiveLevel += 1; state.previewAdaptiveSlowStreak = 0; state.previewAdaptiveFastStreak = 0; } else if (state.previewAdaptiveFastStreak >= 10 && state.previewAdaptiveLevel > 0) { state.previewAdaptiveLevel -= 1; state.previewAdaptiveSlowStreak = 0; state.previewAdaptiveFastStreak = 0; } } function updateInteractionLoadScale(state: AppState): void { const now = performance.now(); const prevAt = state.lastInteractionLoadUpdateAt; state.lastInteractionLoadUpdateAt = now; const dt = prevAt > 0 ? Math.max(1, Math.min(200, now - prevAt)) : 16; const renderMs = state.perf.lastRenderMs; const workerMs = state.perf.lastWorkerMs; let target = INTERACTION_LOAD_SCALE_MIN; if (renderMs > 16.7) { target += Math.min(0.55, (renderMs - 16.7) * 0.028); } if (workerMs > 20) { target += Math.min(0.35, (workerMs - 20) * 0.0115); } if (state.previewInFlight) { target += 0.08; } if (state.perf.previewQueueDepth > 0) { target += Math.min(0.24, state.perf.previewQueueDepth * 0.03); } target = Math.max(INTERACTION_LOAD_SCALE_MIN, Math.min(INTERACTION_LOAD_SCALE_MAX, target)); const rise = 0.0048 * dt; const fall = 0.0022 * dt; if (state.interactionLoadScale < target) { state.interactionLoadScale = Math.min(target, state.interactionLoadScale + rise); } else if (state.interactionLoadScale > target) { state.interactionLoadScale = Math.max(target, state.interactionLoadScale - fall); } } function recordWorkerMessagePerf(state: AppState, msg: WorkerSuccessMessage): void { const workerMs = msg.bundle.pipelineStats?.totalDuration; if (typeof workerMs === 'number' && Number.isFinite(workerMs) && workerMs >= 0) { state.perf.lastWorkerMs = workerMs; } const fullHeightBytes = msg.bundle.heightDataBuffer?.byteLength ?? 0; const heightPatches = msg.heightPatches && msg.heightPatches.length > 0 ? msg.heightPatches : (msg.heightPatch ? [msg.heightPatch] : []); const heatmapPatches = msg.heatmapPatches && msg.heatmapPatches.length > 0 ? msg.heatmapPatches : (msg.heatmapPatch ? [msg.heatmapPatch] : []); const contourPatches = msg.contourPatches && msg.contourPatches.length > 0 ? msg.contourPatches : (msg.contourPatch ? [msg.contourPatch] : []); const overlayLayerPatches = msg.overlayLayerPatches ?? []; const heightBytes = heightPatches.reduce((sum, patch) => sum + patch.dataBuffer.byteLength, 0); const heatBytes = heatmapPatches.reduce((sum, patch) => sum + patch.rgbaBuffer.byteLength, 0); const contourBytes = contourPatches.reduce((sum, patch) => sum + patch.rgbaBuffer.byteLength, 0); const overlayBytes = overlayLayerPatches.reduce((sum, patch) => sum + patch.dataBuffer.byteLength, 0); state.perf.lastTransferBytes = fullHeightBytes + heightBytes + heatBytes + contourBytes + overlayBytes; state.perf.lastHeightPatchBytes = heightBytes || fullHeightBytes; state.perf.lastHeatPatchBytes = heatBytes; state.perf.lastContourPatchBytes = contourBytes; state.perf.lastOverlayPatchBytes = overlayBytes; state.perf.lastOverlayPatchCount = overlayLayerPatches.length; if (heightPatches.length > 0) { state.perf.lastHeightPatchDims = heightPatches.length === 1 ? `${heightPatches[0].width}x${heightPatches[0].height}` : `${heightPatches.length} patches`; } else if (fullHeightBytes > 0) { state.perf.lastHeightPatchDims = `${msg.bundle.width}x${msg.bundle.height} full`; } else { state.perf.lastHeightPatchDims = '-'; } state.perf.lastHeatPatchDims = heatmapPatches.length > 0 ? (heatmapPatches.length === 1 ? `${heatmapPatches[0].width}x${heatmapPatches[0].height}` : `${heatmapPatches.length} patches`) : '-'; state.perf.lastContourPatchDims = contourPatches.length > 0 ? (contourPatches.length === 1 ? `${contourPatches[0].width}x${contourPatches[0].height}` : `${contourPatches.length} patches`) : '-'; if (msg.type === 'preview') { maybeAdjustPreviewAdaptiveLevel(state); } } function hasActivePreviewListener(state: AppState): boolean { if (state.lastListenerAckAt <= 0) return false; return performance.now() - state.lastListenerAckAt <= PREVIEW_LISTENER_ACTIVE_WINDOW_MS; } function probePreviewListener(state: AppState, force = false): void { if (!state.broadcast) return; const now = performance.now(); if (!force && now - state.lastListenerProbeAt < PREVIEW_LISTENER_PROBE_INTERVAL_MS) { return; } state.lastListenerProbeAt = now; const probe: MapLabPresenceProbeMessage = { type: 'maplab-listener-presence-request', sourceId: state.broadcastClientId, sentAt: Date.now() }; try { state.broadcast.postMessage(probe); } catch (error) { console.warn('[MapLab] Failed to probe realtime listener presence:', error); } } function clearContourRestoreTimer(state: AppState): void { if (state.contourRestoreTimer === null) return; window.clearTimeout(state.contourRestoreTimer); state.contourRestoreTimer = null; } function clearRecipePersistTimer(state: AppState): void { if (state.recipePersistTimer === null) return; window.clearTimeout(state.recipePersistTimer); state.recipePersistTimer = null; } function flushRecipePersist(state: AppState): void { clearRecipePersistTimer(state); try { const snapshot = buildPersistableRecipeSnapshot(state); state.recipe = snapshot; window.localStorage.setItem(STORAGE_KEYS.latestRecipe, JSON.stringify(snapshot)); } catch (error) { console.warn('[MapLab] Failed to persist recipe state:', error); } } function scheduleRecipePersist(state: AppState, delay = RECIPE_PERSIST_DEBOUNCE_MS): void { clearRecipePersistTimer(state); state.recipePersistTimer = window.setTimeout(() => { state.recipePersistTimer = null; flushRecipePersist(state); }, delay); } function restoreContoursAfterIdle(state: AppState): void { clearContourRestoreTimer(state); if (!state.turboContoursSuppressed) return; state.turboContoursSuppressed = false; if (state.contourRestoreEnabled) { OVERLAYS.contours.checked = true; syncToolbarStates(); state.workerContour = null; schedulePreviewRegenerate(state, 0, { interactive: false }); return; } syncToolbarStates(); requestRender(state); } function touchTurboContours(state: AppState): void { clearContourRestoreTimer(state); if (!state.turboContoursSuppressed) { state.contourRestoreEnabled = OVERLAYS.contours.checked; if (state.contourRestoreEnabled) { state.turboContoursSuppressed = true; OVERLAYS.contours.checked = false; syncToolbarStates(); requestRender(state); } } state.contourRestoreTimer = window.setTimeout( () => restoreContoursAfterIdle(state), TURBO_CONTOUR_IDLE_RESTORE_MS ); } function publishRealtimeRough( state: AppState, bundle: StrategicMapBundle, mode: RegenerationMode, force = false ): void { const now = performance.now(); if (mode === 'preview') { if (!state.broadcast) { updatePerfPanel(state); return; } probePreviewListener(state); if (!hasActivePreviewListener(state)) { updatePerfPanel(state); return; } if (!force && now - state.lastRoughPublishAt < PREVIEW_ROUGH_BROADCAST_INTERVAL_MS) { updatePerfPanel(state); return; } } else if (!force && now - state.lastRoughPublishAt < 500) { updatePerfPanel(state); return; } state.lastRoughPublishAt = now; const rough = buildStrategicRoughHeightConditions(bundle, LIVE_ROUGH_RESOLUTION, mode); const message: MapLabRealtimeMessage = { type: 'maplab-rough-height', payload: rough }; try { state.broadcast?.postMessage(message); state.perf.broadcastTimes.push(now); } catch (error) { console.warn('[MapLab] Failed to broadcast rough update:', error); } if (mode !== 'preview') { try { window.localStorage.setItem( STORAGE_KEYS.latestRough, serializeStrategicRoughHeightConditions(rough) ); } catch (error) { console.warn('[MapLab] Failed to persist rough conditions:', error); } } updatePerfPanel(state); } function saveLatest(state: AppState, options?: { persistBundle?: boolean }): void { const persistBundle = options?.persistBundle === true; try { if (persistBundle && state.fullBundle) { const bundle = state.fullBundle; if (state.latestBundlePersistHash === bundle.hash || state.persistingBundleHash === bundle.hash) { flushRecipePersist(state); return; } state.persistingBundleHash = bundle.hash; void (async () => { try { const serialized = serializeStrategicBundle(bundle); window.localStorage.setItem(STORAGE_KEYS.latestBundle, serialized); clearStoredLatestBundleRef(); state.latestBundlePersistHash = bundle.hash; state.latestBundlePersistTransport = 'localStorage'; } catch (error) { if (!isQuotaExceededError(error)) { throw error; } // Large heightmaps exceed localStorage limits; keep a small pointer there. try { window.localStorage.removeItem(STORAGE_KEYS.latestBundle); } catch { // Ignore removal failures. } const key = await writeMapLabBundleToIndexedDb(MAPLAB_LATEST_BUNDLE_DB_KEY, bundle); try { writeStoredLatestBundleRef({ version: 1, transport: 'indexeddb', key, createdAt: Date.now(), hash: bundle.hash }); } catch { // If this fails, launch flow can still persist with its own fallback. } state.latestBundlePersistHash = bundle.hash; state.latestBundlePersistTransport = 'indexeddb'; } finally { if (state.persistingBundleHash === bundle.hash) { state.persistingBundleHash = null; } } })().catch((error) => { if (state.persistingBundleHash === bundle.hash) { state.persistingBundleHash = null; } console.warn('[MapLab] Failed to persist local state:', error); }); flushRecipePersist(state); return; } scheduleRecipePersist(state); } catch (error) { console.warn('[MapLab] Failed to persist local state:', error); } } function nextRequestId(state: AppState): number { state.requestSeq += 1; return state.requestSeq; } function nextImportRequestId(state: AppState): number { state.importRequestSeq += 1; return state.importRequestSeq; } function cloneImportRecipeEntry(recipe: ImportRecipe): ImportRecipe { if (recipe.type === 'upload-heightmap') { return { ...recipe }; } if (recipe.type === 'terrarium-tiles') { return { ...recipe, bbox: { ...recipe.bbox }, heightTransform: recipe.heightTransform ? { ...recipe.heightTransform } : undefined }; } if (recipe.type === 'copernicus-dem') { return { ...recipe, bbox: { ...recipe.bbox }, outputResolution: { ...recipe.outputResolution } }; } return { ...recipe, bbox: { ...recipe.bbox } }; } function cloneAttributionEntry(entry: TerrainImportAttribution): TerrainImportAttribution { return { ...entry }; } function syncTerrainImportMetadataOnRecipe(state: AppState): void { const baseMetadata = state.recipe.metadata ?? {}; if (state.importRecipes.length <= 0) { const { terrainImport: _unused, ...rest } = baseMetadata as typeof baseMetadata & { terrainImport?: unknown }; state.recipe.metadata = rest; return; } state.recipe.metadata = { ...baseMetadata, terrainImport: { gridSpec: createMapGridSpecFromRecipe(state.recipe), activeBaseLayerId: MAPLAB_BASE_LAYER_ID, recipes: state.importRecipes.map(cloneImportRecipeEntry), attribution: state.importAttribution.map(cloneAttributionEntry), importedAt: new Date().toISOString() } }; } function syncTerrainImportStateFromRecipe(state: AppState, recipe: StrategicMapRecipe): void { const terrainImport = recipe.metadata?.terrainImport; if (!terrainImport) { state.importRecipes = []; state.importAttribution = []; return; } const recipes = Array.isArray(terrainImport.recipes) ? terrainImport.recipes : []; const validRecipes: ImportRecipe[] = []; for (const recipeEntry of recipes) { const validated = validateImportRecipe(recipeEntry as ImportRecipe); if (validated.ok) { validRecipes.push(cloneImportRecipeEntry(recipeEntry as ImportRecipe)); } } state.importRecipes = validRecipes; const attribution = Array.isArray(terrainImport.attribution) ? terrainImport.attribution : []; state.importAttribution = attribution .filter((entry): entry is TerrainImportAttribution => { if (!entry || typeof entry !== 'object') return false; return typeof entry.provider === 'string' && typeof entry.label === 'string' && typeof entry.url === 'string'; }) .map(cloneAttributionEntry); } function bilinearSample( data: Float32Array, width: number, height: number, fx: number, fy: number ): number { const x = Math.max(0, Math.min(1, fx)) * (width - 1); const y = Math.max(0, Math.min(1, fy)) * (height - 1); const x0 = Math.floor(x); const y0 = Math.floor(y); const x1 = Math.min(width - 1, x0 + 1); const y1 = Math.min(height - 1, y0 + 1); const tx = x - x0; const ty = y - y0; const a = data[y0 * width + x0]; const b = data[y0 * width + x1]; const c = data[y1 * width + x0]; const d = data[y1 * width + x1]; const ab = a + (b - a) * tx; const cd = c + (d - c) * tx; return ab + (cd - ab) * ty; } function resampleImportedBaseLayerToSize( source: Float32Array, sourceWidth: number, sourceHeight: number, targetWidth: number, targetHeight: number ): Float32Array { if (sourceWidth === targetWidth && sourceHeight === targetHeight) { return source; } const out = new Float32Array(targetWidth * targetHeight); const denomX = Math.max(1, targetWidth - 1); const denomY = Math.max(1, targetHeight - 1); for (let y = 0; y < targetHeight; y += 1) { const fy = y / denomY; for (let x = 0; x < targetWidth; x += 1) { const fx = x / denomX; out[y * targetWidth + x] = bilinearSample(source, sourceWidth, sourceHeight, fx, fy); } } return out; } function clearFullWatchdog(state: AppState): void { if (state.fullWatchdogTimer !== null) { window.clearTimeout(state.fullWatchdogTimer); state.fullWatchdogTimer = null; } } function computeSlopeBytesForBundle(bundle: StrategicMapBundle): Uint8Array { const width = bundle.width; const height = bundle.height; const out = new Uint8Array(width * height); const cellSize = bundle.worldMetrics.mapSizeMeters / Math.max(1, width - 1); const invDx = 1 / Math.max(1e-6, cellSize * 2); for (let y = 0; y < height; y += 1) { const ym = y > 0 ? y - 1 : y; const yp = y < height - 1 ? y + 1 : y; for (let x = 0; x < width; x += 1) { const xm = x > 0 ? x - 1 : x; const xp = x < width - 1 ? x + 1 : x; const hL = bundle.heightData[y * width + xm]; const hR = bundle.heightData[y * width + xp]; const hD = bundle.heightData[ym * width + x]; const hU = bundle.heightData[yp * width + x]; const dx = (hR - hL) * invDx; const dz = (hU - hD) * invDx; const slope = Math.hypot(dx, dz); out[y * width + x] = Math.min(255, Math.max(0, Math.round(slope * 255))); } } return out; } function recomputeBundleAnalysisForBaseImport(bundle: StrategicMapBundle): void { const slope = computeSlopeBytesForBundle(bundle); const buildable = new Uint8Array(slope.length); const ramps = new Uint8Array(slope.length); const choke = new Uint8Array(slope.length); let slopeWarnings = 0; let buildableCount = 0; let rampCount = 0; let chokeCount = 0; for (let i = 0; i < slope.length; i += 1) { const s = slope[i] / 255; if (s <= 0.16) { buildable[i] = 1; buildableCount += 1; } if (s >= 0.08 && s <= 0.42) { ramps[i] = 1; rampCount += 1; } if (s >= 0.58) { choke[i] = 1; chokeCount += 1; } if (slope[i] >= 220) slopeWarnings += 1; } bundle.masks = { ...(bundle.masks ?? {}), buildable, ramps, choke }; const total = Math.max(1, slope.length); bundle.analysis = { ...(bundle.analysis ?? { slopeWarnings: 0, maskCoverage: {}, featureCounts: {} }), slopeWarnings, maskCoverage: { ...(bundle.analysis?.maskCoverage ?? {}), buildable: buildableCount / total, ramps: rampCount / total, choke: chokeCount / total } }; } function applyImportedBaseLayerToBundle(state: AppState, bundle: StrategicMapBundle): void { const base = state.importedBaseLayer; if (!base) return; const sourceWidth = Math.max(1, state.importedBaseLayerWidth); const sourceHeight = Math.max(1, state.importedBaseLayerHeight); const sampled = resampleImportedBaseLayerToSize(base, sourceWidth, sourceHeight, bundle.width, bundle.height); if (sampled.length !== bundle.heightData.length) return; for (let i = 0; i < bundle.heightData.length; i += 1) { bundle.heightData[i] += sampled[i]; } recomputeBundleAnalysisForBaseImport(bundle); } function fallbackGenerateFullFromRecipe( state: AppState, recipe: StrategicMapRecipe, reason: string ): void { clearFullWatchdog(state); state.fullInFlight = false; state.latestFullWorkerRecipe = null; state.queuedFullWorkerRecipe = null; try { const bundle = generateStrategicMapBundle(recipe); applyImportedBaseLayerToBundle(state, bundle); applyGeneratedBundle(state, bundle, 'full'); setStatus(`MapLab fallback full generation complete (${reason}).`); } catch (error) { setStatus(`Generation failed (${reason}).`); console.error('[MapLab] Fallback full generation failed:', error); } } function armFullWatchdog(state: AppState, recipe: StrategicMapRecipe): void { clearFullWatchdog(state); state.fullWatchdogTimer = window.setTimeout(() => { state.fullWatchdogTimer = null; if (!state.fullInFlight) return; console.warn('[MapLab] Full worker request timed out, recovering worker and retrying.'); const retryRecipe = state.queuedFullWorkerRecipe ?? state.latestFullWorkerRecipe ?? recipe; recoverMapLabWorkerAndRetryFull(state, retryRecipe, 'timeout'); }, FULL_WORKER_TIMEOUT_MS); } function installBroadcastPresenceListener(state: AppState): void { if (!state.broadcast) return; state.broadcast.addEventListener('message', (event) => { const payload = event.data; if (!payload || typeof payload !== 'object') return; if ((payload as { type?: string }).type !== 'maplab-listener-presence-response') return; const sourceId = (payload as { sourceId?: unknown }).sourceId; if (typeof sourceId !== 'string' || sourceId !== state.broadcastClientId) return; state.lastListenerAckAt = performance.now(); updatePerfPanel(state); }); } function applyGeneratedBundle(state: AppState, generated: StrategicMapBundle, mode: RegenerationMode): void { state.perf.lastBlitMs = 0; state.bundle = generated; state.generationMode = mode; syncInfluenceLayersToSize(state, generated.width, generated.height); applyInfluenceConstraintsToBundle(state, generated, mode); upsertLayerFromBundleHeight(state, generated, { deriveSecondary: mode !== 'preview' }); if (mode !== 'preview') { refreshLayerSelector(state); if (state.overlayAutoRange || !Number.isFinite(state.overlayRangeMin) || !Number.isFinite(state.overlayRangeMax)) { syncLayerRangeFromActive(state); } } updateProbeReadout(state); if (mode !== 'preview') { state.workerHeatmap = null; state.workerContour = null; } if (mode === 'full' || mode === 'imported') { state.fullBundle = generated; saveLatest(state, { persistBundle: true }); } else { saveLatest(state, { persistBundle: false }); } publishRealtimeRough(state, generated, mode, mode !== 'preview'); recomputeStats(state); updateDataPanel(state); updateObjectiveReadout(state); syncSidebarPanelsVisibility(state); requestRender(state); if (mode === 'preview') { setStatusThrottled(state, `MapLab realtime SDF pass (${generated.width}x${generated.height}) ready.`); } else { setStatusThrottled(state, `MapLab SDF bundle generated: ${generated.hash}`, true); } } function applyPreviewPatchFromWorker(state: AppState, msg: WorkerSuccessMessage): void { const blitStart = performance.now(); applyOverlayLayerPatchesFromWorker(state, msg); applyOverlayPrimitivesPatchFromWorker(state, msg); const bundleMeta = msg.bundle; const hasHeightPatch = !!msg.heightPatch || !!(msg.heightPatches && msg.heightPatches.length > 0); if (!hasHeightPatch || !state.bundle) { if (bundleMeta.heightDataBuffer) { applyWorkerHeatmapPatches(state, msg); applyWorkerContourPatches(state, msg); const rebuilt = transferToBundle(bundleMeta); applyGeneratedBundle(state, rebuilt, 'preview'); } else if (state.bundle && state.bundle.width === bundleMeta.width && state.bundle.height === bundleMeta.height) { applyWorkerHeatmapPatches(state, msg); applyWorkerContourPatches(state, msg); if (typeof msg.minHeight === 'number' && typeof msg.maxHeight === 'number') { state.stats = { min: msg.minHeight, max: msg.maxHeight }; } state.generationMode = 'preview'; saveLatest(state, { persistBundle: false }); updateDataPanel(state); updateObjectiveReadout(state); state.perf.lastBlitMs = performance.now() - blitStart; requestRender(state); setStatusThrottled(state, 'MapLab realtime visual patch applied.'); } else { applyWorkerHeatmapPatches(state, msg); applyWorkerContourPatches(state, msg); if (typeof msg.minHeight === 'number' && typeof msg.maxHeight === 'number') { state.stats = { min: msg.minHeight, max: msg.maxHeight }; } state.generationMode = 'preview'; updateDataPanel(state); updateObjectiveReadout(state); state.perf.lastBlitMs = performance.now() - blitStart; requestRender(state); setStatusThrottled(state, 'MapLab realtime visual patch applied.'); } return; } if (state.bundle.width !== bundleMeta.width || state.bundle.height !== bundleMeta.height) { applyWorkerHeatmapPatches(state, msg); applyWorkerContourPatches(state, msg); if (typeof msg.minHeight === 'number' && typeof msg.maxHeight === 'number') { state.stats = { min: msg.minHeight, max: msg.maxHeight }; } state.generationMode = 'preview'; updateDataPanel(state); updateObjectiveReadout(state); state.perf.lastBlitMs = performance.now() - blitStart; requestRender(state); setStatusThrottled(state, 'MapLab realtime visual patch applied.'); return; } applyHeightPatchesToBundle(state.bundle, msg); applyInfluenceConstraintsToBundle(state, state.bundle, 'preview'); applyWorkerHeatmapPatches(state, msg); applyWorkerContourPatches(state, msg); if (bundleMeta.recipe) { state.bundle.recipe = bundleMeta.recipe; } state.bundle.worldMetrics = bundleMeta.worldMetrics; state.bundle.graphSummary = bundleMeta.graphSummary; state.bundle.pipelineStats = bundleMeta.pipelineStats; state.bundle.hash = bundleMeta.hash; state.bundle.generatedAt = bundleMeta.generatedAt; state.generationMode = 'preview'; if (typeof msg.minHeight === 'number' && typeof msg.maxHeight === 'number') { state.stats = { min: msg.minHeight, max: msg.maxHeight }; } else { recomputeStats(state); } saveLatest(state, { persistBundle: false }); publishRealtimeRough(state, state.bundle, 'preview'); updateDataPanel(state); updateObjectiveReadout(state); state.perf.lastBlitMs = performance.now() - blitStart; requestRender(state); const patchCount = msg.heightPatches && msg.heightPatches.length > 0 ? msg.heightPatches.length : 1; if (patchCount > 1) { setStatusThrottled(state, `MapLab realtime SDF patches applied (${patchCount}).`); } else if (msg.heightPatch) { setStatusThrottled(state, `MapLab realtime SDF patch (${msg.heightPatch.width}x${msg.heightPatch.height}) applied.`); } else { setStatusThrottled(state, 'MapLab realtime SDF patch applied.'); } } function dispatchQueuedPreviewIfReady(state: AppState): void { if (!state.worker) return; if (state.previewInFlight) return; if (state.queuedStampDeltas.length > 0) { const deltas = state.queuedStampDeltas.splice(0, state.queuedStampDeltas.length); const recipe = state.queuedPreviewRecipe ?? buildPreviewRecipeForState(state, state.recipe); state.queuedPreviewRecipe = null; state.perf.previewQueueDepth = 0; const requestId = nextRequestId(state); state.latestPreviewRequestId = requestId; state.previewInFlight = true; const includeRecipe = !state.previewWorkerPrimed; state.worker.postMessage({ type: 'stamp-delta-batch', requestId, ...(includeRecipe ? { recipe } : {}), deltas, skipHeightPatch: shouldSkipHeightPatchForRealtime(state), includeHeatmap: OVERLAYS.heat.checked, includeContours: OVERLAYS.contours.checked && !state.turboContoursSuppressed }); updatePerfPanel(state); return; } if (!state.queuedPreviewRecipe) return; const recipe = state.queuedPreviewRecipe; state.queuedPreviewRecipe = null; state.perf.previewQueueDepth = 0; const requestId = nextRequestId(state); state.latestPreviewRequestId = requestId; state.previewInFlight = true; state.worker.postMessage({ type: 'rebuild', requestId, recipe, includeHeatmap: OVERLAYS.heat.checked, includeContours: OVERLAYS.contours.checked && !state.turboContoursSuppressed }); updatePerfPanel(state); } function shouldSkipHeightPatchForRealtime(state: AppState): boolean { if (state.editSpeedMode !== 'turbo') return false; if (state.brushStroke) return true; if (state.geometryEditDrag) return true; if (state.propertyHandleDrag) return true; if (state.splineGizmoDrag) return true; return false; } function postWorkerRebuild(state: AppState, recipe: StrategicMapRecipe): void { postWorkerRebuildWithOptions(state, recipe); } function postWorkerRebuildWithOptions( state: AppState, recipe: StrategicMapRecipe, options?: { includeHeatmap?: boolean; includeContours?: boolean } ): void { if (!state.worker) return; if (state.previewInFlight) { state.queuedPreviewRecipe = recipe; state.queuedStampDeltas = []; state.perf.previewQueueDepth = 1; state.perf.coalescedPreviewCount += 1; updatePerfPanel(state); return; } state.queuedStampDeltas = []; state.queuedPreviewRecipe = null; const requestId = nextRequestId(state); state.latestPreviewRequestId = requestId; state.previewInFlight = true; state.perf.previewQueueDepth = 0; state.worker.postMessage({ type: 'rebuild', requestId, recipe, includeHeatmap: options?.includeHeatmap ?? OVERLAYS.heat.checked, includeContours: options?.includeContours ?? (OVERLAYS.contours.checked && !state.turboContoursSuppressed) }); updatePerfPanel(state); } function startInitialGeneration(state: AppState): void { state.recipe = readRecipeFromControls(state); if (state.bundle) { // Cached boot hydration already gave us a visible preview; defer heavy work. setStatus('MapLab ready (cached preview). Refreshing in background...'); scheduleFullRegenerate(state, 1200); return; } const previewRecipe = buildPreviewRecipeForState(state, state.recipe); if (state.worker) { state.workerContour = null; setStatus(`MapLab startup preview (${previewRecipe.resolution})...`); postWorkerRebuildWithOptions(state, previewRecipe, { includeHeatmap: OVERLAYS.heat.checked, includeContours: false }); scheduleFullRegenerate(state, 900); return; } regenerate(state, 'preview'); scheduleFullRegenerate(state, 900); } function cloneRecipeForWorker(recipe: StrategicMapRecipe): StrategicMapRecipe { return { ...recipe, manualStamps: recipe.manualStamps.map((stamp) => ({ ...stamp })), areaFeatures: (recipe.areaFeatures ?? []).map((feature) => ({ ...feature, points: feature.points.map((point) => ({ ...point })) })), splineFeatures: (recipe.splineFeatures ?? []).map((feature) => ({ ...feature, points: feature.points.map((point) => ({ ...point })) })), overlays: cloneOverlayBundleForEdit(recipe.overlays), ca: cloneCellularAutomataConfigForEdit(recipe.ca), markerGuidance: cloneMarkerGuidanceConfigForEdit(recipe.markerGuidance) }; } function dispatchWorkerFull(state: AppState, recipeSnapshot: StrategicMapRecipe): void { if (!state.worker) return; const requestId = nextRequestId(state); state.latestFullRequestId = requestId; state.latestFullWorkerRecipe = recipeSnapshot; state.fullInFlight = true; state.worker.postMessage({ type: 'full', requestId, recipe: recipeSnapshot }); armFullWatchdog(state, recipeSnapshot); } function dispatchQueuedFullIfReady(state: AppState): void { if (!state.worker) return; if (state.fullInFlight) return; if (!state.queuedFullWorkerRecipe) return; const queuedRecipe = state.queuedFullWorkerRecipe; state.queuedFullWorkerRecipe = null; dispatchWorkerFull(state, queuedRecipe); } function postWorkerFull(state: AppState, recipe: StrategicMapRecipe): void { if (!state.worker) return; const recipeSnapshot = cloneRecipeForWorker(recipe); if (state.fullInFlight) { state.queuedFullWorkerRecipe = recipeSnapshot; return; } dispatchWorkerFull(state, recipeSnapshot); } function postWorkerStampDeltaBatch( state: AppState, recipe: StrategicMapRecipe, deltas: Array<{ stamp: StrategicMapStamp; delta: 'add' | 'remove' }> ): void { if (!state.worker) return; if (deltas.length <= 0) return; if (state.previewInFlight) { state.queuedPreviewRecipe = recipe; const queuedBefore = state.queuedStampDeltas.length; for (let i = 0; i < deltas.length; i += 1) { const item = deltas[i]; state.queuedStampDeltas.push({ stamp: { ...item.stamp }, delta: item.delta }); } if (state.queuedStampDeltas.length > MAX_QUEUED_STAMP_DELTAS) { // Avoid preview/recipe divergence: if queue saturates, discard deltas and force a rebuild. state.queuedStampDeltas = []; state.perf.previewQueueDepth = 1; state.perf.coalescedPreviewCount += Math.max(1, queuedBefore + deltas.length - MAX_QUEUED_STAMP_DELTAS); setStatusThrottled(state, 'MapLab realtime queue saturated; rebuilding preview...'); updatePerfPanel(state); return; } state.perf.previewQueueDepth = state.queuedStampDeltas.length; if (deltas.length > 1) { state.perf.coalescedPreviewCount += deltas.length - 1; } updatePerfPanel(state); return; } state.queuedPreviewRecipe = null; state.queuedStampDeltas = []; const requestId = nextRequestId(state); state.latestPreviewRequestId = requestId; state.previewInFlight = true; state.perf.previewQueueDepth = 0; const includeRecipe = !state.previewWorkerPrimed; if (deltas.length === 1) { const item = deltas[0]; state.worker.postMessage({ type: 'stamp-delta', requestId, ...(includeRecipe ? { recipe } : {}), stamp: item.stamp, delta: item.delta, skipHeightPatch: shouldSkipHeightPatchForRealtime(state), includeHeatmap: OVERLAYS.heat.checked, includeContours: OVERLAYS.contours.checked && !state.turboContoursSuppressed }); } else { state.worker.postMessage({ type: 'stamp-delta-batch', requestId, ...(includeRecipe ? { recipe } : {}), deltas, skipHeightPatch: shouldSkipHeightPatchForRealtime(state), includeHeatmap: OVERLAYS.heat.checked, includeContours: OVERLAYS.contours.checked && !state.turboContoursSuppressed }); } updatePerfPanel(state); } function buildScratchStampDeltaBatch( state: AppState, stamps: StrategicMapStamp[], delta: 'add' | 'remove' ): Array<{ stamp: StrategicMapStamp; delta: 'add' | 'remove' }> { const scratch = state.stampDeltaScratch; const count = stamps.length; for (let i = 0; i < count; i += 1) { const existing = scratch[i]; if (existing) { existing.stamp = stamps[i]; existing.delta = delta; } else { scratch[i] = { stamp: stamps[i], delta }; } } scratch.length = count; return scratch; } function postWorkerSetBaseLayer( state: AppState, heightData: Float32Array, width: number, height: number ): void { if (!state.worker) return; state.previewWorkerPrimed = false; const payloadData = new Float32Array(heightData); const req: WorkerSetBaseLayerRequest = { type: 'set-base-layer', requestId: nextRequestId(state), width, height, heightDataBuffer: payloadData.buffer as ArrayBuffer }; state.worker.postMessage(req, [req.heightDataBuffer]); } function postWorkerClearBaseLayer(state: AppState): void { if (!state.worker) return; state.previewWorkerPrimed = false; const req: WorkerClearBaseLayerRequest = { type: 'clear-base-layer', requestId: nextRequestId(state) }; state.worker.postMessage(req); } function downloadBlob(filename: string, blob: Blob): void { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function downloadJson(filename: string, payload: unknown): void { const text = JSON.stringify(payload, null, 2); const blob = new Blob([text], { type: 'application/json' }); downloadBlob(filename, blob); } function buildBboxFromLatLonRadius( lat: number, lon: number, radiusKm: number ): { west: number; south: number; east: number; north: number } { if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(radiusKm)) { throw new Error('Lat/Lon/Radius must be finite numbers.'); } if (radiusKm <= 0) { throw new Error('Radius must be greater than 0.'); } if (lat <= -85 || lat >= 85) { throw new Error('Latitude near poles is not supported for Terrarium import in MVP.'); } if (lon < -180 || lon > 180) { throw new Error('Longitude must be within [-180, 180].'); } const latDelta = radiusKm / 110.574; const lonDenom = 111.320 * Math.max(0.05, Math.cos((lat * Math.PI) / 180)); const lonDelta = radiusKm / lonDenom; const west = lon - lonDelta; const east = lon + lonDelta; const south = lat - latDelta; const north = lat + latDelta; if (west < -180 || east > 180) { throw new Error('BBox crosses the date line; adjust center/radius for MVP import.'); } if (south <= -90 || north >= 90) { throw new Error('BBox reaches invalid latitude range; reduce radius.'); } return { west, south, east, north }; } async function decodePngFileToRgba(file: File): Promise<{ rgbaBuffer: ArrayBuffer; width: number; height: number; }> { const bitmap = await createImageBitmap(file); const decodeCanvas = document.createElement('canvas'); decodeCanvas.width = bitmap.width; decodeCanvas.height = bitmap.height; const decodeCtx = decodeCanvas.getContext('2d', { willReadFrequently: true }); if (!decodeCtx) { throw new Error('Failed to decode PNG: 2D context unavailable.'); } decodeCtx.drawImage(bitmap, 0, 0); const imageData = decodeCtx.getImageData(0, 0, bitmap.width, bitmap.height); const rgbaBuffer = imageData.data.slice().buffer as ArrayBuffer; return { rgbaBuffer, width: bitmap.width, height: bitmap.height }; } function refreshLayerUiAfterImport(state: AppState): void { refreshLayerSelector(state); if (state.overlayAutoRange) { syncLayerRangeFromActive(state); } updateProbeReadout(state); } function sanitizeImportedHeightData(data: Float32Array): { sanitized: Float32Array; nanCount: number } { const out = new Float32Array(data.length); let nanCount = 0; for (let i = 0; i < data.length; i += 1) { const value = data[i]; if (!Number.isFinite(value)) { out[i] = 0; nanCount += 1; } else { out[i] = value; } } return { sanitized: out, nanCount }; } function pushImportRecipe(state: AppState, recipe: ImportRecipe): void { const hash = hashImportRecipe(recipe); const exists = state.importRecipes.some((entry) => hashImportRecipe(entry) === hash); if (!exists) { state.importRecipes.push(cloneImportRecipeEntry(recipe)); } } function pushImportAttribution(state: AppState, attribution?: TerrainImportAttribution): void { if (!attribution) return; const exists = state.importAttribution.some((entry) => ( entry.provider === attribution.provider && entry.url === attribution.url && entry.label === attribution.label )); if (!exists) { state.importAttribution.push(cloneAttributionEntry(attribution)); } } function clearImportedBaseLayer(state: AppState): void { state.importedBaseLayer = null; state.importedBaseLayerWidth = 0; state.importedBaseLayerHeight = 0; state.importRecipes = []; state.importAttribution = []; state.pendingImportContext = null; state.pendingImportRequestId = 0; removeMapLabLayer(state, 'deltaBase'); syncTerrainImportMetadataOnRecipe(state); postWorkerClearBaseLayer(state); scheduleInteractiveRegenerate(state, 0, 600); setStatus('Cleared imported base layer.'); updateImportPreview(0, 0, [], { source: 'none' }); refreshLayerUiAfterImport(state); syncSidebarPanelsVisibility(state); updateObjectiveReadout(state); } function applyImportedTerrainResult( state: AppState, msg: TerrainImportSuccessMessage ): void { const context = state.pendingImportContext; if (!context) return; const rawData = new Float32Array(msg.heightDataBuffer); const { sanitized, nanCount } = sanitizeImportedHeightData(rawData); state.importedBaseLayer = sanitized; state.importedBaseLayerWidth = msg.width; state.importedBaseLayerHeight = msg.height; postWorkerSetBaseLayer(state, sanitized, msg.width, msg.height); const recipe = cloneImportRecipeEntry(context.recipe); if (recipe.type === 'terrarium-tiles') { const zMeta = msg.meta.z; if (typeof zMeta === 'number' && Number.isFinite(zMeta)) { recipe.z = Math.max(0, Math.floor(zMeta)); } } pushImportRecipe(state, recipe); pushImportAttribution(state, context.attribution); syncTerrainImportMetadataOnRecipe(state); const source = typeof msg.meta.source === 'string' ? msg.meta.source : context.sourceLabel; const isTerrariumImport = context.recipe.type === 'terrarium-tiles' || source === 'terrarium'; if (isTerrariumImport) { applyTerrariumColorDefaults(state, msg); } const metaWithNan = { ...msg.meta, source, nanCount }; updateImportPreview(msg.min, msg.max, msg.histogram, metaWithNan); if (typeof msg.meta.cacheHits === 'number') state.cacheHits += msg.meta.cacheHits; if (typeof msg.meta.cacheMisses === 'number') state.cacheMisses += msg.meta.cacheMisses; updateCacheStatsUi(state); clearScheduledRegenerations(state); regenerate(state, 'full'); refreshLayerUiAfterImport(state); syncSidebarPanelsVisibility(state); requestRender(state); setStatus(`Imported terrain base layer (${context.sourceLabel}, ${msg.width}x${msg.height}); rebuilding full map...`); state.pendingImportContext = null; state.pendingImportRequestId = 0; } function updateImportModeUi(): void { const mode = controls.importMode.value; const upload = mode === 'upload'; controls.importUploadFields.hidden = !upload; controls.importTerrariumFields.hidden = upload; } function populateTerrariumPresetOptions(): void { const select = controls.terrariumPreset; if (!select) return; const existing = new Set(); for (let i = 0; i < select.options.length; i += 1) { existing.add(select.options[i].value); } for (const preset of TERRARIUM_BATTLE_PRESETS) { if (existing.has(preset.id)) continue; const option = document.createElement('option'); option.value = preset.id; option.textContent = preset.label; select.appendChild(option); } } function applyTerrariumPresetById(id: string): boolean { const preset = TERRARIUM_BATTLE_PRESETS.find((entry) => entry.id === id); if (!preset) return false; controls.terrariumLat.value = preset.lat.toFixed(6); controls.terrariumLon.value = preset.lon.toFixed(6); controls.terrariumRadiusKm.value = preset.radiusKm.toFixed(1); controls.terrariumZoom.value = typeof preset.zoom === 'number' ? String(preset.zoom) : ''; return true; } async function runUploadTerrainImport(state: AppState): Promise { if (!state.importWorker) { setStatus('Terrain import worker is unavailable.'); return; } const file = controls.importHeightmapFile.files?.[0]; if (!file) { setStatus('Select a heightmap file first.'); return; } const targetSize = Math.max(2, Number.parseInt(controls.resolution.value, 10) || state.recipe.resolution); const resample = controls.uploadResample.value === 'nearest' ? 'nearest' : 'bilinear'; const normalizeInput = controls.uploadNormalize.checked; const scaleMetersPerUnit = Number.parseFloat(controls.uploadScale.value); const offsetMeters = Number.parseFloat(controls.uploadOffset.value); if (!Number.isFinite(scaleMetersPerUnit) || !Number.isFinite(offsetMeters)) { setStatus('Scale/offset must be finite numbers.'); return; } const format = controls.uploadFormat.value === 'raw16' ? 'raw16' : 'png8'; const requestId = nextImportRequestId(state); state.pendingImportRequestId = requestId; if (format === 'png8') { const decoded = await decodePngFileToRgba(file); const recipe: ImportRecipe = { type: 'upload-heightmap', fileName: file.name, bitDepth: 8, normalizeInput, scaleMetersPerUnit, offsetMeters, resample }; const validated = validateImportRecipe(recipe); if (!validated.ok) { throw new Error(validated.errors.join('; ')); } const req: TerrainImportUploadPng8Request = { type: 'upload-png8', requestId, fileName: file.name, rgbaBuffer: decoded.rgbaBuffer, sourceWidth: decoded.width, sourceHeight: decoded.height, targetSize, normalizeInput, scaleMetersPerUnit, offsetMeters, resample }; state.pendingImportContext = { recipe, sourceLabel: 'Upload PNG8', width: targetSize, height: targetSize }; syncSidebarPanelsVisibility(state); state.importWorker.postMessage(req, [req.rgbaBuffer]); } else { const sourceWidth = Math.max(2, Number.parseInt(controls.uploadRawWidth.value, 10) || 0); const sourceHeight = Math.max(2, Number.parseInt(controls.uploadRawHeight.value, 10) || 0); if (!Number.isFinite(sourceWidth) || !Number.isFinite(sourceHeight)) { throw new Error('RAW16 width/height must be valid integers.'); } const expectedBytes = sourceWidth * sourceHeight * 2; const maxBytes = 1.5 * 1024 * 1024 * 1024; if (expectedBytes > maxBytes) { throw new Error('RAW16 dimensions are too large for MVP import.'); } const rawBuffer = await file.arrayBuffer(); if (rawBuffer.byteLength < expectedBytes) { throw new Error(`RAW16 buffer is truncated (${rawBuffer.byteLength} < ${expectedBytes} bytes).`); } const littleEndian = controls.uploadRawEndian.value !== 'big'; const recipe: ImportRecipe = { type: 'upload-heightmap', fileName: file.name, bitDepth: 16, normalizeInput, scaleMetersPerUnit, offsetMeters, resample }; const validated = validateImportRecipe(recipe); if (!validated.ok) { throw new Error(validated.errors.join('; ')); } const req: TerrainImportUploadRaw16Request = { type: 'upload-raw16', requestId, fileName: file.name, rawBuffer, sourceWidth, sourceHeight, littleEndian, targetSize, normalizeInput, scaleMetersPerUnit, offsetMeters, resample }; state.pendingImportContext = { recipe, sourceLabel: 'Upload RAW16', width: targetSize, height: targetSize }; syncSidebarPanelsVisibility(state); state.importWorker.postMessage(req, [req.rawBuffer]); } setStatus('Importing uploaded heightmap...'); } async function runTerrariumTerrainImport(state: AppState): Promise { if (!state.importWorker) { setStatus('Terrain import worker is unavailable.'); return; } if (!controls.terrariumAttribution.checked) { setStatus('Accept attribution requirements before Terrarium import.'); return; } const lat = Number.parseFloat(controls.terrariumLat.value); const lon = Number.parseFloat(controls.terrariumLon.value); const radiusKm = Number.parseFloat(controls.terrariumRadiusKm.value); const bbox = buildBboxFromLatLonRadius(lat, lon, radiusKm); const targetSize = Math.max(2, Number.parseInt(controls.resolution.value, 10) || state.recipe.resolution); const zRaw = Number.parseInt(controls.terrariumZoom.value, 10); const hasZoomOverride = Number.isFinite(zRaw); const z = hasZoomOverride ? Math.max(6, Math.min(14, zRaw)) : undefined; const requestId = nextImportRequestId(state); state.pendingImportRequestId = requestId; const recipe: ImportRecipe = { type: 'terrarium-tiles', provider: 'aws-terrain-tiles', z: z ?? 6, bbox, resample: 'bilinear', attributionAccepted: true }; const validated = validateImportRecipe(recipe); if (!validated.ok) { throw new Error(validated.errors.join('; ')); } const req: TerrainImportTerrariumRequest = { type: 'terrarium-import', requestId, bbox, targetSize, z, resample: 'bilinear', urlTemplate: MAPLAB_TERRARIUM_URL_TEMPLATE, concurrency: 8, retries: 2 }; state.pendingImportContext = { recipe, attribution: { provider: 'aws-terrain-tiles', label: MAPLAB_TERRARIUM_PROVIDER_LABEL, url: MAPLAB_TERRARIUM_ATTRIBUTION_URL, accepted: true }, sourceLabel: 'Terrarium', width: targetSize, height: targetSize }; syncSidebarPanelsVisibility(state); state.importWorker.postMessage(req); setStatus('Importing Terrarium tiles...'); } function handleTerrainImportWorkerMessage(state: AppState, msg: TerrainImportWorkerMessage): void { if (msg.requestId !== state.pendingImportRequestId) { return; } if (msg.type === 'progress') { setStatus(`Import ${msg.stage}: ${msg.completed}/${msg.total}${msg.detail ? ` (${msg.detail})` : ''}`); syncSidebarPanelsVisibility(state); return; } if (msg.type === 'error') { state.pendingImportRequestId = 0; state.pendingImportContext = null; setStatus(`Terrain import failed: ${msg.message}`); syncSidebarPanelsVisibility(state); return; } applyImportedTerrainResult(state, msg); } function exportMapLabRecipeDocument(state: AppState): void { const document = createTerrainImportRecipeDocument(state); const hashPart = state.bundle?.hash ?? 'draft'; downloadJson(`maplab-import-recipe-${hashPart}.json`, document); setStatus('Exported import recipe JSON.'); } function exportMapLabHeightRaw(state: AppState): void { const bundle = getBestExportBundle(state); if (!bundle) { setStatus('Generate a bundle before exporting RAW height.'); return; } const raw = new Float32Array(bundle.heightData); const hashPart = bundle.hash; downloadBlob(`maplab-height-${hashPart}.raw`, new Blob([raw.buffer], { type: 'application/octet-stream' })); downloadJson(`maplab-height-${hashPart}.json`, { width: bundle.width, height: bundle.height, cellSizeMeters: bundle.worldMetrics.mapSizeMeters / Math.max(1, bundle.width - 1), littleEndian: true, hash: bundle.hash }); setStatus('Exported height RAW + sidecar.'); } async function exportMapLabOverlayPng(state: AppState): Promise { const activeLayer = state.activeLayerId || 'height'; const hashPart = state.bundle?.hash ?? 'draft'; const canvasSource = state.overlayRendererReady ? gpuCanvas : canvas; const blob = await new Promise((resolve) => canvasSource.toBlob(resolve, 'image/png')); if (!blob) { setStatus('Failed to encode overlay PNG.'); return; } downloadBlob(`maplab-overlay-${activeLayer}-${hashPart}.png`, blob); downloadJson(`maplab-overlay-${activeLayer}-${hashPart}.json`, { layerId: activeLayer, min: state.overlayRangeMin, max: state.overlayRangeMax, alpha: state.overlayAlpha, colormap: controls.layerColormap.value }); setStatus('Exported overlay PNG + sidecar.'); } function regenerate(state: AppState, mode: RegenerationMode): void { state.recipe = readRecipeFromControls(state); const generationRecipe = mode === 'preview' ? buildPreviewRecipeForState(state, state.recipe) : buildRecipeForMode(state.recipe, mode); const localFallbackRecipe = !state.worker && mode === 'preview' && generationRecipe.resolution < 256 ? { ...generationRecipe, resolution: 256 } : generationRecipe; if (mode === 'preview') { setStatus(`MapLab realtime SDF pass (${localFallbackRecipe.resolution}): updating...`); } else { setStatus(`Generating MapLab SDF heightmap bundle (${localFallbackRecipe.resolution})...`); } if (state.worker) { if (mode === 'full') { postWorkerFull(state, generationRecipe); } else { postWorkerRebuild(state, generationRecipe); } return; } try { const generated = generateStrategicMapBundle(localFallbackRecipe); applyImportedBaseLayerToBundle(state, generated); applyGeneratedBundle(state, generated, mode); } catch (error) { setStatus('Generation failed. Check recipe values.'); console.error('[MapLab] Generation failed:', error); } } function clearScheduledRegenerations(state: AppState): void { if (state.previewFrameHandle !== null) { window.cancelAnimationFrame(state.previewFrameHandle); state.previewFrameHandle = null; } if (state.previewTimer !== null) { window.clearTimeout(state.previewTimer); state.previewTimer = null; } if (state.fullTimer !== null) { window.clearTimeout(state.fullTimer); state.fullTimer = null; } } function schedulePreviewRegenerate( state: AppState, delay = 0, options?: { interactive?: boolean } ): void { if (options?.interactive !== false) { touchTurboContours(state); } if (state.previewFrameHandle !== null) { window.cancelAnimationFrame(state.previewFrameHandle); state.previewFrameHandle = null; } if (state.previewTimer !== null) { window.clearTimeout(state.previewTimer); state.previewTimer = null; } if (state.fullTimer !== null) { window.clearTimeout(state.fullTimer); state.fullTimer = null; } if (delay <= 0) { state.previewFrameHandle = window.requestAnimationFrame(() => { state.previewFrameHandle = null; regenerate(state, 'preview'); }); return; } state.previewTimer = window.setTimeout(() => { state.previewTimer = null; regenerate(state, 'preview'); }, delay); } function scheduleFullRegenerate(state: AppState, delay = 1400): void { if (state.fullTimer !== null) { window.clearTimeout(state.fullTimer); state.fullTimer = null; } const effectiveDelay = Math.max(420, Math.round(delay * getEditSpeedFullDelayScale(state))); state.fullTimer = window.setTimeout(() => { state.fullTimer = null; regenerate(state, 'full'); }, effectiveDelay); } function scheduleInteractiveRegenerate(state: AppState, previewDelay = 0, fullDelay = 1400): void { schedulePreviewRegenerate(state, previewDelay); scheduleFullRegenerate(state, fullDelay); } function beginShapeDraft(state: AppState): void { const mode = asShapeMode(controls.shapeMode.value); if (mode === 'none') { state.shapeDraft = null; setStatus('Shape mode disabled. Brush editing active.'); syncToolModeUi(state); return; } if (mode === 'edit') { state.shapeDraft = null; setStatus('Geometry edit mode: drag points, Alt+click edge to insert, right-click point to remove.'); syncToolModeUi(state); requestRender(state); return; } state.shapeDraft = { mode, points: [], width: Math.max(40, Number.parseFloat(controls.shapeWidth.value) || 500), intensity: Math.max(1, Number.parseFloat(controls.shapeIntensity.value) || 16), falloff: Math.max(0.2, Math.min(2.5, Number.parseFloat(controls.shapeFalloff.value) || 1)) }; setStatus(`Started ${mode} draft. Left-click to add points, right-click to remove last point.`); syncToolModeUi(state); requestRender(state); } function addShapePointFromCanvas(state: AppState, clientX: number, clientY: number): void { touchTurboContours(state); const mode = asShapeMode(controls.shapeMode.value); if (mode === 'none' || mode === 'edit') return; if (!state.shapeDraft || state.shapeDraft.mode !== mode) { beginShapeDraft(state); } if (!state.shapeDraft) return; const rect = canvas.getBoundingClientRect(); const px = (clientX - rect.left) * (canvas.width / Math.max(1, rect.width)); const py = (clientY - rect.top) * (canvas.height / Math.max(1, rect.height)); const world = canvasToWorld(state, px, py); state.shapeDraft.width = Math.max(40, Number.parseFloat(controls.shapeWidth.value) || state.shapeDraft.width); state.shapeDraft.intensity = Math.max(1, Number.parseFloat(controls.shapeIntensity.value) || state.shapeDraft.intensity); state.shapeDraft.falloff = Math.max(0.2, Math.min(2.5, Number.parseFloat(controls.shapeFalloff.value) || state.shapeDraft.falloff)); const pointWeight = Math.max(0.2, Math.min(3, Number.parseFloat(controls.shapePointWeight.value) || 1)); state.shapeDraft.points.push({ x: world.x, z: world.z, weight: pointWeight }); setStatus(`${mode} draft points: ${state.shapeDraft.points.length}`); requestRender(state); } function removeLastShapePoint(state: AppState): boolean { touchTurboContours(state); if (!state.shapeDraft || state.shapeDraft.mode === 'none' || state.shapeDraft.points.length === 0) return false; state.shapeDraft.points.pop(); setStatus(`Removed last draft point (${state.shapeDraft.points.length} left).`); syncToolModeUi(state); requestRender(state); return true; } function finishShapeDraft(state: AppState): void { if (!state.shapeDraft || state.shapeDraft.mode === 'none') { setStatus('No active shape draft.'); return; } const draft = state.shapeDraft; const areaType = shapeModeToAreaType(draft.mode); const splineType = shapeModeToSplineType(draft.mode); beginHistoryTransaction(state); if (areaType) { if (draft.points.length < 3) { state.historyPendingBefore = null; setStatus('Polygon features need at least 3 points.'); return; } const area: StrategicAreaFeature = { id: `${areaType}-${Date.now()}-${Math.floor(Math.random() * 10000)}`, type: areaType, points: draft.points.map((point) => ({ ...point })), intensity: draft.intensity, falloff: draft.falloff, profile: readDraftProfileFromControls(areaType === 'mountain' ? 'raise' : 'default'), source: 'manual' }; state.recipe.areaFeatures = [...(state.recipe.areaFeatures ?? []), area]; setSelectedFeatureRef(state, { kind: 'area', id: area.id }); state.selectedFeaturePointIndex = null; } else if (splineType) { if (draft.points.length < 2) { state.historyPendingBefore = null; setStatus('Spline features need at least 2 points.'); return; } const spline: StrategicSplineFeature = { id: `${splineType}-${Date.now()}-${Math.floor(Math.random() * 10000)}`, type: splineType, points: draft.points.map((point) => ({ ...point })), width: draft.width, intensity: draft.intensity, falloff: draft.falloff, profile: readDraftProfileFromControls(splineType === 'river' ? 'channel' : 'flatten'), source: 'manual' }; state.recipe.splineFeatures = [...(state.recipe.splineFeatures ?? []), spline]; setSelectedFeatureRef(state, { kind: 'spline', id: spline.id }); state.selectedFeaturePointIndex = Math.max(0, spline.points.length - 1); } const mode = draft.mode; state.shapeDraft = null; refreshFeatureEditor(state); syncToolModeUi(state); scheduleInteractiveRegenerate(state, 0, 1600); commitHistoryTransaction(state); setStatus(`Added ${mode} feature.`); } function cancelShapeDraft(state: AppState): void { if (!state.shapeDraft) return; state.shapeDraft = null; setStatus('Cancelled active shape draft.'); syncToolModeUi(state); requestRender(state); } function clearFeaturesBySelectedMode(state: AppState): void { const mode = asShapeMode(controls.shapeMode.value); if (mode === 'forest' || mode === 'mountain') { beginHistoryTransaction(state); const before = state.recipe.areaFeatures?.length ?? 0; state.recipe.areaFeatures = (state.recipe.areaFeatures ?? []).filter((feature) => feature.type !== mode); const removed = before - (state.recipe.areaFeatures?.length ?? 0); if (removed > 0) { refreshFeatureEditor(state); scheduleInteractiveRegenerate(state, 0, 1400); commitHistoryTransaction(state); } else { state.historyPendingBefore = null; } setStatus(`Removed ${removed} ${mode} polygons.`); return; } if (mode === 'river' || mode === 'road') { beginHistoryTransaction(state); const before = state.recipe.splineFeatures?.length ?? 0; state.recipe.splineFeatures = (state.recipe.splineFeatures ?? []).filter((feature) => feature.type !== mode); const removed = before - (state.recipe.splineFeatures?.length ?? 0); if (removed > 0) { refreshFeatureEditor(state); scheduleInteractiveRegenerate(state, 0, 1400); commitHistoryTransaction(state); } else { state.historyPendingBefore = null; } setStatus(`Removed ${removed} ${mode} splines.`); return; } setStatus('Select a shape mode first.'); } function deleteSelectedFeature(state: AppState): void { const ref = state.selectedFeatureRef; if (!ref) { setStatus('No selected feature to delete.'); return; } beginHistoryTransaction(state); if (ref.kind === 'area') { const before = state.recipe.areaFeatures?.length ?? 0; state.recipe.areaFeatures = (state.recipe.areaFeatures ?? []).filter((feature) => feature.id !== ref.id); if ((state.recipe.areaFeatures?.length ?? 0) === before) { state.historyPendingBefore = null; setStatus('Selected area feature was not found.'); return; } } else { const before = state.recipe.splineFeatures?.length ?? 0; state.recipe.splineFeatures = (state.recipe.splineFeatures ?? []).filter((feature) => feature.id !== ref.id); if ((state.recipe.splineFeatures?.length ?? 0) === before) { state.historyPendingBefore = null; setStatus('Selected spline feature was not found.'); return; } } state.selectedFeaturePointIndex = null; refreshFeatureEditor(state); requestRender(state); scheduleInteractiveRegenerate(state, 0, 1200); commitHistoryTransaction(state); setStatus('Deleted selected feature.'); } function handleShapeGeometryInput( state: AppState, options?: { applyPointWeights?: boolean; commit?: boolean } ): void { updateControlReadouts(); if (state.shapeDraft) { state.shapeDraft.width = Math.max(40, Number.parseFloat(controls.shapeWidth.value) || state.shapeDraft.width); state.shapeDraft.intensity = Math.max(1, Number.parseFloat(controls.shapeIntensity.value) || state.shapeDraft.intensity); state.shapeDraft.falloff = Math.max(0.2, Math.min(2.5, Number.parseFloat(controls.shapeFalloff.value) || state.shapeDraft.falloff)); requestRender(state); return; } const updated = updateSelectedFeatureGeometryFromShapeInputs(state, { applyPointWeights: options?.applyPointWeights === true, schedule: options?.commit ? 'interactive' : 'preview' }); if (!updated) return; requestRender(state); if (options?.commit) { setStatus('Feature geometry updated.'); } else { setStatusThrottled(state, 'Updating feature geometry...'); } } function duplicateSelectedFeature(state: AppState): boolean { const ref = state.selectedFeatureRef; if (!ref) return false; beginHistoryTransaction(state); const offset = Math.max(90, Math.min(520, state.recipe.mapSizeMeters * 0.012)); if (ref.kind === 'area') { const source = (state.recipe.areaFeatures ?? []).find((feature) => feature.id === ref.id); if (!source) { state.historyPendingBefore = null; return false; } const duplicate: StrategicAreaFeature = { ...source, id: `${source.id}-dup-${Date.now().toString(36)}`, points: source.points.map((point) => { const next = clampWorldToMap(state, { x: point.x + offset, z: point.z + offset }); return { ...point, x: next.x, z: next.z }; }), profile: source.profile ? { ...source.profile } : undefined }; state.recipe.areaFeatures = [...(state.recipe.areaFeatures ?? []), duplicate]; setSelectedFeatureRef(state, { kind: 'area', id: duplicate.id }); state.selectedFeaturePointIndex = null; } else { const source = (state.recipe.splineFeatures ?? []).find((feature) => feature.id === ref.id); if (!source) { state.historyPendingBefore = null; return false; } const duplicate: StrategicSplineFeature = { ...source, id: `${source.id}-dup-${Date.now().toString(36)}`, points: source.points.map((point) => { const next = clampWorldToMap(state, { x: point.x + offset, z: point.z + offset }); return { ...point, x: next.x, z: next.z }; }), profile: source.profile ? { ...source.profile } : undefined }; state.recipe.splineFeatures = [...(state.recipe.splineFeatures ?? []), duplicate]; setSelectedFeatureRef(state, { kind: 'spline', id: duplicate.id }); state.selectedFeaturePointIndex = Math.min( duplicate.points.length - 1, Math.max(0, state.selectedFeaturePointIndex ?? 0) ); } refreshFeatureEditor(state); requestRender(state); scheduleInteractiveRegenerate(state, 0, 1300); commitHistoryTransaction(state); setStatus('Duplicated selected feature.'); return true; } function isEditingTextTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; const tag = target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; return target.isContentEditable; } function selectShapeMode(state: AppState, mode: ShapeMode): void { const normalized = mode === 'none' ? 'none' : mode; if (asShapeMode(controls.shapeMode.value) === normalized) return; controls.shapeMode.value = normalized; emitControlEvents(controls.shapeMode); syncSelectToolbar(controls.shapeMode); syncToolModeUi(state); } function selectBrushSymmetryMode(state: AppState, mode: BrushSymmetryMode): void { const normalized = asBrushSymmetryMode(mode); if (asBrushSymmetryMode(controls.brushSymmetry.value) === normalized) return; controls.brushSymmetry.value = normalized; emitControlEvents(controls.brushSymmetry); syncSelectToolbar(controls.brushSymmetry); updateControlReadouts(); requestRender(state); } function cycleTerraformMode(state: AppState): TerraformMode { const order: TerraformMode[] = ['off', 'raise', 'lower', 'smooth', 'erode', 'sharpen', 'flatten-cursor', 'flatten-height']; const current = asTerraformMode(controls.terraformMode.value); const idx = order.indexOf(current); const next = order[(idx + 1) % order.length]; controls.terraformMode.value = next; emitControlEvents(controls.terraformMode); syncSelectToolbar(controls.terraformMode); updateControlReadouts(); syncToolModeUi(state); requestRender(state); return next; } function nudgeSelectedFeatureProperty(state: AppState, key: FeaturePropertyHandleKey, direction: 1 | -1): boolean { const feature = findSelectedFeature(state); if (!feature) return false; if (key === 'width' && !('width' in feature)) return false; beginHistoryTransaction(state); const current = key === 'intensity' ? Math.max(1, Number(feature.intensity) || 16) : key === 'falloff' ? Math.max(0.2, Number(feature.falloff) || 1) : Math.max(80, Number((feature as StrategicSplineFeature).width) || 500); const step = key === 'falloff' ? 0.05 : key === 'width' ? 24 : 1; const next = current + direction * step; if (!applyFeaturePropertyValueFromHandle(state, key, next, 'interactive')) { state.historyPendingBefore = null; return false; } refreshFeatureEditor(state); requestRender(state); commitHistoryTransaction(state); setStatus(`Adjusted ${key}.`); return true; } function nudgeSelectedSplinePointWeight(state: AppState, direction: 1 | -1, step = 0.05): boolean { const spline = getSelectedSplineFeature(state); if (!spline) return false; sanitizeSelectedSplinePointIndex(state); const pointIndex = state.selectedFeaturePointIndex; if (pointIndex === null) return false; const point = spline.points[pointIndex]; if (!point) return false; beginHistoryTransaction(state); const current = Math.max(0.2, Math.min(3, Number.isFinite(point.weight) ? Number(point.weight) : 1)); const next = Math.max(0.2, Math.min(3, current + direction * step)); if (Math.abs(next - current) < 0.001) { state.historyPendingBefore = null; return false; } point.weight = next; setNumericInputValue(controls.shapePointWeight, next); updateControlReadouts(); syncSplineFeatureWeightsInput(spline); requestRender(state); schedulePreviewRegenerate(state, 0, { interactive: false }); scheduleFullRegenerate(state, 1200); commitHistoryTransaction(state); setStatusThrottled(state, `Spline point weight: ${next.toFixed(2)}`); return true; } function handleMapLabKeydown(state: AppState, event: KeyboardEvent): void { if ((event.key === '?' || (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') && !event.altKey) { event.preventDefault(); showShortcutsDialog(); return; } if (controls.shortcutsDialog?.open) { if (event.key === 'Escape') { event.preventDefault(); closeShortcutsDialog(); } return; } if (isEditingTextTarget(event.target)) return; if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'z') { event.preventDefault(); if (event.shiftKey) { redoHistory(state); } else { undoHistory(state); } return; } if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'y') { event.preventDefault(); redoHistory(state); return; } if (!event.ctrlKey && !event.metaKey && !event.altKey) { const lowered = event.key.toLowerCase(); if (lowered === 'h') { event.preventDefault(); setOverlayChecked(OVERLAYS.heat, !OVERLAYS.heat.checked); return; } if (lowered === 'c') { event.preventDefault(); setOverlayChecked(OVERLAYS.contours, !OVERLAYS.contours.checked); return; } if (lowered === 'f') { event.preventDefault(); setOverlayChecked(OVERLAYS.features, !OVERLAYS.features.checked); return; } if (lowered === 'g') { event.preventDefault(); void toggleCanvasShellFullscreen(state); return; } if (lowered === 'u') { event.preventDefault(); toggleSidebarDock(state); return; } if (lowered === 'i') { event.preventDefault(); toggleImportPanelPin(state); return; } if (lowered === 'l') { event.preventDefault(); toggleLayerPanelPin(state); return; } if (lowered === 'o') { event.preventDefault(); toggleOverlaysPanelPin(state); return; } if (lowered === 's') { event.preventDefault(); applyWorkflowPreset(state, 'sculpt'); return; } if (lowered === 'v') { event.preventDefault(); applyWorkflowPreset(state, 'carve'); return; } if (lowered === 'r') { event.preventDefault(); applyWorkflowPreset(state, 'river'); return; } if (lowered === 'e') { event.preventDefault(); applyWorkflowPreset(state, 'edit'); return; } if (lowered === 't') { event.preventDefault(); const next = cycleTerraformMode(state); setStatus(`Terraform mode: ${next}.`); return; } } if (event.key >= '1' && event.key <= '6' && !event.ctrlKey && !event.metaKey && !event.altKey) { event.preventDefault(); switch (event.key) { case '1': selectShapeMode(state, 'none'); return; case '2': selectShapeMode(state, 'edit'); return; case '3': selectShapeMode(state, 'mountain'); return; case '4': selectShapeMode(state, 'forest'); return; case '5': selectShapeMode(state, 'river'); return; case '6': selectShapeMode(state, 'road'); return; } } if (!event.ctrlKey && !event.metaKey && !event.altKey && (event.key === '7' || event.key === '8' || event.key === '9' || event.key === '0')) { event.preventDefault(); if (event.key === '7') { selectBrushSymmetryMode(state, 'none'); setStatus('Brush symmetry: none.'); return; } if (event.key === '8') { selectBrushSymmetryMode(state, 'mirror-x'); setStatus('Brush symmetry: mirror-x.'); return; } if (event.key === '9') { selectBrushSymmetryMode(state, 'mirror-z'); setStatus('Brush symmetry: mirror-z.'); return; } selectBrushSymmetryMode(state, 'quad'); setStatus('Brush symmetry: quad.'); return; } if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd') { event.preventDefault(); duplicateSelectedFeature(state); return; } if (event.key === 'Delete' || event.key === 'Backspace') { const mode = asShapeMode(controls.shapeMode.value); if (mode !== 'edit') return; event.preventDefault(); if (state.selectedFeaturePointIndex !== null) { const feature = getSelectedSplineFeature(state); if (feature) { if (feature.points.length <= 2) { setStatus('Spline needs at least 2 points.'); return; } beginHistoryTransaction(state); const index = state.selectedFeaturePointIndex; feature.points.splice(index, 1); setSelectedSplinePointIndex(state, Math.max(0, index - 1)); refreshFeatureEditor(state); scheduleInteractiveRegenerate(state, 0, 1300); requestRender(state); commitHistoryTransaction(state); setStatus('Deleted selected spline point.'); return; } } deleteSelectedFeature(state); return; } if (event.key === '[' || event.key === ']') { const direction: 1 | -1 = event.key === ']' ? 1 : -1; event.preventDefault(); if (event.altKey) { if (!nudgeSelectedFeatureProperty(state, 'falloff', direction) && asShapeMode(controls.shapeMode.value) === 'none') { const current = Number.parseFloat(controls.brushFalloff.value) || 1; setNumericInputValue(controls.brushFalloff, current + direction * 0.05); updateControlReadouts(); schedulePreviewRegenerate(state, 0, { interactive: false }); } return; } if (event.shiftKey) { if (!nudgeSelectedFeatureProperty(state, 'width', direction) && asShapeMode(controls.shapeMode.value) === 'none') { const current = Number.parseFloat(controls.brushWidth.value) || 900; setNumericInputValue(controls.brushWidth, current + direction * 24); updateControlReadouts(); schedulePreviewRegenerate(state, 0, { interactive: false }); } return; } if (!nudgeSelectedFeatureProperty(state, 'intensity', direction) && asShapeMode(controls.shapeMode.value) === 'none') { const current = Number.parseFloat(controls.brushIntensity.value) || 18; setNumericInputValue(controls.brushIntensity, current + direction * 1); updateControlReadouts(); schedulePreviewRegenerate(state, 0, { interactive: false }); } } } function canvasClientToWorld(state: AppState, clientX: number, clientY: number): { x: number; z: number } { const rect = canvas.getBoundingClientRect(); const px = (clientX - rect.left) * (canvas.width / Math.max(1, rect.width)); const py = (clientY - rect.top) * (canvas.height / Math.max(1, rect.height)); return canvasToWorld(state, px, py); } function canvasClientToCanvasCoords(clientX: number, clientY: number): { x: number; y: number } { const rect = canvas.getBoundingClientRect(); return { x: (clientX - rect.left) * (canvas.width / Math.max(1, rect.width)), y: (clientY - rect.top) * (canvas.height / Math.max(1, rect.height)) }; } function scheduleGeometryEditPreview(state: AppState): void { touchTurboContours(state); const now = performance.now(); const throttleMs = state.editSpeedMode === 'turbo' ? 18 : state.editSpeedMode === 'balanced' ? 24 : 34; if (now - state.lastGeometryPreviewAt < throttleMs) return; state.lastGeometryPreviewAt = now; schedulePreviewRegenerate(state, 0, { interactive: false }); } function beginGeometryPointDrag( state: AppState, pointerId: number, target: FeatureRef, pointIndex: number, startWorld: { x: number; z: number } ): boolean { beginHistoryTransaction(state); const feature = findFeatureByRef(state, target); if (!feature) { state.historyPendingBefore = null; return false; } setSelectedFeatureRef(state, target); if (target.kind === 'spline') { setSelectedSplinePointIndex(state, pointIndex); } else { state.selectedFeaturePointIndex = null; } loadSelectedFeatureToEditor(state); state.geometryEditDrag = { pointerId, target, mode: 'point', pointIndex, startWorld, originPoints: feature.points.map((point) => ({ ...point })), moved: false }; setStatus(`Editing ${target.kind} point #${pointIndex + 1}`); return true; } function beginGeometryFeatureDrag( state: AppState, pointerId: number, target: FeatureRef, startWorld: { x: number; z: number } ): boolean { beginHistoryTransaction(state); const feature = findFeatureByRef(state, target); if (!feature) { state.historyPendingBefore = null; return false; } setSelectedFeatureRef(state, target); if (target.kind !== 'spline') { state.selectedFeaturePointIndex = null; } loadSelectedFeatureToEditor(state); state.geometryEditDrag = { pointerId, target, mode: 'feature', pointIndex: -1, startWorld, originPoints: feature.points.map((point) => ({ ...point })), moved: false }; setStatus('Feature selected. Drag to move.'); return true; } function updateGeometryEditDrag(state: AppState, pointerId: number, world: { x: number; z: number }): void { const drag = state.geometryEditDrag; if (!drag || drag.pointerId !== pointerId) return; const feature = findFeatureByRef(state, drag.target); if (!feature) { state.geometryEditDrag = null; return; } if (drag.mode === 'point') { const clamped = clampWorldToMap(state, world); const point = feature.points[drag.pointIndex]; if (!point) return; if ( Math.abs(point.x - clamped.x) < GEOMETRY_MOVE_EPSILON_METERS && Math.abs(point.z - clamped.z) < GEOMETRY_MOVE_EPSILON_METERS ) { return; } drag.moved = true; point.x = clamped.x; point.z = clamped.z; } else { const dx = world.x - drag.startWorld.x; const dz = world.z - drag.startWorld.z; if (Math.abs(dx) < GEOMETRY_MOVE_EPSILON_METERS && Math.abs(dz) < GEOMETRY_MOVE_EPSILON_METERS) { return; } drag.moved = true; for (let i = 0; i < feature.points.length; i += 1) { const original = drag.originPoints[i]; if (!original) continue; const moved = clampWorldToMap(state, { x: original.x + dx, z: original.z + dz }); feature.points[i].x = moved.x; feature.points[i].z = moved.z; } } scheduleGeometryEditPreview(state); requestRender(state); } function endGeometryEditDrag(state: AppState, pointerId: number): void { const drag = state.geometryEditDrag; if (!drag || drag.pointerId !== pointerId) return; state.geometryEditDrag = null; refreshFeatureEditor(state); if (drag.moved) { schedulePreviewRegenerate(state, 0, { interactive: false }); scheduleFullRegenerate(state, 1300); commitHistoryTransaction(state); setStatus('Geometry edit applied.'); return; } state.historyPendingBefore = null; requestRender(state); } function removeFeaturePointAtCanvas(state: AppState, canvasX: number, canvasY: number): boolean { const hit = pickFeaturePoint(state, canvasX, canvasY, GEOMETRY_PICK_POINT_RADIUS_PX); if (!hit) return false; const feature = findFeatureByRef(state, hit.target); if (!feature) return false; const minimumPoints = hit.target.kind === 'area' ? 3 : 2; if (feature.points.length <= minimumPoints) { setStatus(`Cannot remove point: ${hit.target.kind} needs at least ${minimumPoints} points.`); return true; } beginHistoryTransaction(state); feature.points.splice(hit.pointIndex, 1); setSelectedFeatureRef(state, hit.target); if (hit.target.kind === 'spline') { setSelectedSplinePointIndex(state, Math.max(0, hit.pointIndex - 1)); } else { state.selectedFeaturePointIndex = null; } refreshFeatureEditor(state); scheduleInteractiveRegenerate(state, 0, 1300); requestRender(state); commitHistoryTransaction(state); setStatus('Removed geometry point.'); return true; } function insertFeaturePointNearCanvas(state: AppState, canvasX: number, canvasY: number): boolean { const target = state.selectedFeatureRef ?? pickFeatureBody(state, canvasX, canvasY)?.target ?? null; if (!target) return false; const segmentHit = pickFeatureSegment(state, target, canvasX, canvasY, GEOMETRY_PICK_SEGMENT_RADIUS_PX); if (!segmentHit) return false; const feature = findFeatureByRef(state, target); if (!feature) return false; beginHistoryTransaction(state); const pointWeight = Math.max(0.2, Math.min(3, Number.parseFloat(controls.shapePointWeight.value) || 1)); feature.points.splice(segmentHit.index, 0, { x: segmentHit.world.x, z: segmentHit.world.z, weight: pointWeight }); setSelectedFeatureRef(state, target); if (target.kind === 'spline') { setSelectedSplinePointIndex(state, segmentHit.index); } refreshFeatureEditor(state); scheduleInteractiveRegenerate(state, 0, 1300); requestRender(state); commitHistoryTransaction(state); setStatus('Inserted geometry point.'); return true; } function normalizeRadians(value: number): number { const tau = Math.PI * 2; let out = value % tau; if (out < 0) out += tau; return out; } function mirroredOrientation( orientation: number, mirroredX: boolean, mirroredZ: boolean ): number { if (mirroredX && mirroredZ) return normalizeRadians(orientation + Math.PI); if (mirroredX) return normalizeRadians(Math.PI - orientation); if (mirroredZ) return normalizeRadians(-orientation); return normalizeRadians(orientation); } type BrushSymmetryTarget = { world: { x: number; z: number }; orientation: number; mirroredX: boolean; mirroredZ: boolean; }; function buildBrushSymmetryTargets( state: AppState, world: { x: number; z: number }, orientation: number, symmetryMode: BrushSymmetryMode ): BrushSymmetryTarget[] { const out: BrushSymmetryTarget[] = []; const seen = new Set(); const pushTarget = (x: number, z: number, mirroredX: boolean, mirroredZ: boolean): void => { const clamped = clampWorldToMap(state, { x, z }); const key = `${Math.round(clamped.x * 10)}:${Math.round(clamped.z * 10)}`; if (seen.has(key)) return; seen.add(key); out.push({ world: clamped, orientation: mirroredOrientation(orientation, mirroredX, mirroredZ), mirroredX, mirroredZ }); }; pushTarget(world.x, world.z, false, false); if (symmetryMode === 'mirror-x' || symmetryMode === 'quad') { pushTarget(-world.x, world.z, true, false); } if (symmetryMode === 'mirror-z' || symmetryMode === 'quad') { pushTarget(world.x, -world.z, false, true); } if (symmetryMode === 'quad') { pushTarget(-world.x, -world.z, true, true); } return out; } function applyBrushJitter( state: AppState, world: { x: number; z: number }, orientation: number, radius: number ): { world: { x: number; z: number }; orientation: number } { const jitter = clamp01(Number.parseFloat(controls.brushJitter.value) || 0); if (jitter <= 0) { return { world, orientation }; } const maxOffset = radius * jitter * 0.35; const distance = maxOffset * Math.sqrt(Math.random()); const angle = Math.random() * Math.PI * 2; const jitteredWorld = clampWorldToMap(state, { x: world.x + Math.cos(angle) * distance, z: world.z + Math.sin(angle) * distance }); const orientationJitter = (Math.random() * 2 - 1) * (Math.PI / 8) * jitter; return { world: jitteredWorld, orientation: normalizeRadians(orientation + orientationJitter) }; } type TerraformStampParams = { type: StrategicStampType; sign: 'auto' | 'raise' | 'lower'; intensity: number; }; type TerraformHeightSampleContext = { data: Float32Array; width: number; height: number; mapSizeMeters: number; halfMapSizeMeters: number; }; function createTerraformHeightSampleContext(state: AppState): TerraformHeightSampleContext | null { if (!state.bundle) return null; const layer = state.layerStore.getLayerRef('height'); if (!layer || !(layer.data instanceof Float32Array) || layer.width <= 0 || layer.height <= 0) { return null; } const mapSizeMeters = Math.max(1, state.recipe.mapSizeMeters); return { data: layer.data, width: layer.width, height: layer.height, mapSizeMeters, halfMapSizeMeters: mapSizeMeters * 0.5 }; } function sampleHeightForTerraform( state: AppState, world: { x: number; z: number }, sampleContext?: TerraformHeightSampleContext | null ): number | null { if (sampleContext) { const fx = clamp01((world.x + sampleContext.halfMapSizeMeters) / sampleContext.mapSizeMeters); const fy = clamp01((world.z + sampleContext.halfMapSizeMeters) / sampleContext.mapSizeMeters); const value = bilinearSample(sampleContext.data, sampleContext.width, sampleContext.height, fx, fy); return Number.isFinite(value) ? value : null; } const value = sampleLayerAtWorld(state, 'height', world); if (value === null || !Number.isFinite(value)) return null; return value; } type TerraformNeighborhoodSample = { center: number; average: number; }; const TERRAFORM_SAMPLE_RING: ReadonlyArray<{ x: number; z: number }> = [ { x: 1, z: 0 }, { x: -1, z: 0 }, { x: 0, z: 1 }, { x: 0, z: -1 }, { x: 0.70710678, z: 0.70710678 }, { x: -0.70710678, z: 0.70710678 }, { x: 0.70710678, z: -0.70710678 }, { x: -0.70710678, z: -0.70710678 } ]; function sampleTerraformNeighborhood( state: AppState, world: { x: number; z: number }, radiusMeters: number, sampleContext?: TerraformHeightSampleContext | null ): TerraformNeighborhoodSample | null { const center = sampleHeightForTerraform(state, world, sampleContext); if (center === null) return null; const ringDistance = Math.max(28, radiusMeters * 0.36); let sum = 0; let count = 0; for (let i = 0; i < TERRAFORM_SAMPLE_RING.length; i += 1) { const dir = TERRAFORM_SAMPLE_RING[i]; const sampleWorld = clampWorldToMap(state, { x: world.x + dir.x * ringDistance, z: world.z + dir.z * ringDistance }); const sample = sampleHeightForTerraform(state, sampleWorld, sampleContext); if (sample === null) continue; sum += sample; count += 1; } if (count <= 0) { return { center, average: center }; } return { center, average: sum / count }; } function captureTerraformTargetHeightFromProbe(state: AppState): boolean { if (!state.bundle) return false; const probeCanvasX = state.probe.active ? state.probe.canvasX : canvas.width * 0.5; const probeCanvasY = state.probe.active ? state.probe.canvasY : canvas.height * 0.5; const world = canvasToWorld(state, probeCanvasX, probeCanvasY); const height = sampleHeightForTerraform(state, world); if (height === null) return false; controls.terraformTargetHeight.value = height.toFixed(2); updateControlReadouts(); requestRender(state); return true; } function resolveTerraformStampParams( state: AppState, world: { x: number; z: number }, baseIntensity: number, overrideMode?: TerraformMode, sampleContext?: TerraformHeightSampleContext | null, overrideStrength?: number, overrideTargetHeight?: number, overrideRadiusMeters?: number ): TerraformStampParams | null { const mode = overrideMode ?? asTerraformMode(controls.terraformMode.value); if (mode === 'off') return null; const strengthSource = overrideStrength ?? (Number.parseFloat(controls.terraformStrength.value) || 1); const strength = Math.max(0.3, Math.min(2.5, strengthSource)); if (mode === 'raise') { return { type: 'plateau', sign: 'raise', intensity: Math.max(2, Math.min(70, baseIntensity * (0.75 + strength * 0.55))) }; } if (mode === 'lower') { return { type: 'basin', sign: 'lower', intensity: Math.max(2, Math.min(70, baseIntensity * (0.75 + strength * 0.55))) }; } const localHeight = sampleHeightForTerraform(state, world, sampleContext); if (localHeight === null) return null; if (mode === 'smooth' || mode === 'erode' || mode === 'sharpen') { const radiusMeters = Math.max(60, overrideRadiusMeters ?? (Number.parseFloat(controls.brushRadius.value) || 1200)); const neighborhood = sampleTerraformNeighborhood(state, world, radiusMeters, sampleContext); if (!neighborhood) return null; const delta = neighborhood.average - neighborhood.center; const deadzone = Math.max(0.08, 0.28 / Math.max(0.3, strength)); if (Math.abs(delta) < deadzone) return null; if (mode === 'smooth') { return { type: 'plateau', sign: delta >= 0 ? 'raise' : 'lower', intensity: Math.max(0.8, Math.min(55, Math.abs(delta) * (0.28 + strength * 1.05))) }; } if (mode === 'erode') { const collapsingPeak = delta < 0; const erosionScale = collapsingPeak ? (0.42 + strength * 1.18) : (0.2 + strength * 0.58); return { type: 'plateau', sign: delta >= 0 ? 'raise' : 'lower', intensity: Math.max(0.6, Math.min(52, Math.abs(delta) * erosionScale)) }; } return { type: 'plateau', sign: delta >= 0 ? 'lower' : 'raise', intensity: Math.max(0.65, Math.min(48, Math.abs(delta) * (0.24 + strength * 0.92))) }; } const targetHeight = mode === 'flatten-height' ? (overrideTargetHeight ?? (Number.parseFloat(controls.terraformTargetHeight.value) || 0)) : (state.brushStroke?.flattenTargetHeight ?? localHeight); const delta = targetHeight - localHeight; const deadzone = Math.max(0.12, 0.5 / Math.max(0.3, strength)); if (Math.abs(delta) < deadzone) return null; const flattenIntensity = Math.max(1, Math.min(70, Math.abs(delta) * (0.35 + strength * 1.75))); return { type: 'plateau', sign: delta >= 0 ? 'raise' : 'lower', intensity: flattenIntensity }; } function addManualStampAtWorld( state: AppState, world: { x: number; z: number }, orientationOverride?: number, previewRecipeForWorker?: StrategicMapRecipe | null ): void { touchTurboContours(state); const runtime = state.brushStroke?.settings ?? readBrushRuntimeSettings(); const terraformMode = runtime.terraformMode; const type = runtime.type; const radius = runtime.radius; const intensity = runtime.intensity; const length = runtime.length; const width = runtime.width; const falloff = runtime.falloff; const sign = runtime.sign; const rawOrientation = Number.isFinite(orientationOverride) ? Number(orientationOverride) : runtime.orientationRad; const orientation = normalizeRadians(rawOrientation); const symmetryMode = runtime.symmetryMode; const jittered = applyBrushJitter(state, world, orientation, radius); const targets = buildBrushSymmetryTargets(state, jittered.world, jittered.orientation, symmetryMode); const terraformSampleContext = state.brushStroke?.terraformSampleContext ?? (terraformMode === 'off' ? null : createTerraformHeightSampleContext(state)); const terraformStrength = runtime.terraformStrength; const terraformTargetHeight = runtime.terraformTargetHeight; const created = state.stampScratch; created.length = 0; for (let i = 0; i < targets.length; i += 1) { const target = targets[i]; const terraform = resolveTerraformStampParams( state, target.world, intensity, terraformMode, terraformSampleContext, terraformStrength, terraformTargetHeight, radius ); if (terraformMode !== 'off' && !terraform) { continue; } const stamp: StrategicMapStamp = { id: createManualStampId(state), type: terraform?.type ?? type, x: target.world.x, z: target.world.z, radius, intensity: terraform?.intensity ?? intensity, length, width, falloff, orientation: target.orientation, source: 'manual', sign: terraform?.sign ?? sign }; created.push(stamp); } if (created.length <= 0) return; state.recipe.manualStamps.push(...created); addStampsToManualStampSpatialIndex(state, created); if (state.brushStroke) { state.brushStroke.didMutate = true; } if (state.worker) { const previewRecipe = previewRecipeForWorker ?? buildPreviewRecipeForState(state, state.recipe); setStatusThrottled(state, 'Applying brush delta...'); postWorkerStampDeltaBatch( state, previewRecipe, buildScratchStampDeltaBatch(state, created, 'add') ); } else { scheduleInteractiveRegenerate(state, 0, 1250); } created.length = 0; } function removeNearestManualStampAtWorld( state: AppState, world: { x: number; z: number }, previewRecipeForWorker?: StrategicMapRecipe | null ): void { touchTurboContours(state); if (!state.recipe.manualStamps.length) return; const runtime = state.brushStroke?.settings ?? readBrushRuntimeSettings(); const symmetryMode = runtime.symmetryMode; const targets = buildBrushSymmetryTargets(state, world, 0, symmetryMode); const radius = Math.max(40, runtime.radius); const maxDeleteDistanceSq = Math.max((radius * 1.15) ** 2, 180 ** 2); const removed = state.stampScratch; removed.length = 0; const stamps = state.recipe.manualStamps; const selected = state.stampSelectedScratch; selected.clear(); const spatialIndex = ensureManualStampSpatialIndex(state); if (spatialIndex) { const searchRange = Math.max(1, Math.ceil(Math.sqrt(maxDeleteDistanceSq) / spatialIndex.cellSize)); for (let t = 0; t < targets.length; t += 1) { const target = targets[t]; let bestStamp: StrategicMapStamp | null = null; let bestDistSq = Infinity; const centerX = Math.floor(target.world.x / spatialIndex.cellSize); const centerZ = Math.floor(target.world.z / spatialIndex.cellSize); for (let dz = -searchRange; dz <= searchRange; dz += 1) { for (let dx = -searchRange; dx <= searchRange; dx += 1) { const key = buildManualStampCellKey(centerX + dx, centerZ + dz); const bucket = spatialIndex.buckets.get(key); if (!bucket || bucket.length <= 0) continue; for (let i = 0; i < bucket.length; i += 1) { const stamp = bucket[i]; if (selected.has(stamp)) continue; const distX = stamp.x - target.world.x; const distZ = stamp.z - target.world.z; const distSq = distX * distX + distZ * distZ; if (distSq < bestDistSq) { bestDistSq = distSq; bestStamp = stamp; } } } } if (bestStamp && bestDistSq <= maxDeleteDistanceSq) { selected.add(bestStamp); } } } else { const removeIndices = state.stampIndexScratch; removeIndices.clear(); for (let t = 0; t < targets.length; t += 1) { const target = targets[t]; let bestIndex = -1; let bestDistSq = Infinity; for (let i = 0; i < stamps.length; i += 1) { if (removeIndices.has(i)) continue; const stamp = stamps[i]; const dx = stamp.x - target.world.x; const dz = stamp.z - target.world.z; const distSq = dx * dx + dz * dz; if (distSq < bestDistSq) { bestDistSq = distSq; bestIndex = i; } } if (bestIndex >= 0 && bestDistSq <= maxDeleteDistanceSq) { removeIndices.add(bestIndex); } } if (removeIndices.size > 0) { for (const index of removeIndices) { const stamp = stamps[index]; if (stamp) selected.add(stamp); } } removeIndices.clear(); } if (selected.size > 0) { let writeIndex = 0; for (let i = 0; i < stamps.length; i += 1) { const stamp = stamps[i]; if (selected.has(stamp)) { removed.push(stamp); removeStampFromManualStampSpatialIndex(state, stamp); continue; } if (writeIndex !== i) { stamps[writeIndex] = stamp; } writeIndex += 1; } stamps.length = writeIndex; } selected.clear(); if (removed.length > 0) { if (state.brushStroke) { state.brushStroke.didMutate = true; } if (state.worker) { const previewRecipe = previewRecipeForWorker ?? buildPreviewRecipeForState(state, state.recipe); setStatusThrottled(state, 'Applying brush delta...'); postWorkerStampDeltaBatch( state, previewRecipe, buildScratchStampDeltaBatch(state, removed, 'remove') ); } else { scheduleInteractiveRegenerate(state, 0, 1250); } } removed.length = 0; } function beginBrushStroke( state: AppState, event: PointerEvent, mode: 'add' | 'remove' ): void { beginHistoryTransaction(state); const world = canvasClientToWorld(state, event.clientX, event.clientY); const workerPreviewRecipe = state.worker ? buildPreviewRecipeForState(state, state.recipe) : null; const settings = readBrushRuntimeSettings(); const terraformMode = settings.terraformMode; const terraformSampleContext = terraformMode === 'off' ? null : createTerraformHeightSampleContext(state); let flattenTargetHeight: number | null = null; if (mode === 'add') { if (terraformMode === 'flatten-cursor') { flattenTargetHeight = sampleHeightForTerraform(state, world); if (flattenTargetHeight !== null) { controls.terraformTargetHeight.value = flattenTargetHeight.toFixed(2); settings.terraformTargetHeight = flattenTargetHeight; } } else if (terraformMode === 'flatten-height') { flattenTargetHeight = settings.terraformTargetHeight; } } state.brushStroke = { pointerId: event.pointerId, mode, lastWorld: world, flattenTargetHeight, terraformSampleContext, workerPreviewRecipe, didMutate: false, settings }; if (mode === 'add' && terraformMode !== 'off') { setStatusThrottled(state, `Terraform ${terraformMode}: painting...`); } else { setStatusThrottled(state, mode === 'add' ? 'Brush: painting SDF stamps...' : 'Brush: erasing nearest stamps...'); } const previewRecipeForWorker = state.brushStroke.workerPreviewRecipe; if (mode === 'add') { addManualStampAtWorld(state, world, undefined, previewRecipeForWorker); } else { removeNearestManualStampAtWorld(state, world, previewRecipeForWorker); } } function resolveBrushDynamicSpacingScale( state: AppState, mode: 'add' | 'remove', terraformMode: TerraformMode ): number { let scale = state.interactionLoadScale; const renderMs = state.perf.lastRenderMs; const workerMs = state.perf.lastWorkerMs; if (renderMs >= 24) { scale *= 1.32; } else if (renderMs >= 18) { scale *= 1.16; } else if (renderMs > 0 && renderMs <= 8.5) { scale *= 0.96; } if (workerMs >= 34) { scale *= 1.24; } else if (workerMs >= 24) { scale *= 1.12; } else if (workerMs > 0 && workerMs <= 9) { scale *= 0.97; } if (state.previewInFlight || state.perf.previewQueueDepth > 0) { scale *= 1.08; } if ( mode === 'add' && (terraformMode === 'smooth' || terraformMode === 'erode' || terraformMode === 'sharpen') ) { scale *= 1.12; } const stampCount = state.recipe.manualStamps.length; if (stampCount >= 16000) { scale *= mode === 'remove' ? 1.26 : 1.16; } else if (stampCount >= 9000) { scale *= mode === 'remove' ? 1.18 : 1.1; } else if (stampCount >= 4500) { scale *= mode === 'remove' ? 1.1 : 1.05; } return Math.max(0.9, Math.min(BRUSH_DYNAMIC_SPACING_MAX_SCALE, scale)); } function resolvePointerSampleStride( state: AppState, mode: 'brush' | 'influence' ): number { let stride = 1; if (state.previewInFlight || state.perf.previewQueueDepth > 0) { stride += 1; } if (state.perf.lastRenderMs >= 18) { stride += 1; } if (state.perf.lastRenderMs >= 26 || state.perf.lastWorkerMs >= 34) { stride += 1; } if (state.perf.lastWorkerMs >= 24) { stride += 1; } if (mode === 'brush' && state.editSpeedMode === 'turbo') { stride += 1; } if (state.interactionLoadScale >= 1.2) { stride += 1; } if (state.interactionLoadScale >= 1.45) { stride += 1; } return Math.max(1, Math.min(POINTER_SAMPLE_STRIDE_MAX, stride)); } function resolvePointerSampleBudget(state: AppState, mode: 'brush' | 'influence'): number { const load = state.interactionLoadScale; if (mode === 'brush') { let base = state.editSpeedMode === 'turbo' ? 8 : state.editSpeedMode === 'balanced' ? 10 : 12; if (load >= 1.5) base -= 3; else if (load >= 1.3) base -= 2; else if (load >= 1.15) base -= 1; return Math.max(5, base); } let base = state.editSpeedMode === 'turbo' ? 6 : state.editSpeedMode === 'balanced' ? 8 : 10; if (load >= 1.5) base -= 3; else if (load >= 1.3) base -= 2; else if (load >= 1.15) base -= 1; return Math.max(4, base); } function processPointerMoveSamples( state: AppState, event: PointerEvent, mode: 'brush' | 'influence' ): void { const coalesced = typeof event.getCoalescedEvents === 'function' ? event.getCoalescedEvents() : []; const dispatch = (sample: PointerEvent): void => { if (mode === 'brush') { continueBrushStroke(state, sample); return; } continueInfluencePaintStroke(state, sample); }; if (coalesced.length <= 0) { dispatch(event); return; } const stride = resolvePointerSampleStride(state, mode); const budget = resolvePointerSampleBudget(state, mode); const startIndex = Math.max(0, stride - 1); const lastIndex = coalesced.length - 1; let processed = 0; let lastProcessedIndex = -1; for (let i = startIndex; i < coalesced.length; i += stride) { dispatch(coalesced[i]); lastProcessedIndex = i; processed += 1; if (processed >= budget) { break; } } if (lastProcessedIndex !== lastIndex) { dispatch(coalesced[lastIndex]); } } function continueBrushStroke(state: AppState, event: PointerEvent): void { const stroke = state.brushStroke; if (!stroke || stroke.pointerId !== event.pointerId) return; const settings = stroke.settings; const nextWorld = canvasClientToWorld(state, event.clientX, event.clientY); const dx = nextWorld.x - stroke.lastWorld.x; const dz = nextWorld.z - stroke.lastWorld.z; const distance = Math.hypot(dx, dz); const radius = settings.radius; const spacingScale = state.editSpeedMode === 'turbo' ? 1.2 : state.editSpeedMode === 'balanced' ? 1.05 : 1; const userSpacing = settings.userSpacing; const spacing = (stroke.mode === 'add' ? Math.max(30, Math.min(320, radius * 0.18 * spacingScale)) : Math.max(45, Math.min(360, radius * 0.26 * spacingScale))) * userSpacing; const terraformMode = settings.terraformMode; const dynamicSpacingScale = resolveBrushDynamicSpacingScale(state, stroke.mode, terraformMode); const effectiveSpacing = spacing * dynamicSpacingScale; if (distance < effectiveSpacing) return; const steps = Math.min(BRUSH_MAX_STEPS_PER_POINTER_MOVE, Math.max(1, Math.floor(distance / effectiveSpacing))); const direction = Math.atan2(dz, dx); const previewRecipeForWorker = stroke.workerPreviewRecipe; for (let step = 1; step <= steps; step += 1) { const t = step / steps; const world = { x: stroke.lastWorld.x + dx * t, z: stroke.lastWorld.z + dz * t }; if (stroke.mode === 'add') { addManualStampAtWorld(state, world, direction, previewRecipeForWorker); } else { removeNearestManualStampAtWorld(state, world, previewRecipeForWorker); } } stroke.lastWorld = nextWorld; } function endBrushStroke(state: AppState, pointerId: number): void { const stroke = state.brushStroke; if (!stroke || stroke.pointerId !== pointerId) return; const terraformMode = stroke.settings.terraformMode; state.brushStroke = null; commitHistoryTransaction(state); setStatusThrottled(state, terraformMode === 'off' ? 'Brush stroke complete.' : `Terraform ${terraformMode} stroke complete.`); if (stroke.didMutate && state.worker) { scheduleFullRegenerate(state, 1250); } if (state.editSpeedMode !== 'quality') { schedulePreviewRegenerate(state, 0, { interactive: false }); } } function importBundle(state: AppState, json: string): void { const bundle = deserializeStrategicBundle(json); bundle.recipe = ensureFeatureCollections(bundle.recipe); state.bundle = bundle; state.fullBundle = bundle; state.workerHeatmap = null; state.workerContour = null; state.generationMode = 'imported'; state.recipe = bundle.recipe; clearManualStampSpatialIndex(state); syncTerrainImportStateFromRecipe(state, state.recipe); state.historyPast = []; state.historyFuture = []; state.historyPendingBefore = null; applyRecipeToControls(state); updateControlReadouts(); syncToolbarStates(); syncToolModeUi(state); refreshFeatureEditor(state); updateHistoryControls(state); recomputeStats(state); upsertLayerFromBundleHeight(state, bundle); refreshLayerSelector(state); if (state.overlayAutoRange) { syncLayerRangeFromActive(state); } updateDataPanel(state); updateObjectiveReadout(state); requestRender(state); saveLatest(state, { persistBundle: true }); publishRealtimeRough(state, bundle, 'imported'); if (state.worker) { const previewRecipe = buildPreviewRecipeForState(state, state.recipe); postWorkerRebuild(state, previewRecipe); } setStatus(`Imported bundle: ${bundle.hash}`); } function getBestExportBundle(state: AppState): StrategicMapBundle | null { const recipeSnapshot = buildPersistableRecipeSnapshot(state); let source: StrategicMapBundle | null = null; if (state.fullBundle && !recipesDiffer(state.fullBundle.recipe, recipeSnapshot)) { source = state.fullBundle; } else if (state.bundle && !recipesDiffer(state.bundle.recipe, recipeSnapshot)) { source = state.bundle; } else { source = state.bundle ?? state.fullBundle; } if (!source) return null; if (!recipesDiffer(source.recipe, recipeSnapshot)) return source; return { ...source, recipe: recipeSnapshot }; } async function startGameFromMapLab(state: AppState): Promise { const bundle = getBestExportBundle(state); if (!bundle) { setStatus('Generate or import a MapLab bundle before starting a game.'); return; } const launch: MapLabAutoStartRequest = { version: 1, source: 'maplab', createdAt: Date.now(), mapName: bundle.recipe.metadata?.name, sourceHash: bundle.hash, seed: bundle.recipe.seed, playerCount: bundle.recipe.players, biome: bundle.recipe.biome, mapSize: bundle.recipe.mapSizePreset, bundleTransport: 'localStorage' }; try { window.localStorage.setItem(STORAGE_KEYS.latestBundle, serializeStrategicBundle(bundle)); clearStoredLatestBundleRef(); state.latestBundlePersistHash = bundle.hash; state.latestBundlePersistTransport = 'localStorage'; flushRecipePersist(state); } catch (error) { const quotaExceeded = isQuotaExceededError(error); if (!quotaExceeded) { console.warn('[MapLab] Failed to persist latest bundle for launch:', error); setStatus('Failed to prepare MapLab launch payload.'); return; } try { const launchBundleKey = await writeLaunchBundleToIndexedDb(bundle); launch.bundleTransport = 'indexeddb'; launch.launchBundleKey = launchBundleKey; console.info('[MapLab] localStorage quota exceeded, using IndexedDB launch bundle transport.'); } catch (idbError) { console.warn('[MapLab] Failed to persist launch bundle to IndexedDB:', idbError); setStatus('Failed to store launch bundle (localStorage and IndexedDB unavailable).'); return; } } try { window.sessionStorage.setItem(STORAGE_KEYS.launchRequest, JSON.stringify(launch)); try { window.localStorage.setItem(STORAGE_KEYS.launchRequest, JSON.stringify(launch)); } catch { // localStorage may be full; sessionStorage already carries the launch request. } } catch (error) { console.warn('[MapLab] Failed to persist autostart request:', error); setStatus('Failed to create autostart request.'); return; } setStatus('Launching game with current MapLab terrain...'); window.location.assign('/game.html?maplabStart=1'); } function takeRetryRecipeSnapshot( state: AppState, fallbackRecipe: StrategicMapRecipe | null | undefined ): StrategicMapRecipe | null { const source = state.queuedFullWorkerRecipe ?? state.latestFullWorkerRecipe ?? fallbackRecipe ?? null; state.queuedFullWorkerRecipe = null; state.latestFullWorkerRecipe = null; return source ? cloneRecipeForWorker(source) : null; } function installMapLabWorkerListeners(state: AppState): void { if (!state.worker) return; state.worker.addEventListener('message', (event: MessageEvent) => { const msg = event.data; if (msg.type === 'error') { if (msg.requestId === state.latestFullRequestId) { console.error('[MapLab Worker] Full generation failed:', msg.message); const retryRecipe = takeRetryRecipeSnapshot(state, state.recipe); recoverMapLabWorkerAndRetryFull(state, retryRecipe, 'error'); return; } if (msg.requestId === state.latestPreviewRequestId) { state.previewInFlight = false; state.previewWorkerPrimed = false; dispatchQueuedPreviewIfReady(state); } setStatus(`Generation failed: ${msg.message}`); console.error('[MapLab Worker] Generation failed:', msg.message); return; } if (msg.type === 'preview') { if (msg.requestId < state.latestPreviewRequestId) { return; } if (msg.requestId === state.latestPreviewRequestId) { state.previewInFlight = false; } state.previewWorkerPrimed = true; recordWorkerMessagePerf(state, msg); updateCaLayerStats(state, msg.caLayerStats); if (msg.heightPatch || (msg.heightPatches && msg.heightPatches.length > 0)) { applyPreviewPatchFromWorker(state, msg); dispatchQueuedPreviewIfReady(state); return; } if (!msg.bundle.heightDataBuffer && state.bundle) { applyPreviewPatchFromWorker(state, msg); dispatchQueuedPreviewIfReady(state); return; } applyWorkerHeatmapPatches(state, msg); applyWorkerContourPatches(state, msg); applyOverlayLayerPatchesFromWorker(state, msg); applyOverlayPrimitivesPatchFromWorker(state, msg); const bundle = transferToBundle(msg.bundle); applyGeneratedBundle(state, bundle, 'preview'); dispatchQueuedPreviewIfReady(state); return; } if (msg.type === 'full') { if (msg.requestId < state.latestFullRequestId) { return; } recordWorkerMessagePerf(state, msg); updateCaLayerStats(state, msg.caLayerStats); state.fullInFlight = false; clearFullWatchdog(state); state.latestFullWorkerRecipe = null; if (state.queuedFullWorkerRecipe) { dispatchQueuedFullIfReady(state); return; } applyOverlayLayerPatchesFromWorker(state, msg); applyOverlayPrimitivesPatchFromWorker(state, msg); const bundle = transferToBundle(msg.bundle); applyGeneratedBundle(state, bundle, 'full'); dispatchQueuedFullIfReady(state); } }); state.worker.addEventListener('error', (event) => { console.error('[MapLab Worker] Worker error:', event.message); const retryRecipe = takeRetryRecipeSnapshot(state, state.recipe); recoverMapLabWorkerAndRetryFull(state, retryRecipe, 'crash'); }); } function recoverMapLabWorkerAndRetryFull( state: AppState, recipe: StrategicMapRecipe | null, reason: string ): void { state.previewInFlight = false; state.previewWorkerPrimed = false; state.fullInFlight = false; clearFullWatchdog(state); state.queuedPreviewRecipe = null; state.queuedStampDeltas = []; state.perf.previewQueueDepth = 0; if (state.worker) { state.worker.terminate(); state.worker = null; } state.worker = createMapLabWorker(); if (state.worker) { installMapLabWorkerListeners(state); updatePerfPanel(state, true); if (recipe) { setStatus(`MapLab worker ${reason}; retrying full generation in background...`); dispatchWorkerFull(state, cloneRecipeForWorker(recipe)); } else { setStatus(`MapLab worker ${reason}; reinitialized.`); const previewRecipe = buildPreviewRecipeForState(state, state.recipe); postWorkerRebuild(state, previewRecipe); } return; } updatePerfPanel(state, true); if (recipe) { setStatus(`MapLab worker ${reason}; using fallback generation path.`); window.setTimeout(() => fallbackGenerateFullFromRecipe(state, cloneRecipeForWorker(recipe), `worker ${reason}`), 0); return; } setStatus(`MapLab worker ${reason}; no worker available.`); } function initialize(state: AppState): void { const handleFullscreenChange = () => { syncCanvasFullscreenUi(); requestRender(state); }; mountSidebarAsCanvasOverlay(); syncTerrainImportStateFromRecipe(state, state.recipe); applyRecipeToControls(state); populateTerrariumPresetOptions(); updateImportModeUi(); controls.editSpeed.value = state.editSpeedMode; installToolbarBindings(); updateControlReadouts(); refreshFeatureEditor(state); syncToolModeUi(state); updateHistoryControls(state); updateCacheStatsUi(state); updateProbeReadout(state); updateObjectiveReadout(state); setDockTab(state, state.dockTab); setSidebarDockOpen(state, state.sidebarDockOpen); syncCanvasFullscreenUi(); hydrateFromCacheIfAvailable(state); refreshLayerSelector(state); syncLayerRangeFromActive(state); void MapLabWebGpuOverlayRenderer.create(gpuCanvas) .then((renderer) => { state.overlayRenderer = renderer; state.overlayRendererReady = !!renderer; requestRender(state); }) .catch((error) => { console.warn('[MapLab] WebGPU overlay renderer unavailable:', error); state.overlayRenderer = null; state.overlayRendererReady = false; }); installBroadcastPresenceListener(state); probePreviewListener(state, true); updatePerfPanel(state, true); state.perfUiTimer = window.setInterval(() => updatePerfPanel(state, true), 1000); installMapLabWorkerListeners(state); renderCaLayerStats(state); state.importWorker?.addEventListener('message', (event: MessageEvent) => { handleTerrainImportWorkerMessage(state, event.data); }); state.importWorker?.addEventListener('error', (event) => { console.error('[MapLab Import Worker] Worker error:', event.message); state.pendingImportRequestId = 0; state.pendingImportContext = null; setStatus('Terrain import worker failed.'); }); const commonInputs: Array = [ controls.seed, controls.players, controls.symmetry, controls.biome, controls.sizePreset, controls.resolution, controls.competitive, controls.caEnabled, controls.caBaseResolution, controls.caSubdivision ]; for (const input of commonInputs) { input.addEventListener('input', () => schedulePreviewRegenerate(state, 0)); input.addEventListener('change', () => scheduleInteractiveRegenerate(state, 0, 1200)); } if (quickControls.caMiniEnable) { quickControls.caMiniEnable.addEventListener('click', () => { controls.caEnabled.value = controls.caEnabled.value === '1' ? '0' : '1'; controls.caEnabled.dispatchEvent(new Event('change', { bubbles: true })); }); } if (quickControls.caMiniBaseResolution) { quickControls.caMiniBaseResolution.addEventListener('change', () => { controls.caBaseResolution.value = quickControls.caMiniBaseResolution!.value; controls.caBaseResolution.dispatchEvent(new Event('change', { bubbles: true })); }); } if (quickControls.caMiniSubdivision) { quickControls.caMiniSubdivision.addEventListener('change', () => { controls.caSubdivision.value = quickControls.caMiniSubdivision!.value; controls.caSubdivision.dispatchEvent(new Event('change', { bubbles: true })); }); } if (quickControls.caMiniLayerPreset) { quickControls.caMiniLayerPreset.addEventListener('change', () => { controls.caLayerPreset.value = quickControls.caMiniLayerPreset!.value; controls.caLayerPreset.dispatchEvent(new Event('change', { bubbles: true })); }); } for (const target of [ controls.caEnabled, controls.caBaseResolution, controls.caSubdivision, controls.caLayerPreset ]) { target.addEventListener('change', syncCaMiniControls); } syncCaMiniControls(); controls.editSpeed.addEventListener('change', () => { state.editSpeedMode = asEditSpeedMode(controls.editSpeed.value); requestRender(state); schedulePreviewRegenerate(state, 0, { interactive: false }); }); controls.mouseAssist.addEventListener('change', () => { syncToolModeUi(state); setStatus(getMouseAssistStatus(asMouseAssistMode(controls.mouseAssist.value))); }); controls.sizePreset.addEventListener('change', () => { const preset = controls.sizePreset.value as MapSizePreset; controls.resolution.value = String(MAP_SIZE_PRESET_RESOLUTION[preset]); scheduleInteractiveRegenerate(state, 0, 1300); }); controls.caLayerPreset.addEventListener('change', () => { const preset = asCaLayerPresetId(controls.caLayerPreset.value); applyCaPresetSelectionToRecipe(state, preset); if (preset === 'custom') { setStatus('CA layer preset: custom.'); } else if (preset === 'paper-default') { setStatus('CA layer preset: paper default.'); } else if (preset === 'continental') { setStatus('CA layer preset: continental.'); } else { setStatus('CA layer preset: fragmented.'); } scheduleInteractiveRegenerate(state, 0, 1200); }); controls.caLayerSelect.addEventListener('change', () => { refreshCaLayerEditor(state); }); controls.caLayerEnabled.addEventListener('change', () => { applyCaEditorMutation(state, 'CA layer enabled state updated.', (config) => { const layers = config.layers ?? []; if (layers.length <= 0) return; const layerIndex = parseEditorIndex(controls.caLayerSelect.value, layers.length); layers[layerIndex].enabled = controls.caLayerEnabled.value !== '0'; }); }); controls.caLayerSeedDensity.addEventListener('change', () => { applyCaEditorMutation(state, 'CA layer seed density updated.', (config) => { const layers = config.layers ?? []; if (layers.length <= 0) return; const layerIndex = parseEditorIndex(controls.caLayerSelect.value, layers.length); layers[layerIndex].seedDensity = clampCaNumber(Number.parseFloat(controls.caLayerSeedDensity.value), 0.01, 0.99); }); }); controls.caLayerBaseHeightDelta.addEventListener('change', () => { applyCaEditorMutation(state, 'CA layer base height delta updated.', (config) => { const layers = config.layers ?? []; if (layers.length <= 0) return; const layerIndex = parseEditorIndex(controls.caLayerSelect.value, layers.length); layers[layerIndex].baseHeightDelta = clampCaNumber(Number.parseFloat(controls.caLayerBaseHeightDelta.value), -256, 256); }); }); controls.caLayerSubdivision.addEventListener('change', () => { applyCaEditorMutation(state, 'CA layer subdivision updated.', (config) => { const layers = config.layers ?? []; if (layers.length <= 0) return; const layerIndex = parseEditorIndex(controls.caLayerSelect.value, layers.length); layers[layerIndex].subdivisionSteps = Math.floor(clampCaNumber(Number.parseFloat(controls.caLayerSubdivision.value), 0, 6)); }); }); controls.caRuleSelect.addEventListener('change', () => { refreshCaLayerEditor(state); }); controls.caRuleAdd.addEventListener('click', () => { applyCaEditorMutation(state, 'CA rule added.', (config) => { const layers = config.layers ?? []; if (layers.length <= 0) return; const layerIndex = parseEditorIndex(controls.caLayerSelect.value, layers.length); const layer = layers[layerIndex]; const rules = Array.isArray(layer.rules) ? layer.rules : []; rules.push(normalizeCaRuleForEditor(undefined)); layer.rules = rules; controls.caRuleSelect.value = String(rules.length - 1); }); }); controls.caRuleRemove.addEventListener('click', () => { applyCaEditorMutation(state, 'CA rule removed.', (config) => { const layers = config.layers ?? []; if (layers.length <= 0) return; const layerIndex = parseEditorIndex(controls.caLayerSelect.value, layers.length); const layer = layers[layerIndex]; const rules = Array.isArray(layer.rules) ? layer.rules : []; if (rules.length <= 1) { layer.rules = [normalizeCaRuleForEditor(undefined)]; controls.caRuleSelect.value = '0'; return; } const ruleIndex = parseEditorIndex(controls.caRuleSelect.value, rules.length); rules.splice(ruleIndex, 1); layer.rules = rules; controls.caRuleSelect.value = String(Math.max(0, Math.min(rules.length - 1, ruleIndex))); }); }); const caRuleFieldBindings: Array<{ input: HTMLInputElement; apply: (rule: RecipeCaRule, value: number) => void; min: number; max: number; integer?: boolean; message: string; }> = [ { input: controls.caRuleIterations, apply: (rule, value) => { rule.iterations = Math.floor(value); }, min: 1, max: 12, integer: true, message: 'CA rule iterations updated.' }, { input: controls.caRuleBirthMin, apply: (rule, value) => { rule.birthMin = Math.floor(value); }, min: 0, max: 8, integer: true, message: 'CA rule birth range updated.' }, { input: controls.caRuleBirthMax, apply: (rule, value) => { rule.birthMax = Math.floor(value); }, min: 0, max: 8, integer: true, message: 'CA rule birth range updated.' }, { input: controls.caRuleSurviveMin, apply: (rule, value) => { rule.surviveMin = Math.floor(value); }, min: 0, max: 8, integer: true, message: 'CA rule survival range updated.' }, { input: controls.caRuleSurviveMax, apply: (rule, value) => { rule.surviveMax = Math.floor(value); }, min: 0, max: 8, integer: true, message: 'CA rule survival range updated.' }, { input: controls.caRuleRandomBirth, apply: (rule, value) => { rule.randomBirthChance = value; }, min: 0, max: 1, message: 'CA rule randomness updated.' }, { input: controls.caRuleRandomKill, apply: (rule, value) => { rule.randomKillChance = value; }, min: 0, max: 1, message: 'CA rule randomness updated.' } ]; for (const binding of caRuleFieldBindings) { binding.input.addEventListener('change', () => { applyCaEditorMutation(state, binding.message, (config) => { const layers = config.layers ?? []; if (layers.length <= 0) return; const layerIndex = parseEditorIndex(controls.caLayerSelect.value, layers.length); const layer = layers[layerIndex]; const rules = Array.isArray(layer.rules) && layer.rules.length > 0 ? layer.rules : [normalizeCaRuleForEditor(undefined)]; layer.rules = rules; const ruleIndex = parseEditorIndex(controls.caRuleSelect.value, rules.length); const rule = normalizeCaRuleForEditor(rules[ruleIndex]); rules[ruleIndex] = rule; const parsed = Number.parseFloat(binding.input.value); const value = binding.integer === true ? Math.floor(clampCaNumber(parsed, binding.min, binding.max)) : clampCaNumber(parsed, binding.min, binding.max); binding.apply(rule, value); rules[ruleIndex] = normalizeCaRuleForEditor(rule); }); }); } controls.caPostEnabled.addEventListener('change', () => { applyCaEditorMutation(state, 'CA postprocess toggled.', (config) => { const postprocess = normalizeCaPostprocessForEditor(config.postprocess); postprocess.enabled = controls.caPostEnabled.value === '1'; config.postprocess = postprocess; }); }); controls.caPostIterations.addEventListener('change', () => { applyCaEditorMutation(state, 'CA postprocess iterations updated.', (config) => { const postprocess = normalizeCaPostprocessForEditor(config.postprocess); postprocess.smoothingIterations = Math.floor(clampCaNumber(Number.parseFloat(controls.caPostIterations.value), 0, 8)); config.postprocess = postprocess; }); }); controls.caPostStrength.addEventListener('change', () => { applyCaEditorMutation(state, 'CA postprocess strength updated.', (config) => { const postprocess = normalizeCaPostprocessForEditor(config.postprocess); postprocess.smoothingStrength = clampCaNumber(Number.parseFloat(controls.caPostStrength.value), 0, 1); config.postprocess = postprocess; }); }); controls.caPostPreserveMasks.addEventListener('change', () => { applyCaEditorMutation(state, 'CA postprocess gameplay mask clamp updated.', (config) => { const postprocess = normalizeCaPostprocessForEditor(config.postprocess); postprocess.preserveGameplayMasks = controls.caPostPreserveMasks.value !== '0'; config.postprocess = postprocess; }); }); controls.caPostMaxDrift.addEventListener('change', () => { applyCaEditorMutation(state, 'CA postprocess critical drift clamp updated.', (config) => { const postprocess = normalizeCaPostprocessForEditor(config.postprocess); postprocess.criticalMaskMaxDrift = clampCaNumber(Number.parseFloat(controls.caPostMaxDrift.value), 0, 64); config.postprocess = postprocess; }); }); controls.caDetailEnabled.addEventListener('change', () => { applyCaEditorMutation(state, 'CA detail recovery toggled.', (config) => { const postprocess = normalizeCaPostprocessForEditor(config.postprocess); postprocess.detailRecoveryEnabled = controls.caDetailEnabled.value === '1'; config.postprocess = postprocess; }); }); controls.caDetailTreeDensity.addEventListener('change', () => { applyCaEditorMutation(state, 'CA detail tree density updated.', (config) => { const postprocess = normalizeCaPostprocessForEditor(config.postprocess); postprocess.detailTreeDensity = clampCaNumber(Number.parseFloat(controls.caDetailTreeDensity.value), 0, 2); config.postprocess = postprocess; }); }); controls.caDetailRockDensity.addEventListener('change', () => { applyCaEditorMutation(state, 'CA detail rock density updated.', (config) => { const postprocess = normalizeCaPostprocessForEditor(config.postprocess); postprocess.detailRockDensity = clampCaNumber(Number.parseFloat(controls.caDetailRockDensity.value), 0, 2); config.postprocess = postprocess; }); }); controls.caDetailMaterialVariation.addEventListener('change', () => { applyCaEditorMutation(state, 'CA detail material variation updated.', (config) => { const postprocess = normalizeCaPostprocessForEditor(config.postprocess); postprocess.detailMaterialVariation = clampCaNumber(Number.parseFloat(controls.caDetailMaterialVariation.value), 0, 2); config.postprocess = postprocess; }); }); controls.markerGuidanceEnabled.addEventListener('change', () => { applyMarkerGuidanceMutation(state, 'Marker guidance toggled.', (config) => { config.enabled = controls.markerGuidanceEnabled.value !== '0'; }); }); controls.markerSpawnMinDistance.addEventListener('change', () => { applyMarkerGuidanceMutation(state, 'Marker spawn distance updated.', (config) => { config.spawnMinDistanceMeters = clampCaNumber( Number.parseFloat(controls.markerSpawnMinDistance.value), 200, Math.max(200, state.recipe.mapSizeMeters * 0.62) ); }); }); controls.markerSpawnMaxSlope.addEventListener('change', () => { applyMarkerGuidanceMutation(state, 'Marker spawn slope threshold updated.', (config) => { config.spawnMaxSlope = clampCaNumber(Number.parseFloat(controls.markerSpawnMaxSlope.value), 0.02, 1); }); }); controls.markerSpawnMinBuildable.addEventListener('change', () => { applyMarkerGuidanceMutation(state, 'Marker spawn buildable coverage updated.', (config) => { config.spawnMinBuildableCoverage = clampCaNumber(Number.parseFloat(controls.markerSpawnMinBuildable.value), 0, 1); }); }); controls.markerProbeRadius.addEventListener('change', () => { applyMarkerGuidanceMutation(state, 'Marker buildable probe radius updated.', (config) => { config.spawnBuildableProbeRadiusMeters = clampCaNumber( Number.parseFloat(controls.markerProbeRadius.value), 60, Math.max(60, state.recipe.mapSizeMeters * 0.2) ); }); }); controls.markerResourceMinDistance.addEventListener('change', () => { applyMarkerGuidanceMutation(state, 'Marker resource distance updated.', (config) => { config.resourceMinDistanceMeters = clampCaNumber( Number.parseFloat(controls.markerResourceMinDistance.value), 60, Math.max(60, state.recipe.mapSizeMeters * 0.3) ); }); }); controls.markerResourceSpawnSeparation.addEventListener('change', () => { applyMarkerGuidanceMutation(state, 'Marker resource-vs-spawn separation updated.', (config) => { config.resourceSpawnSeparationFactor = clampCaNumber( Number.parseFloat(controls.markerResourceSpawnSeparation.value), 0.1, 1.2 ); }); }); controls.markerCompetitiveSymmetryLock.addEventListener('change', () => { applyMarkerGuidanceMutation(state, 'Marker competitive symmetry lock updated.', (config) => { config.competitiveSymmetryLock = controls.markerCompetitiveSymmetryLock.value !== '0'; }); }); controls.importMode.addEventListener('change', () => { updateImportModeUi(); }); for (const button of controls.dockTabButtons) { button.addEventListener('click', () => { const tab = asDockTabId(button.dataset.dockTab); ensureSidebarDockOpen(state); setDockTab(state, tab); }); } document.addEventListener('fullscreenchange', handleFullscreenChange); document.addEventListener('webkitfullscreenchange', handleFullscreenChange as EventListener); controls.canvasUiToggle.addEventListener('click', () => { toggleSidebarDock(state); }); controls.canvasFullscreen.addEventListener('click', () => { void toggleCanvasShellFullscreen(state); }); controls.canvasPanelImport.addEventListener('click', () => { toggleImportPanelPin(state); }); controls.canvasPanelLayer.addEventListener('click', () => { toggleLayerPanelPin(state); }); controls.canvasPanelOverlays.addEventListener('click', () => { toggleOverlaysPanelPin(state); }); controls.canvasOverlayHeat.addEventListener('click', () => { if (OVERLAYS.heat.checked) { state.sidebarPanels.layerPinned = true; syncSidebarPanelsVisibility(state); } }); controls.canvasOverlayFeatures.addEventListener('click', () => { if (OVERLAYS.features.checked && asShapeMode(controls.shapeMode.value) === 'none') { controls.shapeMode.value = 'edit'; emitControlEvents(controls.shapeMode); } }); controls.canvasOverlayContours.addEventListener('click', () => { if (OVERLAYS.contours.checked) { state.sidebarPanels.overlaysPinned = true; syncSidebarPanelsVisibility(state); } }); controls.canvasPresetSculpt.addEventListener('click', () => applyWorkflowPreset(state, 'sculpt')); controls.canvasPresetCarve.addEventListener('click', () => applyWorkflowPreset(state, 'carve')); controls.canvasPresetRiver.addEventListener('click', () => applyWorkflowPreset(state, 'river')); controls.canvasPresetEdit.addEventListener('click', () => applyWorkflowPreset(state, 'edit')); controls.canvasShortcuts.addEventListener('click', () => showShortcutsDialog()); controls.shortcutsClose?.addEventListener('click', () => closeShortcutsDialog()); controls.shortcutsDialog?.addEventListener('click', (event) => { const dialog = controls.shortcutsDialog; if (!dialog) return; const rect = dialog.getBoundingClientRect(); const inside = ( event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom ); if (!inside) { closeShortcutsDialog(); } }); controls.terrariumPreset.addEventListener('change', () => { const presetId = controls.terrariumPreset.value; if (presetId === 'custom') { setStatus('Terrarium preset: custom coordinates.'); return; } if (applyTerrariumPresetById(presetId)) { const label = controls.terrariumPreset.options[controls.terrariumPreset.selectedIndex]?.textContent ?? presetId; setStatus(`Terrarium preset loaded: ${label}`); } }); controls.importUploadRun.addEventListener('click', () => { void runUploadTerrainImport(state).catch((error) => { console.error('[MapLab] Upload terrain import failed:', error); state.pendingImportRequestId = 0; state.pendingImportContext = null; setStatus(`Upload import failed: ${error instanceof Error ? error.message : String(error)}`); }); }); controls.importTerrariumRun.addEventListener('click', () => { void runTerrariumTerrainImport(state).catch((error) => { console.error('[MapLab] Terrarium import failed:', error); state.pendingImportRequestId = 0; state.pendingImportContext = null; setStatus(`Terrarium import failed: ${error instanceof Error ? error.message : String(error)}`); }); }); controls.clearBaseLayer.addEventListener('click', () => { clearImportedBaseLayer(state); }); controls.influenceMode.addEventListener('change', () => { syncToolModeUi(state); syncSidebarPanelsVisibility(state); if (asInfluenceMode(controls.influenceMode.value) !== 'none') { setDockTab(state, 'edit'); ensureSidebarDockOpen(state); } syncSelectToolbar(controls.influenceMode); requestRender(state); }); controls.influenceRadius.addEventListener('input', () => { updateControlReadouts(); requestRender(state); }); controls.influenceStrength.addEventListener('input', () => { updateControlReadouts(); requestRender(state); }); controls.influenceClearLayer.addEventListener('click', () => { const mode = asInfluenceMode(controls.influenceMode.value); const layerFromMode = influenceModeToLayerId(mode); const target = layerFromMode && layerFromMode !== 'constraintLock' ? layerFromMode : ( state.activeLayerId === 'intentMainLane' || state.activeLayerId === 'intentSafeExpansion' || state.activeLayerId === 'intentNoBuild' ? state.activeLayerId : null ); if (!target) { setStatus('Select an influence mode/layer to clear.'); return; } if (!clearInfluenceLayer(state, target as Exclude)) { setStatus('No influence layer data to clear.'); return; } setStatus(`Cleared influence layer: ${target}`); }); controls.influenceClearLocks.addEventListener('click', () => { if (!clearConstraintLocks(state)) { setStatus('No lock data to clear.'); return; } setStatus('Cleared all constraint locks.'); }); controls.overlayStudioLayer.addEventListener('change', () => { const layerId = controls.overlayStudioLayer.value || 'wetness'; controls.overlayStudioAuthority.value = resolveOverlayLayerAuthority(state, layerId); syncOverlayStudioReadout(state); }); controls.overlayStudioAuthority.addEventListener('change', () => { const layerId = controls.overlayStudioLayer.value || 'wetness'; const authority: StrategicOverlayAuthority = controls.overlayStudioAuthority.value === 'gameplay' ? 'gameplay' : 'visual'; const overlays = ensureOverlayBundle(state.recipe); overlays.authority![layerId] = authority; syncOverlayRecipeToBundles(state); syncOverlayStudioReadout(state); saveLatest(state, { persistBundle: false }); }); controls.overlayStudioTool.addEventListener('change', () => { syncOverlayStudioReadout(state); }); controls.overlayStudioAddSelected.addEventListener('click', () => { addOverlayPrimitiveFromSelectedFeature(state); }); controls.overlayStudioAddWaypoint.addEventListener('click', () => { addOverlayWaypointAtCursor(state); }); controls.overlayStudioClearLayer.addEventListener('click', () => { const removed = clearOverlayPrimitivesForActiveLayer(state); if (removed <= 0) { setStatus('No overlay primitives found for selected layer.'); return; } setStatus(`Cleared ${removed} overlay primitive(s) from ${controls.overlayStudioLayer.value || 'layer'}.`); }); controls.layerSearch.addEventListener('input', () => { refreshLayerSelector(state); if (state.overlayAutoRange) { syncLayerRangeFromActive(state); } requestRender(state); }); controls.layerPreset.addEventListener('change', () => { applyLayerPreset(state, controls.layerPreset.value); }); controls.layerActive.addEventListener('change', () => { state.activeLayerId = controls.layerActive.value; if (state.overlayAutoRange) { syncLayerRangeFromActive(state); } requestRender(state); }); controls.layerColormap.addEventListener('change', () => { state.overlayColormap = toOverlayColormapId(controls.layerColormap.value); requestRender(state); }); controls.layerAlpha.addEventListener('input', () => { const value = Number.parseFloat(controls.layerAlpha.value); state.overlayAlpha = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : state.overlayAlpha; requestRender(state); }); controls.layerMin.addEventListener('change', () => { const value = Number.parseFloat(controls.layerMin.value); if (!Number.isFinite(value)) return; state.overlayRangeMin = value; state.overlayAutoRange = false; requestRender(state); }); controls.layerMax.addEventListener('change', () => { const value = Number.parseFloat(controls.layerMax.value); if (!Number.isFinite(value)) return; state.overlayRangeMax = value; state.overlayAutoRange = false; requestRender(state); }); controls.layerAutoRange.addEventListener('click', () => { state.overlayAutoRange = true; syncLayerRangeFromActive(state); requestRender(state); }); Object.values(OVERLAYS).forEach((toggle) => { toggle.addEventListener('change', () => { if (toggle === OVERLAYS.contours) { if (state.turboContoursSuppressed) { state.contourRestoreEnabled = true; OVERLAYS.contours.checked = false; syncToolbarStates(); return; } state.contourRestoreEnabled = OVERLAYS.contours.checked; } if (toggle === OVERLAYS.heat && OVERLAYS.heat.checked) { syncSidebarPanelsVisibility(state); schedulePreviewRegenerate(state, 0, { interactive: false }); return; } if (toggle === OVERLAYS.contours && OVERLAYS.contours.checked) { syncSidebarPanelsVisibility(state); schedulePreviewRegenerate(state, 0, { interactive: false }); return; } if (toggle === OVERLAYS.contours) { state.workerContour = null; } syncSidebarPanelsVisibility(state); requestRender(state); }); }); controls.randomSeed.addEventListener('click', () => { controls.seed.value = String(Math.floor(Math.random() * 1_000_000)); scheduleInteractiveRegenerate(state, 0, 1400); }); controls.clearStamps.addEventListener('click', () => { beginHistoryTransaction(state); state.recipe.manualStamps = []; clearManualStampSpatialIndex(state); commitHistoryTransaction(state); scheduleInteractiveRegenerate(state, 0, 1200); }); controls.shapeMode.addEventListener('change', () => { state.brushStroke = null; state.influencePaintStroke = null; state.geometryEditDrag = null; state.propertyHandleDrag = null; state.splineGizmoDrag = null; const mode = asShapeMode(controls.shapeMode.value); if (mode === 'none') { state.shapeDraft = null; setStatus('Brush mode active.'); } else if (mode === 'edit') { state.shapeDraft = null; setStatus('Geometry edit mode active.'); } else { beginShapeDraft(state); } syncToolModeUi(state); requestRender(state); }); controls.brushType.addEventListener('change', () => { if (asShapeMode(controls.shapeMode.value) !== 'none') { controls.shapeMode.value = 'none'; emitControlEvents(controls.shapeMode); syncSelectToolbar(controls.shapeMode); } }); controls.brushSymmetry.addEventListener('change', () => { syncSelectToolbar(controls.brushSymmetry); updateControlReadouts(); requestRender(state); setStatus(`Brush symmetry: ${asBrushSymmetryMode(controls.brushSymmetry.value)}.`); }); controls.terraformMode.addEventListener('change', () => { if (asShapeMode(controls.shapeMode.value) !== 'none') { controls.shapeMode.value = 'none'; emitControlEvents(controls.shapeMode); syncSelectToolbar(controls.shapeMode); } const terraformMode = asTerraformMode(controls.terraformMode.value); syncSelectToolbar(controls.terraformMode); syncToolModeUi(state); updateControlReadouts(); requestRender(state); if (terraformMode !== 'off') { setStatus(`Terraform mode: ${terraformMode}.`); } else { setStatus('Terraform mode disabled.'); } }); controls.shapeWidth.addEventListener('input', () => handleShapeGeometryInput(state)); controls.shapeIntensity.addEventListener('input', () => handleShapeGeometryInput(state)); controls.shapeFalloff.addEventListener('input', () => handleShapeGeometryInput(state)); controls.shapePointWeight.addEventListener('input', () => handleShapeGeometryInput(state, { applyPointWeights: true })); controls.shapeWidth.addEventListener('change', () => handleShapeGeometryInput(state, { commit: true })); controls.shapeIntensity.addEventListener('change', () => handleShapeGeometryInput(state, { commit: true })); controls.shapeFalloff.addEventListener('change', () => handleShapeGeometryInput(state, { commit: true })); controls.shapePointWeight.addEventListener('change', () => { handleShapeGeometryInput(state, { applyPointWeights: true, commit: true }); }); controls.brushRadius.addEventListener('input', () => { updateControlReadouts(); requestRender(state); }); controls.brushIntensity.addEventListener('input', () => { updateControlReadouts(); requestRender(state); }); controls.brushLength.addEventListener('input', () => { updateControlReadouts(); requestRender(state); }); controls.brushWidth.addEventListener('input', () => { updateControlReadouts(); requestRender(state); }); controls.brushFalloff.addEventListener('input', () => { updateControlReadouts(); requestRender(state); }); controls.brushOrientation.addEventListener('input', () => { updateControlReadouts(); requestRender(state); }); controls.brushSpacing.addEventListener('input', () => { updateControlReadouts(); requestRender(state); }); controls.brushJitter.addEventListener('input', () => { updateControlReadouts(); requestRender(state); }); controls.terraformStrength.addEventListener('input', () => { updateControlReadouts(); requestRender(state); }); controls.terraformTargetHeight.addEventListener('change', () => { const parsed = Number.parseFloat(controls.terraformTargetHeight.value); controls.terraformTargetHeight.value = (Number.isFinite(parsed) ? parsed : 0).toFixed(2); requestRender(state); }); controls.terraformCaptureHeight.addEventListener('click', () => { if (!captureTerraformTargetHeightFromProbe(state)) { setStatus('No terrain available to capture terraform target height.'); return; } setStatus(`Terraform target set to ${controls.terraformTargetHeight.value}m from cursor.`); }); controls.shapeStart.addEventListener('click', () => beginShapeDraft(state)); controls.shapeFinish.addEventListener('click', () => finishShapeDraft(state)); controls.shapeCancel.addEventListener('click', () => cancelShapeDraft(state)); controls.shapeClear.addEventListener('click', () => clearFeaturesBySelectedMode(state)); controls.featureSelect.addEventListener('change', () => { const raw = controls.featureSelect.value; const [kind, id] = raw.split(':'); setSelectedFeatureRef(state, kind && id ? { kind: kind as 'area' | 'spline', id } : null); loadSelectedFeatureToEditor(state); requestRender(state); }); const profileInputs: HTMLInputElement[] = [ controls.featureEdge, controls.featureSharpness, controls.featureBank, controls.featureTarget, controls.featureTerraceSteps, controls.featureTerraceStrength ]; controls.featureProfile.addEventListener('change', () => applyFeatureProfileInput(state)); for (const input of profileInputs) { input.addEventListener('input', () => applyFeatureProfileInput(state)); } controls.featureApplyWeights.addEventListener('click', () => { applyFeatureProfileInput(state, { applyWeights: true }); }); controls.featureDelete.addEventListener('click', () => deleteSelectedFeature(state)); controls.regenerate.addEventListener('click', () => { clearScheduledRegenerations(state); regenerate(state, 'full'); }); controls.startGame.addEventListener('click', () => { void startGameFromMapLab(state); }); controls.undo.addEventListener('click', () => { undoHistory(state); }); controls.redo.addEventListener('click', () => { redoHistory(state); }); controls.saveSlot.addEventListener('click', async () => { const bundle = getBestExportBundle(state); if (!bundle) { setStatus('Generate a bundle first.'); return; } const slot = controls.slot.value; const serialized = serializeStrategicBundle(bundle); try { window.localStorage.setItem(STORAGE_KEYS.slot(slot), serialized); clearStoredSlotRef(slot); setStatus(`Saved slot ${slot}`); return; } catch (error) { if (!isQuotaExceededError(error)) { console.warn('[MapLab] Failed to save slot to localStorage:', error); setStatus(`Failed to save slot ${slot}.`); return; } } try { const key = getSlotIndexedDbKey(slot); await writeMapLabBundleToIndexedDb(key, bundle); try { writeStoredSlotRef(slot, { version: 1, transport: 'indexeddb', key, createdAt: Date.now(), hash: bundle.hash }); } catch { // Non-fatal: slot can still be loaded when localStorage payload exists. } try { window.localStorage.removeItem(STORAGE_KEYS.slot(slot)); } catch { // Ignore cleanup failure. } setStatus(`Saved slot ${slot} (IndexedDB fallback).`); } catch (error) { console.warn('[MapLab] Failed to save slot via IndexedDB fallback:', error); setStatus(`Failed to save slot ${slot}: storage full.`); } }); controls.loadSlot.addEventListener('click', async () => { const slot = controls.slot.value; const raw = window.localStorage.getItem(STORAGE_KEYS.slot(slot)); if (raw) { try { importBundle(state, raw); return; } catch (error) { console.warn('[MapLab] Failed to import local slot payload, trying IndexedDB:', error); } } const ref = readStoredSlotRef(slot); const idbKey = ref?.transport === 'indexeddb' ? ref.key ?? getSlotIndexedDbKey(slot) : getSlotIndexedDbKey(slot); try { const bundle = await readMapLabBundleFromIndexedDb(idbKey); if (bundle) { importBundle(state, serializeStrategicBundle(bundle)); return; } } catch (error) { console.warn('[MapLab] Failed to load slot from IndexedDB:', error); } setStatus(`Slot ${slot} is empty.`); }); controls.exportJson.addEventListener('click', () => { const bundle = getBestExportBundle(state); if (!bundle) { setStatus('Generate a bundle before export.'); return; } const payload = serializeStrategicBundle(bundle); const blob = new Blob([payload], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `maplab-${bundle.hash}.json`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); setStatus('Exported JSON bundle.'); }); controls.exportRecipeJson.addEventListener('click', () => { exportMapLabRecipeDocument(state); }); controls.exportHeightRaw.addEventListener('click', () => { exportMapLabHeightRaw(state); }); controls.exportOverlayPng.addEventListener('click', () => { void exportMapLabOverlayPng(state).catch((error) => { console.error('[MapLab] Overlay PNG export failed:', error); setStatus(`Overlay export failed: ${error instanceof Error ? error.message : String(error)}`); }); }); controls.importJson.addEventListener('click', () => controls.importFile.click()); controls.importFile.addEventListener('change', async () => { const file = controls.importFile.files?.[0]; controls.importFile.value = ''; if (!file) return; const text = await file.text(); importBundle(state, text); }); controls.loadLatest.addEventListener('click', async () => { try { const bundle = await loadLatestBundleFromAnyStore(); if (!bundle) { setStatus('No latest bundle saved yet.'); return; } importBundle(state, serializeStrategicBundle(bundle)); } catch (error) { console.warn('[MapLab] Failed to load latest bundle:', error); setStatus('Failed to load latest bundle.'); } }); canvas.addEventListener('click', (event) => { const mode = asShapeMode(controls.shapeMode.value); if (mode !== 'none' && mode !== 'edit') { addShapePointFromCanvas(state, event.clientX, event.clientY); } }); canvas.addEventListener('dblclick', (event) => { const mode = asShapeMode(controls.shapeMode.value); if (mode !== 'edit') return; event.preventDefault(); const canvasPoint = canvasClientToCanvasCoords(event.clientX, event.clientY); if (!insertFeaturePointNearCanvas(state, canvasPoint.x, canvasPoint.y)) { setStatus('No nearby segment for point insert.'); } }); canvas.addEventListener('pointerdown', (event) => { const influenceMode = asInfluenceMode(controls.influenceMode.value); if (influenceMode !== 'none') { if (event.button !== 0 && event.button !== 2) return; event.preventDefault(); const strokeMode: 'add' | 'remove' = event.button === 2 ? 'remove' : 'add'; canvas.setPointerCapture(event.pointerId); beginInfluencePaintStroke(state, event, strokeMode); return; } const mode = asShapeMode(controls.shapeMode.value); const assistMode = asMouseAssistMode(controls.mouseAssist.value); if (mode === 'none') { if (event.button !== 0 && event.button !== 2) return; event.preventDefault(); const strokeMode: 'add' | 'remove' = event.button === 2 || (event.button === 0 && assistMode === 'carve') ? 'remove' : 'add'; canvas.setPointerCapture(event.pointerId); beginBrushStroke(state, event, strokeMode); return; } if (mode !== 'edit') return; if (event.button !== 0 && event.button !== 2) return; event.preventDefault(); const world = canvasClientToWorld(state, event.clientX, event.clientY); const canvasPoint = canvasClientToCanvasCoords(event.clientX, event.clientY); if (event.button === 2) { removeFeaturePointAtCanvas(state, canvasPoint.x, canvasPoint.y); return; } if (assistMode === 'delete') { if (!removeFeaturePointAtCanvas(state, canvasPoint.x, canvasPoint.y)) { setStatus('Mouse assist delete: no editable point under cursor.'); } return; } const splineGizmo = pickSelectedSplinePointGizmo( state, canvasPoint.x, canvasPoint.y, GEOMETRY_PICK_SPLINE_GIZMO_RADIUS_PX ); if (splineGizmo) { canvas.setPointerCapture(event.pointerId); beginSplineGizmoDrag(state, event.pointerId, splineGizmo); requestRender(state); return; } const propertyHandle = pickSelectedFeaturePropertyHandle( state, canvasPoint.x, canvasPoint.y, GEOMETRY_PICK_PROPERTY_HANDLE_RADIUS_PX ); if (propertyHandle) { canvas.setPointerCapture(event.pointerId); beginFeaturePropertyHandleDrag(state, event.pointerId, propertyHandle, canvasPoint.y); requestRender(state); return; } if (assistMode === 'insert' || event.altKey || event.shiftKey) { if (insertFeaturePointNearCanvas(state, canvasPoint.x, canvasPoint.y)) { return; } if (assistMode === 'insert') { setStatus('Mouse assist insert: no nearby segment for point insert.'); return; } } const pointHit = pickFeaturePoint(state, canvasPoint.x, canvasPoint.y, GEOMETRY_PICK_POINT_RADIUS_PX); if (pointHit) { canvas.setPointerCapture(event.pointerId); beginGeometryPointDrag(state, event.pointerId, pointHit.target, pointHit.pointIndex, world); requestRender(state); return; } const bodyHit = pickFeatureBody(state, canvasPoint.x, canvasPoint.y, GEOMETRY_PICK_BODY_RADIUS_PX); if (bodyHit) { canvas.setPointerCapture(event.pointerId); beginGeometryFeatureDrag(state, event.pointerId, bodyHit.target, world); requestRender(state); return; } setSelectedFeatureRef(state, null); refreshFeatureEditor(state); requestRender(state); setStatus('No feature selected.'); }); canvas.addEventListener('pointermove', (event) => { const probePoint = canvasClientToCanvasCoords(event.clientX, event.clientY); state.probe.canvasX = probePoint.x; state.probe.canvasY = probePoint.y; state.probe.active = true; const now = performance.now(); const probeIntervalMs = state.brushStroke || state.influencePaintStroke || state.geometryEditDrag ? 34 : 16; if (now - state.probe.lastUpdateAt >= probeIntervalMs) { state.probe.lastUpdateAt = now; requestRender(state); } const influenceStroke = state.influencePaintStroke; if (influenceStroke && influenceStroke.pointerId === event.pointerId) { processPointerMoveSamples(state, event, 'influence'); return; } const brushStroke = state.brushStroke; if (brushStroke && brushStroke.pointerId === event.pointerId) { processPointerMoveSamples(state, event, 'brush'); } if (state.splineGizmoDrag) { const world = canvasClientToWorld(state, event.clientX, event.clientY); updateSplineGizmoDrag(state, event.pointerId, world); return; } if (state.propertyHandleDrag) { const canvasPoint = canvasClientToCanvasCoords(event.clientX, event.clientY); updateFeaturePropertyHandleDrag(state, event.pointerId, canvasPoint.y); return; } if (state.geometryEditDrag) { const world = canvasClientToWorld(state, event.clientX, event.clientY); updateGeometryEditDrag(state, event.pointerId, world); } }); canvas.addEventListener('pointerleave', () => { state.probe.active = false; updateProbeReadout(state); }); canvas.addEventListener('pointerup', (event) => { endInfluencePaintStroke(state, event.pointerId); endBrushStroke(state, event.pointerId); endSplineGizmoDrag(state, event.pointerId); endFeaturePropertyHandleDrag(state, event.pointerId); endGeometryEditDrag(state, event.pointerId); }); canvas.addEventListener('pointercancel', (event) => { endInfluencePaintStroke(state, event.pointerId); endBrushStroke(state, event.pointerId); endSplineGizmoDrag(state, event.pointerId); endFeaturePropertyHandleDrag(state, event.pointerId); endGeometryEditDrag(state, event.pointerId); }); window.addEventListener('pointerup', (event) => { endInfluencePaintStroke(state, event.pointerId); endBrushStroke(state, event.pointerId); endSplineGizmoDrag(state, event.pointerId); endFeaturePropertyHandleDrag(state, event.pointerId); endGeometryEditDrag(state, event.pointerId); }); window.addEventListener('pointercancel', (event) => { endInfluencePaintStroke(state, event.pointerId); endBrushStroke(state, event.pointerId); endSplineGizmoDrag(state, event.pointerId); endFeaturePropertyHandleDrag(state, event.pointerId); endGeometryEditDrag(state, event.pointerId); }); canvas.addEventListener('contextmenu', (event) => { const mode = asShapeMode(controls.shapeMode.value); if (mode === 'edit') { event.preventDefault(); const canvasPoint = canvasClientToCanvasCoords(event.clientX, event.clientY); if (!removeFeaturePointAtCanvas(state, canvasPoint.x, canvasPoint.y)) { setStatus('No editable point under cursor.'); } return; } if (mode !== 'none') { event.preventDefault(); if (!removeLastShapePoint(state)) { setStatus('No draft points to remove.'); } return; } event.preventDefault(); }); canvas.addEventListener('wheel', (event) => { const mode = asShapeMode(controls.shapeMode.value); if (mode !== 'edit') return; if (!state.selectedFeatureRef || state.selectedFeatureRef.kind !== 'spline') return; const direction: 1 | -1 = event.deltaY < 0 ? 1 : -1; const step = event.shiftKey ? 0.12 : 0.05; if (nudgeSelectedSplinePointWeight(state, direction, step)) { event.preventDefault(); } }, { passive: false }); window.addEventListener('keydown', (event) => { handleMapLabKeydown(state, event); }); window.addEventListener('resize', () => requestRender(state)); window.addEventListener('beforeunload', () => { document.removeEventListener('fullscreenchange', handleFullscreenChange); document.removeEventListener('webkitfullscreenchange', handleFullscreenChange as EventListener); clearScheduledRegenerations(state); clearContourRestoreTimer(state); clearFullWatchdog(state); if (state.renderFrameHandle !== null) { window.cancelAnimationFrame(state.renderFrameHandle); state.renderFrameHandle = null; } if (state.perfUiTimer !== null) { window.clearInterval(state.perfUiTimer); state.perfUiTimer = null; } flushRecipePersist(state); state.broadcast?.close(); state.worker?.terminate(); state.importWorker?.terminate(); state.overlayRenderer?.dispose(); }); syncSidebarPanelsVisibility(state); startInitialGeneration(state); } const recipe = readStoredRecipe() ?? createDefaultRecipe(); const state: AppState = { recipe, bundle: null, fullBundle: null, previewTimer: null, previewFrameHandle: null, renderFrameHandle: null, renderQueued: false, fullTimer: null, stats: { min: 0, max: 1 }, generationMode: 'full', broadcast: createMapLabChannel(), worker: createMapLabWorker(), requestSeq: 0, latestPreviewRequestId: 0, latestFullRequestId: 0, latestFullWorkerRecipe: null, queuedFullWorkerRecipe: null, fullInFlight: false, previewInFlight: false, previewWorkerPrimed: false, queuedPreviewRecipe: null, queuedStampDeltas: [], workerHeatmap: null, workerContour: null, lastRoughPublishAt: 0, lastListenerProbeAt: 0, lastListenerAckAt: -1, contourRestoreTimer: null, contourRestoreEnabled: OVERLAYS.contours.checked, turboContoursSuppressed: false, broadcastClientId: createBroadcastClientId(), recipePersistTimer: null, previewAdaptiveLevel: 0, previewAdaptiveSlowStreak: 0, previewAdaptiveFastStreak: 0, perf: { lastWorkerMs: 0, lastRenderMs: 0, lastBlitMs: 0, lastTransferBytes: 0, lastHeightPatchBytes: 0, lastHeatPatchBytes: 0, lastContourPatchBytes: 0, lastOverlayPatchBytes: 0, lastOverlayPatchCount: 0, lastHeightPatchDims: '-', lastHeatPatchDims: '-', lastContourPatchDims: '-', broadcastTimes: [], broadcastRate: 0, previewQueueDepth: 0, coalescedPreviewCount: 0 }, perfUiTimer: null, interactionLoadScale: 1, lastInteractionLoadUpdateAt: 0, fullWatchdogTimer: null, bootHydrationDone: false, latestBundlePersistHash: null, latestBundlePersistTransport: null, persistingBundleHash: null, historyPast: [], historyFuture: [], historyPendingBefore: null, lastPerfPanelUpdateAt: 0, nextDataPanelAt: 0, shapeDraft: null, editSpeedMode: asEditSpeedMode(controls.editSpeed.value), selectedFeatureRef: null, selectedFeaturePointIndex: null, brushStroke: null, stampScratch: [], stampDeltaScratch: [], stampSelectedScratch: new Set(), stampIndexScratch: new Set(), nextManualStampId: Date.now(), manualStampSpatialIndex: null, influencePaintStroke: null, constraintLocks: null, lastPatchStatusAt: 0, lastStatusMessage: '', geometryEditDrag: null, propertyHandleDrag: null, splineGizmoDrag: null, lastGeometryPreviewAt: 0, importWorker: createMapLabImportWorker(), importRequestSeq: 0, pendingImportRequestId: 0, importedBaseLayer: null, importedBaseLayerWidth: 0, importedBaseLayerHeight: 0, importRecipes: [], importAttribution: [], pendingImportContext: null, layerStore: new LayerStore(), activeLayerId: 'height', overlayAlpha: Number.parseFloat(controls.layerAlpha.value) || 0.85, overlayRangeMin: 0, overlayRangeMax: 1, overlayAutoRange: true, overlayColormap: toOverlayColormapId(controls.layerColormap.value), overlayRenderer: null, overlayRendererReady: false, overlayLayerDataVersionById: {}, overlayFloatCache: null, caLayerStats: [], cacheHits: 0, cacheMisses: 0, sidebarDockOpen: false, dockTab: 'edit', sidebarPanels: { importPinned: false, layerPinned: false, overlaysPinned: false }, probe: { canvasX: 0, canvasY: 0, active: false, lastUpdateAt: 0 } }; initialize(state);