extends RefCounted
## Builds the active map from data/maps.json: sky, light, ground, town (Aldoria only), trees, rocks,
## landmarks, warp portals and zone signs. set_map() picks the map; everything else (zones, music
## areas, town checks, portals) answers for that map.
const GfxLook = preload("res://scripts/gfx_look.gd")

const Chibi = preload("res://scripts/chibi.gd")
const EnvNature = preload("res://scripts/env_nature.gd")
const EnvTown = preload("res://scripts/env_town.gd")
const EnvLayout = preload("res://scripts/env_layout.gd")
const EnvDecor = preload("res://scripts/env_decor.gd")
const EnvFps = preload("res://scripts/env_fps.gd")
const PortalScript = preload("res://scripts/portal.gd")
const EnvCave = preload("res://scripts/env_cave.gd")
const EnvHouse = preload("res://scripts/env_house.gd")
const EnvTime = preload("res://scripts/env_time.gd")
const EnvAssets = preload("res://scripts/env_assets.gd")
const EnvVillage = preload("res://scripts/env_village.gd")
const EnvLife = preload("res://scripts/env_life.gd")

## Aldoria (map 1): the save town. New characters, Return Wings and deaths go back here by default.
const TOWN_CENTER := Vector3(-48, 0, 0)
const TOWN_HALF := 17.0
const TOWN_SPAWN := Vector3(-40, 0, 2)
## Aldoria cottages built from the CC0 modular village kit (env_house.gd), one per house spot in
## _town(): wanted footprint w x d (final metres, snapped to 3.4 / 5.1 m), storeys, roof tint, wall
## look, upper balcony. Same size class as the old 5 x 4 m houses; two of them get an upper floor.
## Roofs (EnvHouse.ROOF_TINTS / ROOF_RECOLOR): terracotta, slate, honey, deep red, warm brown, slate.
const HOUSE_PLAN := [
	{"w": 5, "d": 3.4, "floors": 2, "roof": 0, "walls": 0, "balcony": true},
	{"w": 5, "d": 3.4, "floors": 1, "roof": 4, "walls": 2},
	{"w": 5, "d": 3.4, "floors": 1, "roof": 3, "walls": 1},
	{"w": 5, "d": 3.4, "floors": 2, "roof": 5, "walls": 3},
	{"w": 5, "d": 3.4, "floors": 1, "roof": 1, "walls": 0},
	{"w": 3.4, "d": 5, "floors": 1, "roof": 4, "walls": 2},
]

## Active map definition (data/maps.json entry) + converted zone / portal lists.
static var current: Dictionary = {}
static var _zones: Array = []
static var _portals: Array = []


static func _v3(a) -> Vector3:
	return Vector3(float(a[0]), 0, float(a[1]))


## Makes `id` the active map (data only; build() draws it). Unknown ids fall back to the start map.
static func set_map(id: String) -> void:
	current = GameData.map(id)
	if current.is_empty():
		current = GameData.map(GameData.start_map())
	EnvLayout.configure(current)
	EnvCave.configure(current)
	# Hanamura-style village towns (town.style "village"): layout + grass-free footprints
	EnvVillage.configure(current)
	EnvNature.map_size = float(current.get("size", 140))
	_zones = []
	for z in current.get("zones", []):
		var d: Dictionary = (z as Dictionary).duplicate()
		d["center"] = _v3(z["center"])
		d["radius"] = float(z["radius"])
		_zones.append(d)
	_portals = []
	for pt in current.get("portals", []):
		_portals.append({"to_map": str(pt["to_map"]), "at": _v3(pt["at"]), "arrive_at": _v3(pt["arrive_at"]),
			"label": str(pt.get("label", "To " + GameData.map_name(str(pt["to_map"])))),
			"no_clear": bool(pt.get("no_clear", false))})


static func _ensure() -> void:
	if current.is_empty():
		set_map(GameData.start_map())


static func map_id() -> String:
	_ensure()
	return str(current["id"])


static func map_name() -> String:
	_ensure()
	return str(current.get("name", ""))


static func map_half() -> float:
	_ensure()
	return float(current.get("size", 140)) / 2.0


## Arrival point for a fresh start / unknown position on this map.
static func spawn_point() -> Vector3:
	_ensure()
	return _v3(current.get("spawn", [0, 0]))


## Monster zones of the active map: {name, center: Vector3, radius, music?, mobs: [{id, count}]}.
static func zones() -> Array:
	_ensure()
	return _zones


## Warp portals of the active map: {to_map, at: Vector3, arrive_at: Vector3, label}.
static func portals() -> Array:
	_ensure()
	return _portals


## Dungeon map (env.kind "cave"): walkable floor + rock (env_cave.gd).
static func is_cave() -> bool:
	_ensure()
	return EnvCave.active()


## False on cave rock; always true on the open maps.
static func walkable(p: Vector3) -> bool:
	_ensure()
	return not EnvCave.active() or EnvCave.walkable(p)


## p itself on open maps / cave floor, else the nearest cave floor spot.
static func nearest_walkable(p: Vector3) -> Vector3:
	_ensure()
	return EnvCave.nearest_walkable(p) if EnvCave.active() else p


## Waypoints from a to b around cave walls ([] = straight line is fine / no route).
static func route(a: Vector3, b: Vector3) -> PackedVector3Array:
	_ensure()
	if not EnvCave.active() or EnvCave.line_walkable(a, b):
		return PackedVector3Array()
	return EnvCave.route(a, b)


static func has_town() -> bool:
	_ensure()
	return current.has("town")


static func town_center() -> Vector3:
	return _v3(current["town"]["center"]) if has_town() else TOWN_CENTER


static func town_half() -> float:
	return float(current["town"].get("half", TOWN_HALF)) if has_town() else TOWN_HALF


## Music key for a world position: the town's key in town, else the "music" of the nearest zone
## that has one, else the map's own key ("field" / "forest" / "desert").
static func music_area(p: Vector3) -> String:
	if in_town(p):
		return str(current["town"].get("music", "town"))
	var best := str(current.get("music", "field"))
	var bd := 1e9
	for z in zones():
		if not z.has("music"):
			continue
		var d := p.distance_to(z["center"])
		if d < bd:
			bd = d
			best = str(z["music"])
	return best


static func in_town(p: Vector3) -> bool:
	if not has_town():
		return false
	var c := town_center()
	var h := town_half()
	return absf(p.x - c.x) < h + 3.0 and absf(p.z - c.z) < h + 3.0


## Builds the active map's scenery under `root` (which must already be inside the tree).
## Returns {"trees": n, "rocks": n} for the self-test.
static func build(root: Node3D) -> Dictionary:
	_ensure()
	EnvFps.marks.clear()
	EnvFps.mark("start")
	_environment(root)
	EnvFps.mark("sky")
	# parse this map's CC0 models on worker threads up front (most of the scenery build time)
	EnvAssets.preload_models(_cc0_models() + load("res://scripts/env_field_dress.gd").models(map_id(), is_cave()))
	EnvFps.mark("cc0-models")
	if is_cave():
		return _build_cave(root)
	# trees/rocks are placed first: their positions feed the terrain shade map
	var all := _placements(root)
	var nat := {"trees": _kept(all["trees"]), "rocks": _kept(all["rocks"])}
	var avoid := []
	for z in zones():
		avoid.append(z["center"])
	for pt in portals():
		avoid.append(pt["at"])
		avoid.append(pt["arrive_at"])
	var decor := EnvDecor.plan(avoid)
	var extra := EnvDecor.shade_blobs(decor)
	EnvFps.mark("placements")
	EnvNature.bake_shade(nat["trees"] + extra[0], nat["rocks"] + extra[1])
	EnvFps.mark("shade")
	_ground(root)
	EnvFps.mark("ground+hills")
	if has_town():
		_town(root)
		# ambient life: butterflies by day, fireflies at night (town graphics pass 2026-10-01)
		EnvLife.add(root, town_center(), town_half(), str(current.get("id", "")).hash(), str(current.get("id", "")))
	EnvFps.mark("town")
	_nature(root, nat)
	EnvDecor.build(root, decor)
	EnvFps.mark("nature")
	root.add_child(EnvFps.new())
	for z in zones():
		if str(z["name"]) != "":
			_sign(root, z["center"] + Vector3(0, 0, -z["radius"] * 0.4), str(z["name"]))
		if z.has("warning"):
			# classic-faithful danger (no nerf): a red warning post at the zone edge facing the map's arrival point
			var to_sp: Vector3 = spawn_point() - z["center"]
			to_sp.y = 0.0
			_warning_sign(root, z["center"] + to_sp.normalized() * (float(z["radius"]) + 1.5), str(z["warning"]), atan2(to_sp.x, to_sp.z))
	for pt in portals():
		var po = PortalScript.new()
		po.label = str(pt["label"])
		root.add_child(po)
		po.position = pt["at"]
	if not has_town():
		_field_dress(root, false, nat["trees"], nat["rocks"])
	return {"trees": (nat["trees"] as Array).size(), "rocks": (nat["rocks"] as Array).size()}


## CC0 models the active map's scenery will use (see env_house / env_town / env_nature / env_decor / env_cave).
static func _cc0_models() -> Array:
	var ids: Array = ["torch_metal", "mushroom_common", "mushroom_laetiporus", "rock_medium_1", "rock_medium_2",
		"rock_medium_3", "barrel", "crate_wooden", "pouch_large"]
	if is_cave():
		return ids
	ids += EnvNature.CC0_TREES + EnvNature.CC0_PINES + ["bush_common", "bush_common_flowers", "fern_1", "dead_tree_1",
		"twisted_tree_1", "stall_cart_empty", "prop_wagon", "bag", "flower_3_group", "flower_4_group", "plant_1_big",
		"barrel_apples", "farm_crate_apple", "prop_wooden_fence_single", "wall_uneven_brick_straight", "prop_vine1"]
	if not EnvDecor.extra_landmarks().is_empty() and str(EnvDecor.extra_landmarks()[0]["kind"]) != "bone_field":
		ids += EnvHouse.REQUIRED
	if EnvVillage.active:
		ids += EnvHouse.REQUIRED + ["wall_plaster_wood_grid"] + EnvVillage.models()
	elif has_town():
		ids += EnvHouse.REQUIRED + ["wall_uneven_brick_straight", "wall_plaster_wood_grid", "window_shutters_wide_round_open",
			"prop_chimney", "prop_vine1", "balcony_simple_straight", "lantern_wall"] + EnvTown.PROP_IDS + load("res://scripts/env_dress.gd").models("aldoria")
	return ids


## Dungeon: flat floor + walls + rock mass, torches, crystals, zone signs and portals (no sky, grass, trees).
static func _build_cave(root: Node3D) -> Dictionary:
	EnvNature.bake_shade([], [])
	_ground(root)
	EnvFps.mark("ground")
	EnvCave.build(root)
	EnvFps.mark("cave")
	root.add_child(EnvFps.new())
	for z in zones():
		if str(z["name"]) != "":
			_sign(root, EnvCave.nearest_walkable(z["center"] + Vector3(0, 0, -z["radius"] * 0.4)), str(z["name"]), 56)
	for pt in portals():
		var po = PortalScript.new()
		po.label = str(pt["label"])
		root.add_child(po)
		po.position = pt["at"]
	_field_dress(root, true)
	return {"trees": 0, "rocks": 0}


## Field / dungeon dressing (env_field_dress.gd, CC0 kits; builds over the next frames): clear of the spawn, the portals
## (and where they land you) and the monster zone centres.
static func _field_dress(root: Node3D, cave: bool, trees: Array = [], rocks: Array = []) -> void:
	var avoid: Array = [spawn_point()]
	var ports: Array = []
	for pt in portals():
		avoid.append(pt["at"])
		avoid.append(pt["arrive_at"])
		ports.append(pt["at"])
	for z in zones():
		avoid.append(z["center"])
	var obs: Array = []
	for t in trees:
		obs.append([t["pos"], 2.6])
	for r in rocks:
		obs.append([r["pos"], 1.2])
	load("res://scripts/env_field_dress.gd").dress(root, map_id(), spawn_point(), ports, avoid, cave, obs)


static func _environment(root: Node3D) -> void:
	# Look target: Draconia Saga — bright pastel day, soft cel shading, soft blue-violet shadows,
	# gentle bloom only on bright VFX, light aerial haze in the distance.
	var we := WorldEnvironment.new()
	var env := Environment.new()
	env.background_mode = Environment.BG_SKY
	var sky := Sky.new()
	var sm := ProceduralSkyMaterial.new()
	sm.sky_top_color = EnvLayout.col("sky_top", Color(0.36, 0.62, 0.98))
	sm.sky_horizon_color = EnvLayout.col("sky_horizon", Color(0.8, 0.9, 1.0))
	sm.sky_curve = 0.12
	sm.ground_horizon_color = sm.sky_horizon_color
	sm.ground_bottom_color = Color(0.5, 0.62, 0.55)
	sm.sun_angle_max = 20.0
	sky.sky_material = sm
	sky.radiance_size = Sky.RADIANCE_SIZE_64
	env.sky = sky
	# ambient: strong blue-violet sky light = the colour of every shadow (STYLE_GUIDE §2-3: never grey)
	env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
	env.ambient_light_color = EnvLayout.col("ambient", Color(0.72, 0.66, 0.91))
	env.ambient_light_sky_contribution = 0.6
	env.ambient_light_energy = 0.6
	env.reflected_light_source = Environment.REFLECTION_SOURCE_DISABLED
	env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
	env.tonemap_exposure = 0.97
	env.tonemap_white = 6.0
	# bloom: high threshold so only HDR VFX (spells, sparks, level-up pillar) glow
	env.glow_enabled = not ("--noglow" in OS.get_cmdline_user_args())
	env.glow_normalized = true
	env.set_glow_level(0, 0.0)
	env.set_glow_level(1, 0.7)
	env.set_glow_level(2, 0.9)
	env.set_glow_level(3, 0.25)
	env.set_glow_level(4, 0.0)
	env.glow_intensity = 0.55
	env.glow_strength = 1.0
	env.glow_bloom = 0.0
	env.glow_hdr_threshold = 1.5
	env.glow_hdr_scale = 2.0
	env.glow_blend_mode = Environment.GLOW_BLEND_MODE_SCREEN
	env.adjustment_enabled = true
	env.adjustment_brightness = 1.02
	env.adjustment_contrast = 1.1
	env.adjustment_saturation = 1.3
	# distance haze (pastel blue)
	env.fog_enabled = true
	env.fog_mode = Environment.FOG_MODE_EXPONENTIAL
	env.fog_light_color = EnvLayout.col("fog", Color(0.81, 0.88, 1.0))
	env.fog_light_energy = 1.0
	env.fog_density = float(EnvLayout.look.get("fog_density", 0.003))
	env.fog_sky_affect = 0.1
	env.fog_aerial_perspective = 0.2
	if EnvLayout.kind() == "cave":
		# underground: no sky, dark violet void, dim cool "cave light" + fog swallowing far chambers
		env.background_mode = Environment.BG_COLOR
		env.background_color = EnvLayout.col("cave_bg", Color(0.05, 0.04, 0.08))
		env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
		env.ambient_light_energy = float(EnvLayout.look.get("ambient_energy", 0.5))
		env.fog_sky_affect = 0.0
		env.fog_aerial_perspective = 0.0
		env.glow_hdr_threshold = 1.1
	we.environment = env
	root.add_child(we)
	var sun := DirectionalLight3D.new()
	sun.name = "Sun"
	sun.rotation_degrees = Vector3(-58, -35, 0)
	sun.light_energy = float(EnvLayout.look.get("sun_energy", 1.25))
	sun.light_color = EnvLayout.col("sun_color", Color(1.0, 0.97, 0.9))
	sun.shadow_enabled = true
	sun.shadow_blur = 0.9
	sun.shadow_opacity = 0.86
	sun.shadow_bias = 0.04
	sun.shadow_normal_bias = 1.2
	sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_2_SPLITS
	sun.directional_shadow_split_1 = 0.25
	sun.directional_shadow_blend_splits = true
	sun.directional_shadow_max_distance = 60.0
	root.add_child(sun)
	# Graphics v2 finishing layer (gfx_look.gd): before env_time / Settings capture the base look
	GfxLook.grade(env, EnvLayout.kind() == "cave")
	# day/night cycle (dungeons: clock only, always cave lighting)
	var tod = EnvTime.new()
	tod.setup(env, sun, sm, EnvLayout.kind() == "cave")
	root.add_child(tod)
	if EnvLayout.kind() != "cave":
		var clouds := GfxLook.clouds(sun, sm)
		if clouds:
			root.add_child(clouds)
	RenderingServer.directional_soft_shadow_filter_set_quality(RenderingServer.SHADOW_QUALITY_SOFT_LOW)
	RenderingServer.directional_shadow_atlas_set_size(4096, true)
	# smooth (not blocky) bloom from the low-res glow levels
	RenderingServer.environment_glow_set_use_bicubic_upscale(true)
	# colour grade + sharpen + soft vignette on top of the 3D view (behind the HUD layers)
	root.add_child(GfxLook.post_layer(env, EnvLayout.kind() == "cave", str(current.get("id", ""))))


static func _static_box(root: Node3D, size: Vector3, pos: Vector3) -> StaticBody3D:
	var sb := StaticBody3D.new()
	sb.collision_layer = 1
	sb.collision_mask = 0
	var cs := CollisionShape3D.new()
	var bs := BoxShape3D.new()
	bs.size = size
	cs.shape = bs
	sb.add_child(cs)
	root.add_child(sb)
	sb.position = pos
	return sb


static func _static_cyl(root: Node3D, r: float, h: float, pos: Vector3) -> StaticBody3D:
	var sb := StaticBody3D.new()
	sb.collision_layer = 1
	sb.collision_mask = 0
	var cs := CollisionShape3D.new()
	var c := CylinderShape3D.new()
	c.radius = r
	c.height = h
	cs.shape = c
	cs.position.y = h / 2.0
	sb.add_child(cs)
	root.add_child(sb)
	sb.position = pos
	return sb


static func _ground(root: Node3D) -> void:
	var size := map_half() * 2.0
	var ground := _static_box(root, Vector3(size, 1, size), Vector3(0, -0.5, 0))
	ground.add_to_group("ground")
	# painted terrain (grass tones, dirt road, cobblestone plaza, pond banks) — one shader, flat at y=0
	var gv := Node3D.new()
	gv.position.y = 0.5
	ground.add_child(gv)
	EnvNature.ground_mesh(gv)
	GfxLook.ground(gv, is_cave(), str(current.get("id", "")))
	if not is_cave():
		EnvNature.hills(root)
	# invisible map walls
	var h := size / 2.0
	_static_box(root, Vector3(size, 4, 1), Vector3(0, 2, -h - 0.5))
	_static_box(root, Vector3(size, 4, 1), Vector3(0, 2, h + 0.5))
	_static_box(root, Vector3(1, 4, size), Vector3(-h - 0.5, 2, 0))
	_static_box(root, Vector3(1, 4, size), Vector3(h + 0.5, 2, 0))


static func _town(root: Node3D) -> void:
	if EnvVillage.active:
		# Hanamura: wooden village (env_village.gd builds the art + the same colliders the server makes)
		EnvVillage.build_town(root)
		var sp: Array = EnvVillage.town.get("sign", [town_center().x, town_center().z - 6.0])
		_sign(root, Vector3(float(sp[0]), 0, float(sp[1])), str(current["town"].get("name", "")))
		return
	# colliders: fountain cylinder + one box per house (5x3x4, wider for the 6 m cottages); art lives in env_town.gd
	var tc := town_center()
	_static_cyl(root, 3.0, 1.0, tc)
	# style guide: warm brown #be8749 / terracotta #c8643c roofs, plus one blue-grey slate
	var roofs := [Color(0.78, 0.39, 0.24), Color(0.75, 0.53, 0.29), Color(0.42, 0.52, 0.66), Color(0.85, 0.47, 0.25),
		Color(0.68, 0.45, 0.28), Color(0.80, 0.36, 0.28)]
	var spots := [Vector3(-60, 0, -12), Vector3(-50, 0, -14), Vector3(-38, 0, -13), Vector3(-60, 0, 12), Vector3(-38, 0, 13), Vector3(-62, 0, 1)]
	var houses := []
	# CC0 modular cottages when the kit is present (front stays at local z = +2 like the old 5x4 box;
	# a bigger footprint grows the collider sideways / backwards only), else the procedural houses
	var modular := EnvHouse.available() and not ("--oldhouses" in OS.get_cmdline_user_args())
	for i in spots.size():
		var p: Vector3 = spots[i]
		var to_c := tc - p
		var ry := atan2(to_c.x, to_c.z)
		var xf := Transform3D(Basis(Vector3.UP, ry), p)
		var size := Vector3(5, 3, 4)
		var cz := 0.0
		var plan: Dictionary = {}
		if modular and i < HOUSE_PLAN.size():
			plan = HOUSE_PLAN[i]
			var fp := EnvHouse.footprint(float(plan["w"]), float(plan["d"]))
			size.x = maxf(5.0, fp.x + 0.3)
			# the cottage front stays where the old house front was (local z = +2)
			cz = 2.0 - fp.y / 2.0
			if fp.y > 4.0:
				size.z = fp.y + 0.3
		var hb := _static_box(root, size, xf * Vector3(0, 1.5, minf(cz, 0.0)))
		hb.rotation.y = ry
		hb.add_to_group("house_colliders")
		houses.append({"xf": xf, "roof": roofs[i % roofs.size()], "i": i, "plan": plan, "cz": cz})
	EnvTown.build(root, tc, houses)
	_sign(root, tc + Vector3(0, 2.5, -6), str(current["town"].get("name", "Aldoria")))


## Seeded tree + rock placement (map 1: same RNG sequence as the original layout, so tree colliders
## stay put). Trees get their collider here; the visuals are drawn later as MultiMeshes.
static func _placements(root: Node3D) -> Dictionary:
	var rng := RandomNumberGenerator.new()
	rng.seed = int(EnvLayout.num("seed", 42))
	var h := map_half() - 2.0
	var placed := 0
	var tries := 0
	var trees := []
	var rocks := []
	var want := int(EnvLayout.num("trees", 230))
	var max_tries := int(EnvLayout.num("tree_tries", 3000))
	var pine_k := EnvLayout.num("pine", 0.5)
	# keep portal spots clear ("portal_clear" metres; 0 would keep an old layout's RNG sequence)
	var clear := EnvLayout.num("portal_clear", 4.0)
	var avoid := []
	for pt in portals():
		# portals added later to an old map ("no_clear") keep its seeded layout; a "clearing" landmark thins it instead
		if pt.get("no_clear", false):
			continue
		avoid.append(pt["at"])
		avoid.append(pt["arrive_at"])
	while placed < want and tries < max_tries:
		tries += 1
		var p := Vector3(rng.randf_range(-h, h), 0, rng.randf_range(-h, h))
		if in_town(p) or _on_road(p):
			continue
		if _near(p, avoid, clear):
			continue
		# keep zone centers fairly open
		var crowded := false
		for z in zones():
			if p.distance_to(z["center"]) < 6.0:
				crowded = true
		if crowded:
			continue
		# same four draws the old Chibi.tree() made: size, colour, kind, rotation
		var th := rng.randf_range(0.8, 1.3)
		rng.randf()
		var pine := rng.randf() < pine_k
		var rot := rng.randf() * TAU
		if EnvLayout.pond_sdf(Vector2(p.x, p.z)) < 1.5:
			continue
		trees.append({"pos": p, "h": th, "pine": pine, "rot": rot})
		placed += 1
	for i in int(EnvLayout.num("rocks", 60)):
		var p := Vector3(rng.randf_range(-h, h), 0, rng.randf_range(-h, h))
		if in_town(p) or _on_road(p) or _near(p, avoid, clear * 0.75):
			continue
		var s := rng.randf_range(0.4, 0.9)
		var dark := rng.randf()
		var rot := rng.randf() * TAU
		if EnvLayout.pond_sdf(Vector2(p.x, p.z)) < 0.5:
			continue
		rocks.append({"pos": p, "s": s, "dark": dark, "rot": rot})
	# scenery density (config.json "env_density"): the full seeded layout is generated as before (same
	# RNG sequence), then thinned evenly; only kept trees get a collider, so no orphan colliders
	for list in [trees, rocks]:
		var pts := []
		for it in list:
			pts.append(it["pos"])
		var keep := EnvLayout.thin_even(pts, EnvLayout.density())
		for i in list.size():
			list[i]["thin"] = not keep[i]
	# code-side landmarks (windmill, ruined arch, bone field) keep their footprint clear of trees/rocks
	for cs in EnvDecor.clear_spots():
		for list in [trees, rocks]:
			for it in list:
				if (it["pos"] as Vector3).distance_to(cs["pos"]) < float(cs["r"]):
					it["thin"] = true
	for t in trees:
		if not t["thin"]:
			_static_cyl(root, 0.45, 2.0, t["pos"])
	return {"trees": trees, "rocks": rocks}


## Items of a _placements() list that survive the density thinning.
static func _kept(list: Array) -> Array:
	return list.filter(func(it: Dictionary) -> bool: return not it.get("thin", false))


## Keep trees/rocks off the road: map 1 keeps its original straight band |z| < road_band.
static func _on_road(p: Vector3) -> bool:
	if EnvLayout.env.has("road_band"):
		return absf(p.z) < EnvLayout.num("road_band", 3.0)
	return EnvLayout.road_sdf(Vector2(p.x, p.z)) < 1.2


static func _near(p: Vector3, pts: Array, r: float) -> bool:
	for c in pts:
		if p.distance_to(c) < r:
			return true
	return false


static func _nature(root: Node3D, nat: Dictionary) -> void:
	var centers := []
	for z in zones():
		centers.append(z["center"])
	for cs in EnvDecor.clear_spots():
		centers.append(cs["pos"])
	EnvNature.build(root, nat["trees"], nat["rocks"], centers)


## Red-and-yellow danger post + red floating label (group "warning_signs").
static func _warning_sign(root: Node3D, pos: Vector3, text: String, face: float = 0.0) -> void:
	var post := Node3D.new()
	post.add_to_group("warning_signs")
	root.add_child(post)
	post.position = Vector3(pos.x, 0, pos.z)
	post.rotation.y = face
	Chibi.add(post, Chibi.cyl(0.08, 0.1, 1.9, 6), Color(0.45, 0.32, 0.24), Vector3(0, 0.95, 0))
	Chibi.add(post, Chibi.box(Vector3(1.5, 0.62, 0.08)), Color(0.95, 0.78, 0.2), Vector3(0, 1.55, 0.06))
	Chibi.add(post, Chibi.box(Vector3(1.3, 0.44, 0.1)), Color(0.78, 0.16, 0.12), Vector3(0, 1.55, 0.07))
	for sx in [-0.5, 0.5]:
		Chibi.add(post, Chibi.sphere(0.13, 6), Color(0.3, 0.2, 0.16), Vector3(sx * 1.1, 0.12, 0.3))
	var l := Label3D.new()
	l.name = "WarningLabel"
	l.text = "! " + text + " !"
	l.font_size = 60
	l.outline_size = 16
	l.pixel_size = 0.01
	l.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	l.modulate = Color(1.0, 0.42, 0.32)
	l.outline_modulate = Color(0.25, 0.04, 0.02)
	post.add_child(l)
	l.position.y = 2.5


static func _sign(root: Node3D, pos: Vector3, text: String, font: int = 96) -> void:
	# little wooden signpost under the floating name (visual only)
	var post := Node3D.new()
	root.add_child(post)
	post.position = Vector3(pos.x, 0, pos.z)
	Chibi.add(post, Chibi.cyl(0.08, 0.1, 1.6, 6), Color(0.62, 0.44, 0.32), Vector3(0, 0.8, 0))
	Chibi.add(post, Chibi.box(Vector3(1.3, 0.45, 0.08)), Color(0.82, 0.64, 0.44), Vector3(0, 1.35, 0.06))
	Chibi.add(post, Chibi.box(Vector3(1.4, 0.08, 0.1)), Color(0.62, 0.44, 0.32), Vector3(0, 1.6, 0.06))
	# Boss 2026-09-30: zone names float no more in the world (clutter) - they show on the full map only
	if not bool(GameData.config.get("world_zone_labels", false)):
		return
	var l := Label3D.new()
	l.text = text
	l.font_size = font
	l.outline_size = 18
	l.pixel_size = 0.01
	l.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	l.modulate = Color(1, 0.97, 0.85)
	root.add_child(l)
	l.position = pos + Vector3(0, 3.5, 0)
