OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / map_mountain.gd

1036 lines · gd ref:
extends "res://scenes/maps/map_dc_level.gd"
## MapMountain — the bridged mountain, alone, at the scale it is meant to be
## seen at.
##
## ⛔ WHY IT EXTENDS map_dc_level, WITH level_import OFF. Editing is inherited, not
## copied: map_core (the base of map_dc_level) brings terrain CSG -- the RMB carve,
## the Terrain panel, edit replay -- and map_dc_level brings the brush store, the
## brush editor and the journal. map_dc_world is the same pairing with a Quake
## level in it; here there is no level, so level_import is off and the DCLevel
## starts empty: brushes are built from scratch. START.MAP is never loaded.
##
## ⛔ WHY IT IS ITS OWN MAP. This asset was a lodger in map_flat: pushed one
## 4096 m tile out so the flat testbed stayed flat. That offset is precisely
## what made its object-space defects invisible in the comparison scene and
## visible here (hyper-games #83 §6b), and the second DCTerrain in map_flat is
## also what broke bot navigation -- _terrain() binds to the FIRST child that
## answers nav_find_path, and the mountain answered with a disjoint graph.
## One asset, one map, no neighbours to be confused with.
##
##   HYPER_TPS_MAP=res://scenes/maps/map_mountain.tscn scripts/dev-run.sh
##
## ⚠ Loading the .tscn directly gives an unlit grey world -- the sky lives in
## game.tscn, not in a map. Go through the shell, or the teleporter in map_test.
##
## The mountain is a BOUNDED field: outside its declared box there is no
## surface at all, only air. So arrival is from ABOVE, the way map_dc_erosion
## does it, and `ground_platform` exists because a return teleporter needs a
## floor and this map has exactly as much floor as the field gives it.


## A thin slab at the mountain's base, wide enough to stand back and look at it.
##
## ⚠ NOT DECORATION. The field is bounded: step outside the declared box and
## there is nothing to stand on, including under the return teleporter. Turn
## this off to see the asset with nothing around it -- and arrive on the summit,
## because there will be nowhere else to land.
@export var ground_platform: bool = true


func _ready() -> void:
	# BEFORE super: map_dc_level reads it in _ready (daylight) and in the deferred
	# _import_level. This map has no .MAP.
	level_import = false
	# super FIRST, per map_core's contract; the terrain panel binding it defers
	# runs after _setup_mountain below has assigned the generator.
	super._ready()
	_setup_ground()
	mountain_mesh_bake = _wants_mesh_bake()
	Settings.setting_changed.connect(_on_setting_changed)
	_setup_mountain()
	_setup_cloud_bank()
	_register_mountain_panel()


## The slab, sized from the generator's own declared box rather than a number
## typed here, so it cannot disagree with the asset it surrounds.
func _setup_ground() -> void:
	var slab: Node = get_node_or_null(^"Ground")
	if slab == null:
		return
	slab.visible = ground_platform
	var col: Node = slab.get_node_or_null(^"Collision")
	if col != null:
		(col as CollisionShape3D).disabled = not ground_platform


# ---- the mountain ------------------------------------------------------------
#
# A bridged Blender asset, meshed by dc-terrain as the bounded volume its graph
# declares. The node in map_flat.tscn carries how it STREAMS; this carries what
# it IS, because the graph lives in a scene and cannot be an ext_resource of one.
#
# ⛔ GPU ONLY, and that is the generator's stated contract, not an oversight:
# DCTerrainGeneratorProcNodesBounded::evaluate() returns air and says so in its
# header, because a CPU field invented to disagree with the shader is worse than
# no field. It costs nothing here — a headless peer frees the terrain outright,
# two lines below, exactly as map_dc_terrain does.

## Where the graph lives. A BRIDGED SCENE, not a .tres: blender-bridge writes this
## from mountain_voronoi2.blend, so anything derived from it goes stale the next
## time the mountain is re-imported. Read the graph out of the scene instead and
## there is one source.
const MOUNTAIN_SCENE := "res://blender/mountain_voronoi3.tscn"

## How much larger than Blender the mountain stands in the world.
##
## The graph is authored as a MODEL, not a landmark: measured, its declared box is
## (-20, -2, -20)..(20, 23.8, 20) and the evaluated mesh's AABB agrees exactly --
## 40 m across and 25.8 m tall. At 100 it is a 4 km massif 2.4 km high.
##
## The generator emits this INTO the shader as f_world(p) = f_node(p / s) * s.
## ⚠ The trailing multiply is not decoration: an SDF carries a DISTANCE, so
## dividing the input without multiplying the output leaves the surface in the
## right place and every gradient, edge-crossing search and clearance query wrong
## by a factor of s. Verified in the emitted GLSL at s = 50: `p * 0.02` going in,
## `d * 50.0` coming out.
## ⛔ 1.0 — THE ASSET IS AUTHORED AT GAME SCALE NOW.
## mountain_voronoi3 is natively 6000 x 6000 x 3136 m: its cage, its geo-node
## coordinates, its declared bounds and its material are all in metres. The
## generator would scale it a SECOND time -- at 100 this asset becomes 260 km.
## The scale lives in the .blend, not here, because the MATERIAL cannot be
## scaled by world_scale: it reads world Position, and there is no uniform that
## divides it. Authoring at target scale is what makes the two agree.
@export var mountain_world_scale: float = 1.0


## Sample the graph's terrain functions on the ground plane rather than at the
## query point.
##
## This asset's density is `f(pos) - up` with f reaching Position, so f shrinks as
## the sampler climbs and the surface solves f(z) = z. Measured: a 9.90 peak where
## the Blender mesh has 21.77, because geometry nodes evaluate f ONCE on a cage
## that is a flat slab. Flattening tracks the mesh across the whole profile.
##
## ⚠ Correct for a heightfield, WRONG for a real volume -- it would erase caves
## and overhangs. It is off in the generator by default, and only the map knows
## which kind of asset it placed.
##
## ⛔ OFF FOR voronoi5 (what mountain_voronoi3.tscn carries since 2026-09-27). It
## is a VOLUME -- a floating island with pockets and air around it -- and
## flattening turned it into a 1 km slab over the whole 12 km box. Measured,
## columns at x = 0 / 1500 / 2600: with this OFF the GPU density equals
## Blender's own Density field (max 31.00 / 5.55 / -7.18 on both sides, same
## sign runs); ON, every column was one slab -960..+40, including x = 2600,
## which Blender says is all air. Turn it on only for a heightfield asset.
@export var mountain_heightfield: bool = false

## Leaf voxel size for the mountain, in WORLD metres. 0 uses the authored size.
##
## ⛔ IT IS NOT SCALED BY mountain_world_scale ANY MORE, and that was the whole
## problem. The old rule was `authored * world_scale`, so at scale 100 a 0.16 m
## authored voxel became a SIXTEEN METRE one: the mountain was meshed at a
## hundredth of the detail it was authored with, which is why it renders as a
## smooth cone with none of the rock the material expects to sit on, and why
## CSG edits here are a hundred times blunter than in every other map.
##
## Scaling the leaf with the object defeats the octree. The octree exists to
## put small leaves near the viewer and large ones far away; fixing the leaf at
## a world size and letting LOD handle distance is what every other map does.
## The density field is analytic, so it has detail at any resolution asked of
## it -- the mesh was the only thing that had been coarsened.
##
## Raise it if meshing cost bites: chunk = voxel * 32, so 0.16 gives 5 m chunks
## and 2.0 gives 64 m ones.
## ⛔ RETRACTED: an earlier revision of this comment claimed "0.16 IS MEASURABLY
## NOT VIABLE AT world_scale 100... 4000 / 0.16 is 25,000 voxels per axis and
## the LOD cannot hide that". THAT REASONING IS WRONG and the number it rested
## on was not a leaf count.
##
## The clipmap's split rule is `dist < cell_size * lod_factor`
## (dc_octree_clipmap.cpp:346, lod_factor 2.0), and BOTH sides scale with
## voxel_size -- so voxel size cannot change the leaf count. Measured by
## building and running dc-terrain's own tests/test_leaf_count_vs_depth.cpp
## against the shipping OctreeClipmap: a flat 224 leaves PER LEVEL, so
## octree_depth 12 wants 2,080 leaves at ANY voxel size, and depth 14 wants
## 2,528 while covering 51 km. map_dc_erosion runs 0.16-class detail over
## 917 km on exactly this machinery. The observed opening count of 2,560 was
## the clipmap behaving correctly; the climb to 50,176 is a DIFFERENT
## mechanism and is not yet explained. Ruled out by reading source:
## use_screen_space_error (off by default), content_aware_lod (forced false,
## dc_terrain.cpp:309), seamless_lod (false in every map), collision_distance
## (gates a build queue, never a split), and a stuck stats accumulator
## (leaf_count IS zeroed each traverse, dc_octree_clipmap.cpp:153). The open
## lead is sub-LOD: min_lod drops to -2 near an edit_focus_point once
## lod0_settled, and this is the map that takes CSG edits.
##
## So 2.0 here is a HOLD while that is found, NOT a considered resolution, and
## it is the reason this mountain is blunter than the asset. Restore 0.0
## (authored) once the growth is explained.
##
## The detail BELOW the leaf belongs in the shader, not the mesh -- see
## dc_erosion_color.gdshader's density_normal_blend, which re-evaluates the
## density per fragment and fades in as the geometry coarsens.
## ⚠ AND THE BRIDGED MATERIAL *CAN* DO THAT -- an earlier note here and in
## docs/plans/2026-09-21-bridged-material-and-detail-normal.md said the field
## "exists only as emitted COMPUTE GLSL" and needed a third dual-compile
## target. It does not. get_compiled_glsl() is bound to ClassDB
## (dc_terrain_generator_procnodes_bounded.cpp:33) and hands GDScript the
## field as `float evaluate_density(float,float,float)` at runtime, and a
## VisualShader IS a Shader whose .code is the finished .gdshader text
## (visual_shader.cpp:3155). Proven end to end, 17/17, by
## scenes/test/bridged_density_splice_gate.gd.
##
## ⛔ 2026-09-28: HOLD LIFTED — 0.224 m with octree_depth 15 (map_mountain.tscn).
## Leaf count is voxel-size invariant (224 leaves PER LEVEL, measured), so the
## coarse voxel bought nothing and made every CSG carve blunt: near an edit the
## sub-LOD gives voxel/4, i.e. 0.5 m cuts at 2.0 vs 0.056 m now. 0.224 is
## map_dc_world's value (Quake's 8-unit grid × 0.028), so terrain cuts line up
## with brushes built on the level grid. DEPTH MOVES WITH IT: map_core scales the
## fog from extent = 2^(depth-1) × 28 × voxel — depth 12 at 0.224 would shrink the
## world to 12.8 km and pull the fog in to ~1.5 km; depth 15 keeps ~103 km, the
## same atmosphere as before, for +3 clipmap levels.
## ⚠ 2026-09-28 later: 0.224/depth 15 made carves SLOW (7.2 m chunks, 0.056 m at
## the edit's LOD -2, and 12k+ chunks still streaming 44 s in). Now 0.5 m / depth 14
## (map_mountain.tscn): 16 m chunks, 0.125 m at an edit — 4x crisper than the 2.0 hold
## — and extent 2^13 × 28 × 0.5 ≈ 115 km, so map_core's fog is unchanged.
@export var mountain_voxel_size: float = 0.5

## Convert the bridged VisualShader into a .gdshader that also carries the
## generator's density field, and recover the sub-voxel normal from it.
## Off gives exactly the previous behaviour, so this is a comparison you can
## make in one toggle rather than one git revert.
##
## ⛔ DEFAULT OFF, AND THE REASON IS A MEASUREMENT, NOT CAUTION.
## The spliced shader is ~196 KB -- 196,345 bytes at 6 taps and 196,235 at 4,
## so the tap count is NOT what makes it big; the 176 KB of voronoi + perlin +
## erosion function definitions is.
## MEASURED on an RTX 2070 via bridged_density_splice_gate run WITHOUT
## --headless: build() takes 3749 ms, of which 13 ms is set_code and the rest is
## the lazy GLSL -> SPIR-V on first RID use. That is a one-off at map load.
##
## ⚠ WHAT IS NOT MEASURED is the cost of the first DRAW, where Godot compiles a
## pipeline per render pass (opaque, shadow, depth) as separate specializations
## of that same 196 KB -- nor whether the driver inlines the density chain once
## or once per tap. Turning this on made map_flat stall right after
## "[MapMountain] mountain material:" and I did not get a number for how long,
## because measuring it needs a real display and there is no xvfb on this host.
## scenes/test/bridged_density_render_cost.gd is the instrument for it and takes
## --taps; run it on a display you do not mind borrowing.
##
## So: the conversion is PROVEN correct (13/13) and its load cost is known; its
## first-frame cost is not. Flip this on to measure it, not to ship it.
@export var mountain_detail_normal: bool = false

## How many evaluate_density call sites the detail normal uses: 2, 4 or 6.
## ⚠ NOT a measured cost knob -- see bridged_density_material.gd, the text is
## the same size either way. 4 matches dc_erosion_color.gdshader (x+/x-/z+/z-,
## no y), which is CORRECT for a heightfield, and this graph is one
## (mountain_heightfield above). That, not cost, is why it is the default.
@export_enum("2:2", "4:4", "6:6") var mountain_detail_normal_taps: int = 4

## How much of the recovered normal to use. 1.0 replaces the mesh normal
## entirely; lower values blend. ⚠ This is the ONE knob worth turning by eye --
## the FREQUENCIES come from the generator and must not be retuned, or the
## material stops describing the surface the geometry has.
@export_range(0.0, 1.0) var mountain_detail_normal_blend: float = 1.0
## PERFORMANCE A/B: render the mountain with a plain StandardMaterial3D instead
## of the bridged Blender material. Compare fps with clouds off: the difference
## is what the bridged shader costs per pixel. Reload the map after changing.
@export var mountain_plain_material: bool = false
## MESH-TIME MATERIAL BAKE (hyper-games #90). When the bridged material carries
## meta bb_mesh_bake (blender-bridge >= 0.169.0, kernel ABI v2), dc-terrain
## evaluates the material's position-only noise ONCE PER VERTEX while meshing (the
## kernel) and the mountain renders with the SLIM material, which reads those 16
## values from CUSTOM0..3 instead of computing them per pixel. A chunk the kernel
## never ran on (CPU mesh, seams) reads zeros -- c0.x < 1.5, "not baked" -- and
## gets the exact per-pixel original. Off = the full material, exactly as before.
##
## WHO DECIDES (#91): Follow = the player's Settings "terrain_material" (Full /
## Vertex), live. Full / Vertex FORCE it, so a saved setting cannot change what a
## bench or a gate measures (mountain-bench, gate_mesh_bake_pixels). Resolved in
## _ready: set it before add_child.
@export_enum("Follow Settings", "Full", "Vertex") var mountain_terrain_material: int = 0
## The resolved switch _apply_mesh_bake reads (from the above, in _ready).
var mountain_mesh_bake: bool = false
const BDM := preload("res://scenes/maps/bridged_density_material.gd")
const MOUNTAIN_GRAPH_NODE := ^"Mountain/ProcGeoNodes"
const MOUNTAIN_MESH_NODE := ^"Mountain"

var _mountain: Node = null


func _setup_mountain() -> void:
	_mountain = get_node_or_null(^"MountainTerrain")
	if _mountain == null:
		return

	# HEADLESS FREES IT, same as map_dc_terrain.gd. A daemon peer never renders,
	# so meshing is pure waste — and with no RenderingDevice there is no GPU
	# thread, so this generator would have no field at all and mesh nothing very
	# expensively. Gamestate still syncs; only the geometry is skipped.
	if DisplayServer.get_name() == "headless":
		_mountain.queue_free()
		_mountain = null
		print("[MapMountain] headless — MountainTerrain freed (gamestate-sync only)")
		return

	if mountain_density_source == "mesh":
		_setup_mountain_from_mesh()
		return

	if not ClassDB.class_exists("DCTerrainGeneratorProcNodesBounded"):
		push_warning("[MapMountain] DCTerrainGeneratorProcNodesBounded missing — the " +
				"dc-terrain extension is stale. No mountain.")
		return

	var graph: Resource = _load_mountain_graph()
	if graph == null:
		return

	var gen: Resource = ClassDB.instantiate("DCTerrainGeneratorProcNodesBounded")
	# BEFORE set_graph: the scale is emitted INTO the compiled shader, so it has to
	# be known at compile time rather than applied to a finished one.
	gen.set_world_scale(mountain_world_scale)
	gen.set_heightfield(mountain_heightfield)
	gen.set_graph(graph)
	# ⛔ ASK, DO NOT ASSUME IT COMPILED. The generator refuses rather than dropping
	# a node it cannot emit, and a refusal leaves the terrain EMPTY rather than
	# wrong — which looks exactly like a missing node and gets diagnosed as one.
	if not gen.has_compiled_shader():
		push_warning("[MapMountain] the mountain graph did not compile: %s"
				% gen.get_last_compile_error())
		return
	if not gen.has_bounds():
		push_warning("[MapMountain] the mountain graph declares no bounds — it will be " +
				"streamed as an unbounded field, which is not what it is.")

	# IT STANDS WHERE THE SCENE PUTS IT -- at the origin, alone. In map_flat it
	# was pushed one 4096 m tile out so the flat testbed stayed flat, and that
	# offset is what made every object-space bug in this material visible only
	# here and never in the comparison scene. Nothing to offset from now.

	# THE VOXEL TRAVELS WITH THE SCALE, and it is DERIVED rather than authored
	# twice. The graph says what it was built at (0.16 m for a 40 m model); at
	# s = 100 the same relative detail is a 16 m voxel. Left at the scene's 0.16 the
	# mesher would be asked for 25,000 voxels across the box -- the terrain would
	# not be wrong, it would be unbuildable.
	#
	# Set BEFORE the generator on purpose. Every DCTerrain setter calls rebuild(),
	# and this one runs while the terrain still has no field, so the only rebuild
	# that costs anything is the last one.
	# THE BRIDGED MATERIAL, not dc-terrain's built-in shader. Without this the
	# mountain renders with the default noise colouring and none of the rock,
	# snow or slope blending that was authored in Blender.
	var mountain_mat: Material = _load_mountain_material()
	if mountain_mat != null:
		_mountain.set("custom_material", mountain_mat)
		# ⛔ AND TELL IT WHAT SIZE IT IS BEING RENDERED AT. The material was
		# authored against the object's own coordinates; dc-terrain builds this
		# at world_scale, so without the division every texture comes out
		# `mountain_world_scale` times too fine. The generator already does the
		# same division for the density field.
		if mountain_mat is ShaderMaterial and mountain_world_scale > 0.0:
			(mountain_mat as ShaderMaterial).set_shader_parameter(
					"bb_coord_scale", 1.0 / mountain_world_scale)
		mountain_mat = _apply_mesh_bake(mountain_mat)
		print("[MapMountain] mountain material: %s" % mountain_mat)
		# ⛔ THE THREE NUMBERS THAT DECIDE WHERE THIS MATERIAL SAMPLES ITSELF.
		# Printed because every question about the mountain's shading has so far
		# been answered by reasoning about them instead of reading them. The
		# .tscn SAVES bb_world_to_object_on = 0.0 and DCTerrain overwrites it to
		# 1.0 when the material is assigned; if this line says 0.0 the push did
		# not happen and the material is reading chunk-local coordinates.
		if mountain_mat is ShaderMaterial:
			var _mm := mountain_mat as ShaderMaterial
			print("[MapMountain] object space: on=%s w2o=%s coord_scale=%s gen_min=%s gen_size=%s"
					% [str(_mm.get_shader_parameter("bb_world_to_object_on")),
					str(_mm.get_shader_parameter("bb_world_to_object")),
					str(_mm.get_shader_parameter("bb_coord_scale")),
					str(_mm.get_shader_parameter("bb_gen_min")),
					str(_mm.get_shader_parameter("bb_gen_size"))])
			print("[MapMountain] terrain at %v, declared field %v .. %v (world m)"
					% [(_mountain as Node3D).global_position,
					gen.get_bounds_min(), gen.get_bounds_max()])

		# ---- DETAIL NORMAL, from the SAME field the mesher used ----------
		# The bridged material is a VisualShader, so it cannot #include and has
		# no evaluate_density -- which is the whole reason this mountain never
		# had the per-fragment normal that map_dc_erosion gets from
		# dc_erosion_color.gdshader. BridgedDensityMaterial converts it: the
		# VisualShader's own generated .gdshader text, plus the generator's
		# get_compiled_glsl(), spliced into one plain Shader. Proven 13/13 by
		# scenes/test/bridged_density_splice_gate.gd.
		#
		# ⚠ IT REPLACES THE MATERIAL ONLY ON SUCCESS. A failed conversion
		# leaves the bridged material in place and says why, because a mountain
		# that renders slightly flat is worth far more than one that renders
		# untextured.
		if mountain_detail_normal and mountain_mat is ShaderMaterial:
			var built: Dictionary = BDM.build(mountain_mat as ShaderMaterial, gen,
					mountain_detail_normal_taps)
			if built["material"] != null:
				mountain_mat = built["material"]
				_mountain.set("custom_material", mountain_mat)
				(mountain_mat as ShaderMaterial).set_shader_parameter(
						"dn_blend", mountain_detail_normal_blend)
				# Half a leaf voxel: below that the central difference
				# underflows float32 and the horizontal terms come back 0.0.
				(mountain_mat as ShaderMaterial).set_shader_parameter(
						"dn_eps", maxf(0.5, float(_mountain.get("voxel_size")) * 0.5))
				print("[MapMountain] detail normal: ON taps=%d — %s (timings %s)"
						% [mountain_detail_normal_taps, ", ".join(built["log"]),
						   str(built.get("timings", {}))])
			else:
				push_warning("[MapMountain] detail normal OFF, keeping the bridged "
						+ "material: " + ", ".join(built["log"]))
	else:
		push_warning("[MapMountain] %s carries no material — the mountain will use " % MOUNTAIN_SCENE +
				"dc-terrain's default shader. Re-bridge and publish from Blender " +
				"so the scene carries material_override on Mountain.")

	var voxel: float = (mountain_voxel_size if mountain_voxel_size > 0.0
			else gen.get_authored_voxel_size())
	_mountain.set("voxel_size", voxel)

	# AND SO DOES THE COLLISION RADIUS. This is why the mountain had no collision
	# while rendering perfectly.
	#
	# dc-terrain defers any collision request whose chunk CENTRE is farther than
	# collision_distance (dc_collision_trimesh.h:92) -- deferred, not dropped, so
	# nothing errors. A LOD-0 chunk is voxel * 32 across: 5.12 m at the authored
	# 0.16. A chunk that tall puts its centre ~256 m
	# below a player standing on its surface, which is already past the authored
	# 200 m radius before any horizontal offset is counted -- so the body under
	# your feet is never built and you fall through.
	#
	# Four chunk-spans covers the one you are in plus its neighbours. ⚠ The
	# property's range hint stops at 500 in the inspector, but the setter does not
	# clamp (dc_terrain.cpp:2859), so this is authorable only from code.
	var chunk_span: float = float(_mountain.get("voxel_size")) * 32.0
	_mountain.set("collision_distance",
			maxf(float(_mountain.get("collision_distance")), chunk_span * 4.0))

	_mountain.set("generator", gen)
	print("[MapMountain] mountain: x%.0f -> bounds %v..%v, voxel %.3f m (authored %.3f, NOT scaled), chunk %.1f m, collision r=%.0f m, standing at %v"
			% [mountain_world_scale, gen.get_bounds_min(), gen.get_bounds_max(),
			   voxel, gen.get_authored_voxel_size(), chunk_span,
			   float(_mountain.get("collision_distance")), (_mountain as Node3D).position])


## The mesh-time bake, if the material carries one and it is switched on: set the
## kernel on the terrain and assign the SLIM material. Returns the material now on
## the terrain -- the slim one, or `full` unchanged.
##
## ⛔ THE KERNEL READS MATERIAL UNIFORMS AS CONSTANTS. A compute shader has no
## material, so every uniform it names (meta "uniforms": bb_coord_scale,
## bb_aa_strength) is declared here with the value the material holds NOW -- set
## above, or the shader's own default. Change one later and the kernel must be set
## again (_on_aa_toggled does), or the baked values describe the old setting.
var _full_mat: Material = null     # the full per-pixel material (debug views read its code)
var _mesh_bake: Dictionary = {}    # the active meta, {} when off


func _apply_mesh_bake(full: Material) -> Material:
	_full_mat = full
	_mesh_bake = {}
	if not mountain_mesh_bake or not (full is ShaderMaterial) or not full.has_meta("bb_mesh_bake"):
		if full is ShaderMaterial and not full.has_meta("bb_mesh_bake"):
			print("[MapMountain] mesh bake: the material carries none (blender-bridge < 0.168.0, or nothing lifts)")
		return full
	if not _mountain.has_method("set_material_kernel"):
		push_warning("[MapMountain] mesh bake: dc-terrain has no set_material_kernel -- stale extension; full material")
		return full
	var mb: Dictionary = full.get_meta("bb_mesh_bake")
	var slim: ShaderMaterial = mb.get("material")
	if slim == null or String(mb.get("kernel", "")).is_empty():
		push_warning("[MapMountain] mesh bake: meta without a material or kernel; full material")
		return full
	# The slim material is its own resource: give it the runtime values the full
	# one was given (bb_coord_scale above; everything else the bake already holds).
	for u in (full as ShaderMaterial).shader.get_shader_uniform_list():
		var v: Variant = (full as ShaderMaterial).get_shader_parameter(u["name"])
		if v != null:
			slim.set_shader_parameter(u["name"], v)
	var kernel := _mesh_bake_kernel(mb, slim)
	if kernel.is_empty():
		return full
	_mesh_bake = mb
	_mountain.call("set_material_kernel", kernel)
	_mountain.set("custom_material", slim)
	print("[MapMountain] mesh bake: ON -- %d per-vertex value(s) %s, kernel %d chars"
			% [(mb.get("slots", []) as Array).size(), str(mb.get("slots")), kernel.length()])
	return slim


func _mesh_bake_kernel(mb: Dictionary, mat: ShaderMaterial) -> String:
	var prelude := ""
	for name in mb.get("uniforms", []):
		var v: Variant = mat.get_shader_parameter(name)
		if v == null:
			v = RenderingServer.shader_get_parameter_default(mat.shader.get_rid(), name)
		if not (v is float or v is int):
			push_warning("[MapMountain] mesh bake: kernel uniform %s is %s, not a float; full material"
					% [name, type_string(typeof(v))])
			return ""
		var s := String.num(float(v), 9)
		if not s.contains(".") and not s.contains("e"):
			s += ".0"
		prelude += "const float %s = %s;\n" % [name, s]
	return prelude + String(mb["kernel"])


func _load_mountain_material() -> Material:
	## The bridged material, off the same scene the graph comes from.
	##
	## The bridge applies it as material_override on the Mountain MeshInstance3D,
	## and that IS saved with the scene -- unlike anything on ProcGeoNodes'
	## MeshOutput, which is disowned so the scene does not carry a few million
	## baked vertices. So the override is where to look.
	if mountain_plain_material:
		# A/B for per-pixel cost: one cheap lit material in place of the bridged
		# shader. Everything bridged-only below is guarded by `is ShaderMaterial`,
		# and DCTerrain skips its param push for a non-shader material.
		var plain := StandardMaterial3D.new()
		plain.albedo_color = Color(0.55, 0.52, 0.48)
		plain.roughness = 0.9
		print("[MapMountain] mountain material: PLAIN (mountain_plain_material on)")
		return plain
	if not ResourceLoader.exists(MOUNTAIN_SCENE):
		return null
	# ⛔ PREFER THE BAKED MATERIAL. blender-bridge (>= 0.167.0) bakes every bridged
	# VisualShader on each scene write into <scene>.<node>.gdshader + .tres: the
	# finished GLSL, generated by the editor (which has VisualShader node GROUPS),
	# plus the material's parameter values. Loading it skips regenerating the graph
	# on every load and does not need group support in the running engine. Used only
	# when at least as new as the scene -- a stale bake silently shows the OLD look.
	var baked_path := MOUNTAIN_SCENE.get_basename() + "." + String(MOUNTAIN_MESH_NODE).get_file() + ".tres"
	if ResourceLoader.exists(baked_path):
		var t_scene := FileAccess.get_modified_time(MOUNTAIN_SCENE)
		var t_baked := FileAccess.get_modified_time(baked_path)
		if t_baked >= t_scene:
			var baked: Material = load(baked_path)
			if baked != null:
				print("[MapMountain] mountain material: BAKED %s" % baked_path)
				return baked
		else:
			push_warning("[MapMountain] %s is older than %s -- using the VisualShader" % [baked_path, MOUNTAIN_SCENE])
	var ps: PackedScene = load(MOUNTAIN_SCENE)
	if ps == null:
		return null
	var inst: Node = ps.instantiate()
	var mi: Node = inst.get_node_or_null(MOUNTAIN_MESH_NODE)
	var mat: Material = null
	if mi != null:
		mat = mi.get("material_override")
		if mat == null and mi.get("get_surface_override_material_count") != null:
			if (mi as MeshInstance3D).get_surface_override_material_count() > 0:
				mat = (mi as MeshInstance3D).get_surface_override_material(0)
	inst.free()
	return mat


## The ProcNodeGraph out of the bridged scene, or null.
##
## Instantiated and freed rather than read off the PackedScene's state: a graph is
## a sub-resource of a node's property, and digging it out of SceneState by index
## is a different thing from what the scene MEANS every time the bridge rewrites it.
func _load_mountain_graph() -> Resource:
	if not ResourceLoader.exists(MOUNTAIN_SCENE):
		push_warning("[MapMountain] %s is missing — no mountain. It is written by " % MOUNTAIN_SCENE +
				"blender-bridge from mountain_voronoi2.blend.")
		return null
	var ps: PackedScene = load(MOUNTAIN_SCENE)
	if ps == null:
		return null
	var inst: Node = ps.instantiate()
	var holder: Node = inst.get_node_or_null(MOUNTAIN_GRAPH_NODE)
	var graph: Resource = holder.get("graph") if holder != null else null
	if graph == null:
		push_warning("[MapMountain] %s has no graph at %s — the bridge changed the scene's shape"
				% [MOUNTAIN_SCENE, MOUNTAIN_GRAPH_NODE])
	# The graph is a Resource and outlives the scene it came in; the nodes do not.
	inst.free()
	return graph


func get_mountain() -> Node:
	return _mountain


# ---- a CLOUD BANK around the mountain's midsection (SunshineClouds2) -----------
#
# The mechanism — how a bank is localised, why it goes on the GAME'S SHARED SKY and
# not a map-owned WorldEnvironment, the donut mask — lives in CloudBank
# (scenes/maps/cloud_bank.gd, moved out of here 2026-10-06 so the Geiss maps share
# it). These exports configure the mountain's: a donut around the massif.
@export_group("Cloud bank")
@export var clouds_enabled: bool = true
## The whole cloud look: every SunshineCloudsGD export, editable here.
## This SAME instance goes into the shared sky's compositor (never a duplicate),
## so editing it in the Remote inspector while the game runs changes the live
## clouds. Tuned values live in res://scenes/maps/mountain_clouds.tres; if this
## is cleared, CloudBank.default_resource() builds an equivalent one in code.
## ⚠ The noise textures and the mask are bound into GPU uniform sets once; after
## swapping a TEXTURE live, press the resource's "Refresh Compute" button.
@export var clouds_resource: SunshineCloudsGD
## WHERE CLOUDS FORM — a donut around the mountain, metres from the map origin:
##   inside inner_radius    the cloud bank
##   between the two        clear, no clouds
##   outside outer_radius   background clouds, all the way out
## Each radius is the MIDDLE of a soft edge `fade` metres wide.
## The mask is GENERATED from these when the map loads (CloudBank) and
## replaces any mask the .tres holds. Change them, then reload the map.
## (No background: set outer_radius beyond what you can see, ~20000.)
@export var clouds_inner_radius: float = 3500.0
@export var clouds_outer_radius: float = 11500.0
@export var clouds_fade: float = 2000.0
## Optional density boost around the mountain centre (SunshineCloudsEffector,
## world metres). Adds Power*(1 - d/Radius) to the cloud shape inside the sphere;
## negative carves clouds away. Keep |Power| well under 1 or it erases the noise
## definition inside the sphere. 0 = off.
@export var clouds_effector_radius: float = 4000.0
@export_range(-100.0, 100.0) var clouds_effector_power: float = 0.0
## Wind: noise offsets move at direction * speed, in world m/s (driver._process).
## Scaled to this bank's km-sized features, not the addon's 100 m/s sky layer.
## (No extra-large speed: in mask mode the mask is pinned to origin_offset.)
@export var clouds_wind_direction: Vector3 = Vector3(0.7071, 0.0, 0.7071)
@export var clouds_wind_large: float = 6.0
@export var clouds_wind_medium: float = 3.0
@export var clouds_wind_small: float = 1.5
@export_group("")

const _CloudBank := preload("res://scenes/maps/cloud_bank.gd")


## The bank is a CloudBank child: it joins the shared sky's compositor in its _ready
## and leaves it in its _exit_tree, with the map.
func _setup_cloud_bank() -> void:
	if not clouds_enabled:
		return
	var cb: Node = _CloudBank.new()
	cb.name = "CloudBank"
	cb.set("resource", clouds_resource)
	cb.set("footprint", _CloudBank.DONUT)
	cb.set("inner_radius", clouds_inner_radius)
	cb.set("outer_radius", clouds_outer_radius)
	cb.set("fade", clouds_fade)
	cb.set("effector_radius", clouds_effector_radius)
	cb.set("effector_power", clouds_effector_power)
	cb.set("wind_direction", clouds_wind_direction)
	cb.set("wind_large", clouds_wind_large)
	cb.set("wind_medium", clouds_wind_medium)
	cb.set("wind_small", clouds_wind_small)
	cb.set("log_tag", "MapMountain")
	add_child(cb)
	# The live instance, for the Remote inspector (the bank fills it when null).
	clouds_resource = cb.get("resource")


func _exit_tree() -> void:
	# (The cloud bank takes its effect off the shared sky in its own _exit_tree.)
	var dock := get_node_or_null("/root/BuildDock")
	if dock != null and dock.has_method("unregister"):
		dock.unregister("mountain")
	super._exit_tree()


# ---- in-game Mountain panel: live material A/B --------------------------------
#
# Both apply LIVE, no reload: DCTerrain.set_custom_material re-applies to every
# existing chunk (dc_terrain.cpp set_custom_material), and bb_aa_strength is a
# uniform on that one shared material (bridged material, blender-bridge v0.166+).

var _bridged_mat: Material = null
var _plain_mat: StandardMaterial3D = null
var _aa_check: CheckButton = null
var _bake_check: CheckButton = null


func _register_mountain_panel() -> void:
	var dock := get_node_or_null("/root/BuildDock")
	if dock == null or not dock.has_method("register") or _mountain == null:
		return
	var cur: Material = _mountain.get("custom_material")
	if not mountain_plain_material:
		_bridged_mat = cur
		if _full_mat == null:
			_full_mat = cur
	var body := VBoxContainer.new()
	var plain := CheckButton.new()
	plain.text = "Plain material (perf test)"
	plain.button_pressed = mountain_plain_material
	plain.toggled.connect(_on_plain_toggled)
	body.add_child(plain)
	var bake := CheckButton.new()
	_bake_check = bake
	bake.text = "Mesh bake (per-vertex material, #90)"
	bake.button_pressed = not _mesh_bake.is_empty()
	bake.disabled = not (_full_mat is ShaderMaterial and _full_mat.has_meta("bb_mesh_bake"))
	bake.tooltip_text = ("On = noise evaluated per vertex at meshing (slim material); off = the full per-pixel material. The terrain remeshes on each switch."
			if not bake.disabled else "This material carries no mesh bake -- republish with blender-bridge >= 0.168.0.")
	bake.toggled.connect(_on_mesh_bake_toggled)
	body.add_child(bake)
	_aa_check = CheckButton.new()
	_aa_check.text = "Anti-alias noise (bb_aa_strength)"
	_aa_check.button_pressed = _aa_strength() > 0.5
	_aa_check.disabled = not _has_aa()
	_aa_check.tooltip_text = ("On = octave fade (1), off = old look (0)." if _has_aa()
			else "This material has no bb_aa_strength — republish with blender-bridge v0.166+.")
	_aa_check.toggled.connect(_on_aa_toggled)
	body.add_child(_aa_check)
	var dbg := OptionButton.new()
	for label in DEBUG_VIEWS:
		dbg.add_item(label)
	dbg.disabled = not (_full_mat is ShaderMaterial)
	dbg.tooltip_text = "Which part of the material makes an artefact: switch outputs off one at a time."
	dbg.item_selected.connect(_on_debug_view)
	body.add_child(dbg)
	dock.register("mountain", "Mountain", body, true)


func _has_aa() -> bool:
	return _bridged_mat is ShaderMaterial and \
			(_bridged_mat as ShaderMaterial).shader != null and \
			(_bridged_mat as ShaderMaterial).shader.code.contains("bb_aa_strength")


func _aa_strength() -> float:
	if not _has_aa():
		return 0.0
	var v: Variant = (_bridged_mat as ShaderMaterial).get_shader_parameter("bb_aa_strength")
	return 1.0 if v == null else float(v)   # null = the uniform's default, 1.0


func _on_plain_toggled(on: bool) -> void:
	if _mountain == null or not is_instance_valid(_mountain):
		return
	if on:
		if _plain_mat == null:
			_plain_mat = StandardMaterial3D.new()
			_plain_mat.albedo_color = Color(0.55, 0.52, 0.48)
			_plain_mat.roughness = 0.9
		_mountain.set("custom_material", _plain_mat)
	elif _bridged_mat != null:
		_mountain.set("custom_material", _bridged_mat)
	else:
		print("[MapMountain] map loaded with the plain material — reload with it off to get the bridged one")
	print("[MapMountain] material -> %s" % ("PLAIN" if on else "bridged"))


## LIVE A/B of the mesh-time bake: on = kernel + slim material, off = no kernel +
## the full material. Each switch remeshes the terrain (set_material_kernel rebuilds).
func _on_mesh_bake_toggled(on: bool) -> void:
	if _mountain == null or not is_instance_valid(_mountain) or not (_full_mat is ShaderMaterial):
		return
	mountain_mesh_bake = on
	if on:
		_bridged_mat = _apply_mesh_bake(_full_mat)
	else:
		_mountain.call("set_material_kernel", "")
		_mountain.set("custom_material", _full_mat)
		_bridged_mat = _full_mat
		_mesh_bake = {}
	if _bake_check != null and is_instance_valid(_bake_check):
		_bake_check.set_pressed_no_signal(not _mesh_bake.is_empty())
	print("[MapMountain] mesh bake -> %s" % ("ON (slim + kernel)" if not _mesh_bake.is_empty() else "OFF (full per-pixel)"))


## mountain_terrain_material resolved: a forced mode, else the player's setting.
func _wants_mesh_bake() -> bool:
	match mountain_terrain_material:
		1: return false
		2: return true
	return int(Settings.get_value("terrain_material")) == 1


## The settings tab moved "terrain_material": follow it live, unless forced.
func _on_setting_changed(key: String, _value: Variant) -> void:
	if key != "terrain_material" or mountain_terrain_material != 0:
		return
	var want := _wants_mesh_bake()
	if want != (not _mesh_bake.is_empty()):
		_on_mesh_bake_toggled(want)


func _on_aa_toggled(on: bool) -> void:
	if _has_aa():
		(_bridged_mat as ShaderMaterial).set_shader_parameter("bb_aa_strength", 1.0 if on else 0.0)
		if _full_mat is ShaderMaterial and _full_mat != _bridged_mat:
			(_full_mat as ShaderMaterial).set_shader_parameter("bb_aa_strength", 1.0 if on else 0.0)
		if _debug_mat != null:
			_debug_mat.set_shader_parameter("bb_aa_strength", 1.0 if on else 0.0)
		# The kernel holds bb_aa_strength as a CONSTANT: re-set it (the terrain
		# remeshes, which is what re-bakes the per-vertex values).
		if not _mesh_bake.is_empty() and _mountain != null and is_instance_valid(_mountain):
			var kernel := _mesh_bake_kernel(_mesh_bake, _bridged_mat as ShaderMaterial)
			if not kernel.is_empty():
				_mountain.call("set_material_kernel", kernel)
		print("[MapMountain] bb_aa_strength -> %.0f" % (1.0 if on else 0.0))


## ARTEFACT ISOLATION. A copy of the bridged shader whose four outputs are each
## gated by one uniform, so the in-game view can drop them one at a time. The
## gate wraps only the final ASSIGNMENTS at the end of fragment(): every value
## (and every dFdx) is still computed exactly as in the real material, outside
## any branch. A skipped NORMAL leaves Godot's interpolated geometric normal.
const DEBUG_VIEWS := ["Debug: full material", "Debug: no bump (geometric normal)",
		"Debug: grey albedo (bump kept)", "Debug: colour only, unlit", "Debug: fixed roughness 0.8",
		"Debug: bump Filter Width FD off (analytic only)", "Debug: mesh-normal dFdx off",
		"Debug: both FD terms off",
		"Show (unlit): geometric normal", "Show (unlit): rock noise", "Show (unlit): MicroRelief normal",
		"Show (unlit): SlopeDot", "Show (unlit): snow mask", "Show (unlit): final colour"]
## Unlit views 8+: the value is written to EMISSION with albedo, specular and
## metallic zeroed and roughness 1, so NO lighting touches it. Ordered along the
## graph (geometry -> rock noise -> MicroRelief bump -> SlopeDot -> snow mask ->
## colour): the first view that shows an artefact is where it enters.
const SHOW_EXPRS := {
	8: "n_out5p1 * 0.5 + 0.5", 9: "vec3(n_out31p0)", 10: "n_out18p0 * 0.5 + 0.5",
	11: "vec3(n_out7p0)", 12: "vec3(n_out30p0)", 13: "n_out26p0",
}
var _debug_mat: ShaderMaterial = null


func _on_debug_view(idx: int) -> void:
	if _mountain == null or not is_instance_valid(_mountain) or not (_full_mat is ShaderMaterial):
		return
	if idx == 0:
		_mountain.set("custom_material", _bridged_mat)
		print("[MapMountain] debug view -> full material")
		return
	if _debug_mat == null:
		# From the FULL material: SHOW_EXPRS name its variables, and the slim
		# one's ids differ (its bands are inlined). The kernel stays set; the
		# full material simply ignores CUSTOM2.
		_debug_mat = _build_debug_material(_full_mat as ShaderMaterial)
		if _debug_mat == null:
			return
	_debug_mat.set_shader_parameter("mtn_debug", idx)
	_mountain.set("custom_material", _debug_mat)
	print("[MapMountain] debug view -> %s" % DEBUG_VIEWS[idx])


func _build_debug_material(src: ShaderMaterial) -> ShaderMaterial:
	var code: String = src.shader.code
	var outs := {
		"\tALBEDO = ": "\tif (mtn_debug == 2) ALBEDO = vec3(0.6); else if (mtn_debug == 3) { ALBEDO = vec3(0.0); EMISSION = %s; } else ALBEDO = ",
		"\tROUGHNESS = ": "\tif (mtn_debug == 4) ROUGHNESS = 0.8; else ROUGHNESS = ",
		"\tNORMAL = ": "\tif (mtn_debug != 1) NORMAL = ",
	}
	for k in outs:
		if code.count(k) != 1:
			push_warning("[MapMountain] debug view: expected one '%s' in the shader, found %d — not built"
					% [k.strip_edges(), code.count(k)])
			return null
	# ALBEDO's RHS is needed twice (emission); read it before rewriting.
	var a0 := code.find("\tALBEDO = ") + 10
	var albedo_rhs := code.substr(a0, code.find(";", a0) - a0)
	code = code.replace("\tALBEDO = ", String(outs["\tALBEDO = "]) % albedo_rhs)
	code = code.replace("\tROUGHNESS = ", outs["\tROUGHNESS = "])
	code = code.replace("\tNORMAL = ", outs["\tNORMAL = "])
	# The two screen-space finite differences inside the bridged Bump (v0.165):
	# 5 = Blender Filter Width's blend of dFdx(height) into the analytic gradient,
	# 6 = dFdx/dFdy of the mesh's interpolated normal. Both derivatives are still
	# COMPUTED unconditionally; only the value used is switched (uniform branch).
	var re_fw := RegEx.new()
	re_fw.compile("(vec2 _dh = \\S+ \\+ )clamp\\(([^;]*?), 0\\.0, 1\\.0\\)")
	var n_fw := re_fw.search_all(code).size()
	code = re_fw.sub(code, "$1((mtn_debug == 5 || mtn_debug == 7) ? 0.0 : clamp($2, 0.0, 1.0))", true)
	var n_gx := code.count("vec3 _gx = dFdx(_gwn);")
	code = code.replace("vec3 _gx = dFdx(_gwn);", "vec3 _gx = dFdx(_gwn); if (mtn_debug >= 6) _gx = vec3(0.0);")
	code = code.replace("vec3 _gy = dFdy(_gwn);", "vec3 _gy = dFdy(_gwn); if (mtn_debug >= 6) _gy = vec3(0.0);")
	print("[MapMountain] debug material: %d Filter-Width blend(s), %d mesh-normal derivative site(s) switchable" % [n_fw, n_gx])
	# Unlit value views, appended after the (gated) NORMAL write.
	var show := ""
	for k in SHOW_EXPRS:
		var ex: String = SHOW_EXPRS[k]
		var v := RegEx.create_from_string("n_(out|in)\\d+p\\d+").search(ex).get_string()
		if code.find(v) >= 0:
			show += "\tif (mtn_debug == %d) { ALBEDO = vec3(0.0); METALLIC = 0.0; SPECULAR = 0.0; ROUGHNESS = 1.0; EMISSION = clamp(%s, 0.0, 1.0); }\n" % [k, ex]
		else:
			push_warning("[MapMountain] debug view %d: %s not in this shader — skipped" % [k, v])
	var ni := code.find("\tif (mtn_debug != 1) NORMAL = ")
	var ne := code.find("\n", ni) + 1
	code = code.substr(0, ne) + show + code.substr(ne)
	var fi := code.find("void fragment()")
	code = code.substr(0, fi) + "uniform int mtn_debug = 0;\n\n" + code.substr(fi)
	var sh := Shader.new()
	sh.code = code
	var mat := src.duplicate() as ShaderMaterial   # keeps every parameter value
	mat.shader = sh
	return mat


# ---- TEMP instrument (2026-09-28): does the clipmap settle, and is a carve
# queued behind a backlog? Remove once the carve-latency question is answered.
var _stat_t := 0.0
var _stat_prev_chunks := -1
## Worst frame in the window: a hitch while running does not show in an fps
## average, and "lag" is usually hitches.
var _stat_worst := 0.0
var _stat_measuring := false
var _stat_last_us := 0


## Worst frame by the WALL CLOCK, never by delta: the engine clamps delta when a
## frame needs more than max_physics_steps_per_frame physics steps (main.cpp
## cuts process_step to 8 × the physics step, ~133 ms at 60 Hz), so max(delta)
## printed worst=135ms for a 4117 ms stall (#93 §2, gate_mountain_stats_worst).
func _stat_note_frame() -> void:
	var now := Time.get_ticks_usec()
	if _stat_last_us > 0:
		_stat_worst = maxf(_stat_worst, (now - _stat_last_us) / 1.0e6)
	_stat_last_us = now


func _process(delta: float) -> void:
	super._process(delta)
	_stat_t += delta
	_stat_note_frame()
	if not _stat_measuring:
		# CPU vs GPU time of the main viewport — which side the frame waits on.
		RenderingServer.viewport_set_measure_render_time(get_viewport().get_viewport_rid(), true)
		_stat_measuring = true
	if _stat_t < 2.0 or _mountain == null or not is_instance_valid(_mountain):
		return
	_stat_t = 0.0
	var vp := get_viewport().get_viewport_rid()
	var c: int = int(_mountain.call("get_chunk_count"))
	var p: int = int(_mountain.call("get_pending_count"))
	print("[MountainStats] chunks=%d (%+d/2s) pending=%d fps=%d worst=%.0fms | cpu %.1fms gpu %.1fms process %.1fms | draws %d tris %.1fM" % [
			c, (c - _stat_prev_chunks) if _stat_prev_chunks >= 0 else 0, p,
			int(Engine.get_frames_per_second()), _stat_worst * 1000.0,
			RenderingServer.viewport_get_measured_render_time_cpu(vp),
			RenderingServer.viewport_get_measured_render_time_gpu(vp),
			Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0,
			int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)),
			Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME) / 1.0e6])
	_stat_prev_chunks = c
	_stat_worst = 0.0


# ---- the mountain FROM ITS MESH ----------------------------------------------

## WHERE THE MOUNTAIN'S SHAPE COMES FROM.
##
## "mesh" (default): the island IS the graph's evaluated MESH -- evaluated with
## its current parameters and stamped into an otherwise EMPTY terrain as one
## persistent mesh-SDF edit, the same GPU machinery as map_dc_world's level shell.
## CSG carves apply on top.
## "graph_density": the old path -- the graph's separate `Density` output, a
## ~102-node noise function sharing NOTHING with the mesh chain, so its shape
## never matched the Blender mesh (hyper-games #85 §3b). Kept to compare.
##
## 2026-09-28: DEFAULT BACK TO "graph_density". voronoi5's Density output was
## rebuilt from the MESH's own fields (F_MESH_DENSITY frame in the .blend):
## min(100(top - z), 100(z - underside), 100·coast) — the same Math.038 / Math.019 /
## Math.032 the mesh's Set Position and Delete use, all functions of x,y — with the
## carve/worm/room caves min'd on behind the "Caves" input (> 0.5 = on). Same shape
## as the mesh, but a cheap GPU formula: no BVH, so carves cost what they did.
## "mesh" stays as the reference to compare the fit against.
@export_enum("mesh", "graph_density") var mountain_density_source: String = "graph_density"


func _setup_mountain_from_mesh() -> void:
	if not ClassDB.class_exists("DCTerrainGeneratorEmpty"):
		push_warning("[MapMountain] DCTerrainGeneratorEmpty missing — the dc-terrain " +
				"extension is stale. No mountain.")
		return
	var t0 := Time.get_ticks_msec()
	var tri: Dictionary = _evaluate_mountain_mesh()
	if tri.is_empty():
		return
	var verts: PackedVector3Array = tri["verts"]
	var idx: PackedInt32Array = tri["indices"]

	# ⛔ WINDING IS MEASURED, NOT ASSUMED. The stamp's pseudonormal SDF reads a
	# Godot-outward mesh (signed volume NEGATIVE — Blender's is positive) as
	# positive-INSIDE, which is what strength +1 = max(d, mesh) needs to ADD solid.
	# ProcGeometryNode already reverses a Blender graph's faces as they enter, so
	# this should hold; if it does not, the island would stamp as an inverted
	# world, so refuse rather than guess.
	var vol := 0.0
	for i in range(0, idx.size(), 3):
		vol += verts[idx[i]].dot(verts[idx[i + 1]].cross(verts[idx[i + 2]]))
	vol /= 6.0
	if vol >= 0.0:
		push_error("[MapMountain] evaluated mesh has signed volume %.0f (>= 0): wound " % vol +
				"Blender-outward, would stamp inside-out. Not stamping.")
		return

	# Material, voxel and collision exactly as the density path sets them.
	var mountain_mat: Material = _load_mountain_material()
	if mountain_mat != null:
		_mountain.set("custom_material", mountain_mat)
		if mountain_mat is ShaderMaterial and mountain_world_scale > 0.0:
			(mountain_mat as ShaderMaterial).set_shader_parameter(
					"bb_coord_scale", 1.0 / mountain_world_scale)
		_apply_mesh_bake(mountain_mat)
	_mountain.set("voxel_size", mountain_voxel_size if mountain_voxel_size > 0.0 else 0.224)
	var chunk_span: float = float(_mountain.get("voxel_size")) * 32.0
	_mountain.set("collision_distance",
			maxf(float(_mountain.get("collision_distance")), chunk_span * 4.0))

	_mountain.set("generator", ClassDB.instantiate("DCTerrainGeneratorEmpty"))
	# `centre` = the mesh's own float32 centroid, which stamp_mesh_arrays re-centres
	# on (map_dc_world's shell learned this: anything else translates the mesh).
	# persistent=true: map-authored landscape, must survive clear_edits() on every
	# map_changing. interactive=false: derived, not a live carve.
	var centre := Vector3.ZERO
	for v in verts:
		centre += v
	centre /= float(verts.size())
	(_mountain as Node).call("stamp_mesh_arrays", verts, idx, centre, 1.0, 1.0, false, 0, true)
	print("[MapMountain] mountain FROM MESH: %d verts, %d tris, signed volume %.0f m^3, " % [
			verts.size(), idx.size() / 3, vol] +
			"voxel %.3f m, %d ms (evaluate + stamp)" % [
			float(_mountain.get("voxel_size")), Time.get_ticks_msec() - t0])


## The bridged graph's MESH product, triangulated, in this map's space.
## Same path as scenes/test/mountain_density_vs_mesh.gd: instantiate the bridged
## scene, evaluate() explicitly (auto_update only runs in NOTIFICATION_PROCESS),
## read MeshOutput. The scene is parented at this map's origin while evaluating so
## MeshOutput's global transform is the map-space placement, then freed.
func _evaluate_mountain_mesh() -> Dictionary:
	if not ResourceLoader.exists(MOUNTAIN_SCENE):
		push_warning("[MapMountain] %s is missing — no mountain." % MOUNTAIN_SCENE)
		return {}
	var side: Node3D = (load(MOUNTAIN_SCENE) as PackedScene).instantiate()
	side.visible = false
	# NO PROCESSING: ProcGeometryNode's auto_update evaluates in NOTIFICATION_PROCESS,
	# which would run the whole ~8 s chain a SECOND time before a queue_free landed.
	side.process_mode = Node.PROCESS_MODE_DISABLED
	add_child(side)
	var pg: Node = side.get_node_or_null(MOUNTAIN_GRAPH_NODE)
	if pg == null:
		push_warning("[MapMountain] %s has no %s" % [MOUNTAIN_SCENE, MOUNTAIN_GRAPH_NODE])
		side.queue_free()
		return {}
	pg.call("evaluate")
	var mo: Node3D = pg.get_node_or_null(^"MeshOutput") as Node3D
	var m: Mesh = mo.get("mesh") if mo != null else null
	if m == null or m.get_surface_count() == 0:
		push_warning("[MapMountain] evaluate() produced no mesh")
		side.queue_free()
		return {}
	var xf: Transform3D = global_transform.affine_inverse() * mo.global_transform
	var verts := PackedVector3Array()
	var idx := PackedInt32Array()
	for s in m.get_surface_count():
		var a: Array = m.surface_get_arrays(s)
		var sv: PackedVector3Array = a[Mesh.ARRAY_VERTEX]
		var si: PackedInt32Array = a[Mesh.ARRAY_INDEX] if a[Mesh.ARRAY_INDEX] != null else PackedInt32Array()
		var base := verts.size()
		for v in sv:
			verts.push_back(xf * v)
		if si.is_empty():                       # non-indexed draw form: consecutive triples
			for i in sv.size():
				idx.push_back(base + i)
		else:
			for i in si:
				idx.push_back(base + i)
	remove_child(side)
	side.free()
	return {"verts": verts, "indices": idx}