scopedvr-map-editor/src/editor/terrain_tool.gd
2026-10-25 00:55:18 +02:00

657 lines
25 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends Node3D
## Terrain-Pinsel (M2, Karte t_9aba04f1): Höhenfeld heben/senken/glätten im
## 3D-Viewport — im Terrain-Modus (Chef-Grundsatz Sly: maximal easy + Spaß).
##
## Bedienung:
## Linksklick-Ziehen = gewählter Pinsel-Modus (Heben / Senken / Glätten)
## Strg+Linksklick = das Gegenteil (bei Heben senkt er, bei Senken hebt
## er — Invert-Konvention wie Blender/Photoshop; im
## Malen-Modus malt Strg+Klick Gras zurück). Karte
## t_2470de89: die RECHTE Maustaste gehört IMMER dem
## Freelook-Flug — sie startet hier keinen Strich mehr.
## Der ganze Strich = EIN Undo-Eintrag (Strg+Z stellt die Form VOR dem
## Strich wieder her, nicht nur eine Zwischenstufe).
##
## Feedback: Pinsel-Ring folgt dem Cursor AUF dem Gelände (ray-march auf das
## Höhenfeld), das Mesh folgt dem Strich LIVE (Vertices pro Frame neu).
##
## Der Doc wird nur ZUM Strich-Ende verändert (encode → heightmap_r16_b64)
## — während des Strichs lebt ein dekodiertes PackedFloat32Array, das Mesh
## liest daraus. Undo/Redo läuft über die gemeinsame ActionHistory (terrain-Block).
const SMOOTH_RATE_DIVISOR := 20.0 # Stärke-Slider (m/s) → Glätt-Tempo (Anteil/s)
const MODE_LABELS := ["Heben", "Senken", "Glätten", "Malen"]
var main # Hauptfenster (script-erweitert, Variant)
var camera: Camera3D
var view_builder # TerrainMesh-Ansicht (script-erweitert, Variant)
var interaction # Objekt-Interaktion (für Modus-Wechsel)
var active := false
var brush_mode: int = TerrainField.Mode.RAISE
var brush_radius := 12.0 # Meter
## Kante beim Malen: 0 = hart, 1 = ganz weich (Karte 25.10.).
var brush_weichheit := 0.4
var brush_strength := 8.0 # Meter pro Sekunde (Heben/Senken)
var paint_material: int = 0 # Material-ID im Malen-Modus (0=Gras, 1=Sand, 2=Fels)
# Stroke-Zustand
var _stroking := false
var _stroke_mode: int = TerrainField.Mode.RAISE
var _stroke_material: int = 0
var _heights := PackedFloat32Array()
var _mats := PackedByteArray() # Material-Schicht (res²) — seit _refresh_
# _from_doc immer dekodiert (t_55ed041a),
# Mal-Strich schreibt live hinein.
var _res := 0
var _mres := 0 # Raster der Material-Schicht (feines Raster, Karte 24.10.; alt = _res)
var _mix_b := PackedByteArray() # zweites Material je Punkt (weiche Ränder, Karte 25.10.)
var _mix_w := PackedByteArray() # Anteil des zweiten Materials 0–255
var _sx := 0.0
var _sz := 0.0
var _hmin := 0.0
var _hmax := 0.0
var _cursor_valid := false
var _cursor := Vector3.ZERO
# Pinsel-Ring
var _ring: MeshInstance3D
var _ring_radius := -1.0
var _ring_mode := -1
func setup(p_main, p_camera: Camera3D, p_builder, p_interaction) -> void:
main = p_main
camera = p_camera
view_builder = p_builder
interaction = p_interaction
_ensure_ring()
func _ensure_ring() -> void:
if _ring != null and is_instance_valid(_ring):
return
_ring = MeshInstance3D.new()
_ring.visible = false
add_child(_ring)
# ------------------------------------------------------------ Aktivierung ---
## Terrain-Modus einschalten. false = abgelehnt (Auflösungs-Limit) — main
## bleibt dann im Objekt-Modus und erklärt warum.
func activate() -> bool:
var terrain: Dictionary = main.doc.get("terrain", {})
var res := int(terrain.get("resolution", 0))
if res > TerrainField.EDITOR_MAX_RESOLUTION:
main.call("_set_status",
"Diese Map hat eine Terrain-Auflösung von %d — das Editor-MVP formt Terrain nur bis %d (Performance). Neu angelegte Maps erfüllen das automatisch." % [res, TerrainField.EDITOR_MAX_RESOLUTION],
true)
return false
if not _refresh_from_doc():
return false
active = true
_stroking = false
_ring.visible = false
return true
func deactivate() -> void:
active = false
if _stroking:
_end_stroke()
if _ring != null and is_instance_valid(_ring):
_ring.visible = false
## Nach Undo/Redo/Laden/Neue-Map: Feld neu dekodieren (Cache ist sonst alt).
func refresh_from_doc() -> bool:
if not _refresh_from_doc():
return false
return true
func _refresh_from_doc() -> bool:
var terrain: Dictionary = main.doc.get("terrain", {})
if terrain.is_empty():
return false
_res = int(terrain.get("resolution", 0))
_sx = float(terrain.get("size_x_m", 0.0))
_sz = float(terrain.get("size_z_m", 0.0))
_hmin = float(terrain.get("min_height_m", 0.0))
_hmax = float(terrain.get("max_height_m", 0.0))
if _res < 2 or _sx <= 0.0 or _sz <= 0.0 or _hmax <= _hmin:
return false
_heights = SvmapIO.decode_heights(terrain)
# Material-Schicht mit dekodieren (Karte t_55ed041a): der Live-Rebuild
# beim Heben/Senken/Glätten braucht sie, sonst fällt das Mesh während
# des Strichs auf alles-Gras zurück (Sand/Fels „verschwinden" live).
_mats = SvmapIO.decode_materials(terrain)
_mres = SvmapIO.material_resolution(terrain)
_load_mix(terrain)
return _heights.size() >= _res * _res
# ------------------------------------------------------------ Pinsel-Optionen
func set_brush_mode(mode: int) -> void:
# Clamp bis PAINT (3) — der alte Clamp 0..2 hätte den Malen-Modus still
# zu Glätten degradiert (Karte t_55257792, Ziffer 4 = Malen).
brush_mode = clampi(mode, 0, MODE_LABELS.size() - 1)
_refresh_ring_mesh()
func set_radius(m: float) -> void:
brush_radius = clampf(m, 1.0, 64.0) # ab 1 m (Karte 25.10., feines Raster)
_refresh_ring_mesh()
## Misch-Schicht (Karte 25.10.) aus dem terrain-Block — fehlt sie, Nullen.
func _load_mix(terrain: Dictionary) -> void:
var mix := SvmapIO.decode_mix(terrain)
_mix_b = mix[0]
_mix_w = mix[1]
## Materialpunkt als ein int (a | b<<8 | w<<16) — Spiegeln kopiert alle drei.
func _punkt(idx: int) -> int:
return int(_mats[idx]) | (int(_mix_b[idx]) << 8) | (int(_mix_w[idx]) << 16)
## Anteil (0–255) des Materials m an einem gepackten Punkt.
static func _anteil(v: int, m: int) -> int:
var a := v & 0xFF
var b := (v >> 8) & 0xFF
var w := (v >> 16) & 0xFF
return (255 - w if a == m else 0) + (w if b == m else 0)
func _setze_punkt(idx: int, v: int) -> void:
_mats[idx] = v & 0xFF
_mix_b[idx] = (v >> 8) & 0xFF
_mix_w[idx] = (v >> 16) & 0xFF
## Regler „Kante: hart ↔ weich“ (nur Malen).
func set_weichheit(w: float) -> void:
brush_weichheit = clampf(w, 0.0, 1.0)
func set_strength(mps: float) -> void:
brush_strength = clampf(mps, 1.0, 20.0)
func set_paint_material(id: int) -> void:
paint_material = clampi(id, 0, SvmapIO.MATERIAL_IDS.size() - 1)
_refresh_ring_mesh()
# --------------------------------------------------------------- Eingaben ---
func _unhandled_input(event: InputEvent) -> void:
if not active:
return
if event is InputEventMouseMotion:
_update_cursor((event as InputEventMouseMotion).position)
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
# Karte t_2470de89: Strg = Invert-Modifier. Heben↔Senken
# tauschen (Blender/Photoshop-Konvention), im Malen-Modus
# malt Strg+Klick Gras zurück (alter Radierer-Weg).
var mode := brush_mode
var mat := -1
if mb.ctrl_pressed:
match brush_mode:
TerrainField.Mode.RAISE:
mode = TerrainField.Mode.LOWER
TerrainField.Mode.LOWER:
mode = TerrainField.Mode.RAISE
TerrainField.Mode.PAINT:
mat = 0 # Gras zurückmalen
_begin_stroke(mode, mat)
else:
_end_stroke()
get_viewport().set_input_as_handled()
# Rechte Maustaste: absichtlich NICHT behandelt — sie gehört mit
# absolutem Vorrang dem Freelook-Flug (orbit_camera.gd, Karte
# t_2470de89), in jedem Werkzeugzustand.
func _update_cursor(viewport_pos: Vector2) -> void:
var hit: Variant = TerrainField.raycast(_heights, _res, _sx, _sz,
camera.project_ray_origin(viewport_pos), camera.project_ray_normal(viewport_pos))
if hit == null:
_cursor_valid = false
if _ring != null:
_ring.visible = false
return
_cursor_valid = true
_cursor = hit as Vector3
var half_x := _sx * 0.5
var half_z := _sz * 0.5
_cursor.x = clampf(_cursor.x, -half_x, half_x)
_cursor.z = clampf(_cursor.z, -half_z, half_z)
# Ring minimal über dem Gelände, damit er nicht im Mesh verschwindet.
_ring.position = Vector3(_cursor.x, _height_at_stroke(_cursor.x, _cursor.z) + 0.3, _cursor.z)
_ring.visible = true
func _height_at_stroke(x: float, z: float) -> float:
return TerrainField.height_at(_heights, _res, _sx, _sz, x, z)
# ----------------------------------------------------------------- Stroke ---
func _begin_stroke(mode: int, material_id: int = -1) -> void:
if _stroking:
return
if not _cursor_valid:
main.call("_set_status",
"Der Pinsel braucht einen Punkt AUF dem Gelände — führe die Maus über die Karte und halte dann die Maustaste gedrückt.",
true)
return
_stroke_mode = mode
_stroke_material = paint_material if material_id < 0 else material_id
_stroking = true
# Im Malen-Modus lebt die Material-Schicht während des Strichs
# (dekodiert — Legacy-Maps ohne Schicht starten mit alles-Gras).
if mode == TerrainField.Mode.PAINT:
_mats = SvmapIO.decode_materials(main.doc.get("terrain", {}))
_mres = SvmapIO.material_resolution(main.doc.get("terrain", {}))
_load_mix(main.doc.get("terrain", {}))
_refresh_ring_mesh()
main.call("_set_status", "%s läuft — Maustaste gedrückt halten. Beim Loslassen wird der ganze Strich EIN Rückgängig-Schritt (Strg+Z)." % _stroke_label())
## Lesbarer Stroke-Name: Heben/Senken/Glätten bzw. „Malen (Sand)".
func _stroke_label() -> String:
if _stroke_mode == TerrainField.Mode.PAINT:
return "Malen (%s)" % SvmapIO.MATERIAL_IDS[clampi(_stroke_material, 0, SvmapIO.MATERIAL_IDS.size() - 1)]
return MODE_LABELS[_stroke_mode]
func _process(delta: float) -> void:
if not _stroking or not _cursor_valid:
return
if _stroke_mode == TerrainField.Mode.PAINT:
var painted := _paint_step(_cursor.x, _cursor.z)
if painted:
# Karte t_7a0a6b79: Malen lebt im Sub-Rect — update_terrain bekommt
# das betroffene Zellrechteck (inkl. Spiegelzellen) und leitet nur
# die Splat-Region neu ab; das Mesh selbst bleibt unangetastet
# (Vertex-Farben hängen NICHT vom Material ab).
if view_builder.has_method("set_terrain_mix"):
view_builder.call("set_terrain_mix", _mix_b, _mix_w)
view_builder.call("update_terrain", _heights, _mats, _splat_rect)
_ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3
return
var amount: float
if _stroke_mode == TerrainField.Mode.SMOOTH:
amount = clampf(brush_strength / SMOOTH_RATE_DIVISOR * delta, 0.0, 1.0)
else:
amount = brush_strength * delta
# Während des Strichs NICHT auf die Format-Range clampen — die zieht der
# Range-Expander danach nach (sonst friert Heben/Senken an der Kante ein).
var changed := _height_step(_cursor.x, _cursor.z, amount)
if changed:
_expand_range_if_needed()
# Karte t_55ed041a: Material-Schicht MITGEBEN — ohne sie hat der
# Live-Rebuild alles als Gras gerendert (Sand/Fels „verschwanden"
# während des Heben/Senk-Strichs und kehrten erst am Stroke-Ende
# mit dem Vollrebuild zurück).
view_builder.call("update_terrain", _heights, _mats)
_ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3
## Höhe spannt das Höhenfeld automatisch auf, wenn der Pinsel über die
## Format-Grenzen (min/max_height) hinaus will — sonst wäre Senken auf einer
## frisch angelegten (flachen) Map unmöglich und langes Heben würde clampen.
## Die Range gehört zum terrain-Block → Undo stellt sie mit zurück. Spannweite
## bleibt ≤ 500 m (V6).
func _expand_range_if_needed() -> void:
var lo := TerrainField._min_h(_heights)
var hi := TerrainField._max_h(_heights)
if lo < _hmin:
_hmin = floorf(lo - 1.0)
if hi > _hmax:
_hmax = ceilf(hi + 1.0)
if _hmax - _hmin > 500.0:
if lo < _hmin:
_hmin = _hmax - 500.0
else:
_hmax = _hmin + 500.0
func _end_stroke() -> void:
if not _stroking:
return
_stroking = false
# Doc erst jetzt ändern: der GANZE Strich ist ein History-Eintrag.
var before: Dictionary = main.history.snapshot(["terrain"])
var terrain: Dictionary = main.doc.get("terrain", {})
if _stroke_mode == TerrainField.Mode.PAINT:
terrain["materials_r8_b64"] = SvmapIO.encode_materials(_mats)
SvmapIO.encode_mix(terrain, _mix_b, _mix_w)
main.history.commit("Boden gemalt (%s)" % SvmapIO.MATERIAL_IDS[clampi(_stroke_material, 0, SvmapIO.MATERIAL_IDS.size() - 1)],
before, main.history.snapshot(["terrain"]))
else:
terrain["min_height_m"] = _hmin
terrain["max_height_m"] = _hmax
terrain["heightmap_r16_b64"] = TerrainField.encode(_heights, _hmin, _hmax)
main.history.commit("Boden geformt (%s)" % MODE_LABELS[_stroke_mode],
before, main.history.snapshot(["terrain"]))
main.call("_on_terrain_stroke_committed")
## Programmgesteuerter Stroke (Demo/Screenshot-Hook): macht exakt, was eine
## Maus auch tun würde — inkl. History-Eintrag.
func demo_stroke(x: float, z: float, mode: int, seconds: float) -> void:
_refresh_from_doc()
_cursor = Vector3(x, 0.0, z)
_cursor_valid = true
_begin_stroke(mode)
var steps := int(ceil(seconds / 0.05))
var dt := seconds / float(maxi(steps, 1))
var save_x := _cursor.x
var save_z := _cursor.z
for i in steps:
var amount: float
if mode == TerrainField.Mode.SMOOTH:
amount = clampf(brush_strength / SMOOTH_RATE_DIVISOR * dt, 0.0, 1.0)
else:
amount = brush_strength * dt
if mode == TerrainField.Mode.PAINT:
_paint_step(save_x, save_z)
continue
_height_step(save_x, save_z, amount)
_expand_range_if_needed()
_end_stroke()
_refresh_from_doc()
## Demo-Hook: Pinsel-Ring sichtbar an einer Weltposition parken (für
## Screenshots ohne echte Maus-Ereignisse).
func demo_park_brush(x: float, z: float) -> void:
_refresh_ring_mesh()
_cursor = Vector3(x, 0.0, z)
_cursor_valid = true
_ring.position = Vector3(x, _height_at_stroke(x, z) + 0.3, z)
_ring.visible = true
# ------------------------------------------------------------- Symmetrie ----
## M2 (Karte t_f9eff526): Im Symmetrie-Modus (main.sym_mode ≠ AUS) schreibt
## jeder Strich seine Zellen EXAKT gespiegelt in die andere Kartenseite —
## Höhe ist ein Skalar → 1:1 identisch übernommen, Material ebenso. Die
## Zell-Abbildung kommt aus M1 (SymmetryCore.mirror_cell), nichts neu erfunden.
##
## Ablauf pro Pinsel-Schritt:
## 1. Pinsel EINMAL an der Strich-Position anwenden — bewusst NICHT zweimal
## (Strichpunkt + Spiegelpunkt): sonst bekämen Zellen im Überlappband den
## Effekt doppelt und an der Rastermitte entstünde ein Grat.
## 2. Die seit dem Snapshot geänderten Zellen des Pinsel-Rechtecks ermitteln
## und jede Änderung in die gespiegelte Schwesterzelle kopieren.
## Liegt der Strich genau auf der Mitte/Achse, bildet mirror_cell die Zellen
## auf sich selbst bzw. auf Partner mit IDENTISCHEm Wert ab (spiegelsymmetrischer
## Fußabdruck, gleicher Falloff-Abstand) → nichts wird gedoppelt.
## Überlappen Fußabdruck und Spiegelbild (Strich nahe der Mitte), gewinnt pro
## Zellpaar die Seite, die NÄHER am Strichpunkt liegt — die andere Hälfte ist
## exakte Kopie der Strich-Seite; bei exaktem Gleichstand entscheidet der
## kleinere Grid-Index (deterministisch, Row-major-Verarbeitung).
## Bei Modus AUS: keine Snapshot-/Kopierarbeit — exakt das alte Verhalten.
## Zusätzlicher Rand um die grobe Pinsel-Region (TerrainField arbeitet mit
## floor/ceil ± 1; hier großzügig ± 2 — der Snapshot-Vergleich entscheidet
## ohnehin zellgenau, was wirklich geändert wurde).
const SYM_REGION_MARGIN := 2
func _sym_mode() -> int:
if main == null:
return SymmetryCore.Mode.OFF
# .get statt direkter Property: Haupts ohne sym_mode-Feld (z. B. alte
# Test-Stubs, Tools außerhalb des Editors) fallen auf AUS zurück —
# exakt das Verhalten vor M2.
var mode: Variant = main.get("sym_mode")
if mode == null or not (mode is int):
return SymmetryCore.Mode.OFF
return int(mode)
func _sym_active() -> bool:
return _sym_mode() != SymmetryCore.Mode.OFF
## Welt-X/Z → Raster-Koordinaten (gleiche Konvention wie TerrainField.height_at:
## Index 0 = −X/−Z-Ecke, x-Index läuft MIT +WeltX).
func _world_to_grid(x: float, z: float) -> Vector2:
return _world_to_grid_r(x, z, _res)
## Wie _world_to_grid, aber auf einem beliebigen Raster (Material: _mres).
func _world_to_grid_r(x: float, z: float, r: int) -> Vector2:
var last := float(r - 1)
return Vector2((x / _sx + 0.5) * last, (z / _sz + 0.5) * last)
## Grobes Rechteck um die Raster-Position (g), das den Pinsel-Fußabdruck
## jedes Modus garantiert abdeckt (Radius in Zellen + Margin).
func _brush_region(g: Vector2) -> Rect2i:
return _brush_region_r(g, _res)
## Wie _brush_region, auf dem Raster r (Material-Schicht: _mres).
func _brush_region_r(g: Vector2, r: int) -> Rect2i:
var cell_x := _sx / float(r - 1)
var cell_z := _sz / float(r - 1)
var rx := brush_radius / cell_x + float(SYM_REGION_MARGIN)
var rz := brush_radius / cell_z + float(SYM_REGION_MARGIN)
var x0 := maxi(int(floorf(g.x - rx)), 0)
var x1 := mini(int(ceilf(g.x + rx)), r - 1)
var z0 := maxi(int(floorf(g.y - rz)), 0)
var z1 := mini(int(ceilf(g.y + rz)), r - 1)
return Rect2i(x0, z0, x1 - x0 + 1, z1 - z0 + 1)
## Abstand einer Rasterzelle zum Strichpunkt in Metern (gleiche Zell-Metrik
## wie TerrainField.apply_brush: Zellindex-Abstand × Zellgröße).
func _cell_dist(pos: Vector2i, g: Vector2, cell_x: float, cell_z: float) -> float:
var dx := (float(pos.x) - g.x) * cell_x
var dz := (float(pos.y) - g.y) * cell_z
return sqrt(dx * dx + dz * dz)
## Ein Pinsel-Schritt HÖHE (Heben/Senken/Glätten) inkl. Spiegel-Kopie.
## amount: Meter (Heben/Senken) bzw. Mischfaktor 0–1 (Glätten) — unverändert
## zur bisherigen apply_brush-Semantik; ohne Clamp (Range-Expander zieht nach).
func _height_step(x: float, z: float, amount: float) -> bool:
var g := _world_to_grid(x, z)
var snap := {}
if _sym_active():
snap = _snap_heights(_brush_region(g))
var changed := TerrainField.apply_brush(_heights, _res, _sx, _sz,
-100000.0, 100000.0, x, z, brush_radius, _stroke_mode, amount)
if changed and not snap.is_empty():
_mirror_heights(snap, g)
return changed
## Ein Pinsel-Schritt MATERIAL (Malen) inkl. Spiegel-Kopie.
func _paint_step(x: float, z: float) -> bool:
# Material lebt auf seinem eigenen (feinen) Raster _mres (Karte 24.10.).
var g := _world_to_grid_r(x, z, _mres)
# VOR dem Pinsel: Zellrect des Fußabdrucks merken (deckt Spiegelzellen mit)
# — der Live-Splat-Refresh (t_7a0a6b79) leitet genau diese Region neu ab.
_splat_rect = _splat_region(g)
var snap := {}
if _sym_active() and _mats.size() >= _mres * _mres:
snap = _snap_mats(_brush_region_r(g, _mres))
var changed := TerrainField.apply_material_brush_mix(_mats, _mix_b, _mix_w, _mres,
_sx, _sz, x, z, brush_radius, _stroke_material, brush_weichheit)
if changed and not snap.is_empty():
_mirror_mats(snap, g)
return changed
## Snapshot {Vector2i(x, z) → Höhe} über das Pinsel-Rechteck (NUR bei
## Symmetrie AN erzeugt — bei AUS bleibt es bei exakt dem alten Verhalten).
func _snap_heights(rect: Rect2i) -> Dictionary:
var snap := {}
for z: int in range(rect.position.y, rect.end.y):
for x: int in range(rect.position.x, rect.end.x):
snap[Vector2i(x, z)] = _heights[z * _res + x]
return snap
## Spiegel-Pass für die Höhe: jede seit dem Snapshot geänderte Zelle wird in
## ihre gespiegelte Schwester kopiert (Strich-Seite ist Master, siehe oben).
func _mirror_heights(snap: Dictionary, g: Vector2) -> void:
var sym := _sym_mode()
var cell_x := _sx / float(_res - 1)
var cell_z := _sz / float(_res - 1)
var done := {}
var positions: Array = snap.keys()
# Row-major (kleinster Index zuerst): der Gleichstand-Tie-Break „kleinerer
# Index gewinnt“ ist damit deterministisch, und ein Paar wird genau einmal
# an seinem kleineren Index entschieden.
positions.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
return (a.y * _res + a.x) < (b.y * _res + b.x))
for pos: Vector2i in positions:
if done.has(pos):
continue
var idx: int = pos.y * _res + pos.x
var mirror: Vector2i = SymmetryCore.mirror_cell(sym, pos.x, pos.y, _res)
done[pos] = true
if mirror == pos:
continue # Selbstabbildung (Rastermitte/Achse) — nichts zu kopieren
var midx: int = mirror.y * _res + mirror.x
var value: float = _heights[idx]
if snap.has(mirror):
# Beide Seiten wurden vom Pinsel geändert (Fußabdruck überlappt
# sein Spiegelbild): näher am Strichpunkt gewinnt, Gleichstand
# → kleinere Index (== diese pos, da Row-major zuerst kam).
if _cell_dist(mirror, g, cell_x, cell_z) < _cell_dist(pos, g, cell_x, cell_z):
value = _heights[midx]
_heights[idx] = value
_heights[midx] = value
done[mirror] = true
## Snapshot {Vector2i(x, z) → Material-ID} über das Pinsel-Rechteck.
func _snap_mats(rect: Rect2i) -> Dictionary:
var snap := {}
for z: int in range(rect.position.y, rect.end.y):
for x: int in range(rect.position.x, rect.end.x):
snap[Vector2i(x, z)] = _punkt(z * _mres + x)
return snap
## Spiegel-Pass für die Material-Schicht (identische Paar-Logik wie _mirror_heights).
func _mirror_mats(snap: Dictionary, g: Vector2) -> void:
var sym := _sym_mode()
var cell_x := _sx / float(_mres - 1)
var cell_z := _sz / float(_mres - 1)
var done := {}
var positions: Array = snap.keys()
positions.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
return (a.y * _mres + a.x) < (b.y * _mres + b.x))
# Karte 25.10. (Sly: harte gerade Kanten mit Symmetrie): NUR Punkte spiegeln,
# die dieser Pinsel-Schritt wirklich verändert hat. Vorher wurde das ganze
# Snapshot-Quadrat auf die Gegenseite kopiert — weiche Verläufe drüben
# wurden an den Quadratkanten hart abgeschnitten.
for pos: Vector2i in positions:
if done.has(pos):
continue
done[pos] = true
var idx: int = pos.y * _mres + pos.x
if _punkt(idx) == int(snap[pos]):
continue # nicht bemalt → Gegenseite bleibt unangetastet
var mirror: Vector2i = SymmetryCore.mirror_cell(sym, pos.x, pos.y, _mres)
if mirror == pos:
continue
var midx: int = mirror.y * _mres + mirror.x
var value: int = _punkt(idx)
if snap.has(mirror) and _punkt(midx) != int(snap[mirror]):
# Beide Seiten bemalt (Pinsel überlappt sein Spiegelbild, z. B. nahe
# der Kartenmitte): der Wert mit dem GRÖSSEREN Anteil des Mal-
# Materials gewinnt. Vorher „näher am Strichpunkt“ — das zog entlang
# der Mittelsenkrechte eine harte gerade Kante durch weiche Verläufe
# (Sly 25.10.). Max ist stetig und symmetrisch.
if _anteil(_punkt(midx), _stroke_material) > _anteil(value, _stroke_material):
value = _punkt(midx)
_setze_punkt(idx, value)
done[mirror] = true
_setze_punkt(midx, value)
# ----------------------------------------------------------- Ring-Optik -----
func _refresh_ring_mesh() -> void:
if _ring == null:
return
if _ring_radius == brush_radius and _ring_mode == brush_mode:
return
_ring_radius = brush_radius
_ring_mode = brush_mode
var torus := TorusMesh.new()
torus.inner_radius = brush_radius - 0.25
torus.outer_radius = brush_radius + 0.25
torus.rings = 48
torus.ring_segments = 8
_ring.mesh = torus
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
match brush_mode:
TerrainField.Mode.RAISE:
mat.albedo_color = Color(0.4, 1.0, 0.45, 0.9)
TerrainField.Mode.LOWER:
mat.albedo_color = Color(1.0, 0.45, 0.3, 0.9)
TerrainField.Mode.PAINT:
# Ring in der Farbe des gewählten Materials (Karte t_603ec1a8).
var mc: int = clampi(paint_material, 0, SvmapIO.MATERIAL_IDS.size() - 1)
mat.albedo_color = (terrain_mesh_color(mc) as Color)
mat.albedo_color.a = 0.9
_:
mat.albedo_color = Color(0.4, 0.7, 1.0, 0.9)
mat.emission_enabled = true
mat.emission = (mat.albedo_color as Color) * 0.5
_ring.material_override = mat
## Material-ID → Renderer-Farbe (gleiche Palette wie terrain_mesh.gd).
func terrain_mesh_color(id: int) -> Color:
match id:
1:
return Color(0.78, 0.70, 0.45) # Sand
2:
return Color(0.45, 0.44, 0.42) # Fels
_:
return Color(0.30, 0.45, 0.22) # Gras
# ------------------------------------------------ Live-Splat-Sub-Rect (Karte
# t_7a0a6b79): Zellrechteck des letzten Mal-Schritts für das Live-Update der
# Splat-Gewichtstextur. Am Dateiende angefügt (Karten-Regel).
## Zellrect des letzten Mal-Schritts (Pinsel-Fußabdruck ∪ gespiegelte
## Schwesterzellen) — vom Stroke-Code als 3. Parameter an update_terrain
## (view_builder) weitergereicht.
var _splat_rect := Rect2i()
## Zellrect des Pinsel-Fußabdrucks (∪ Spiegelzellen): dieselben Region-
## Grenzen wie apply_material_brush, mit dem SYM_REGION_MARGIN-Rand — der
## deckt auch die Kernel-Nachbarschaft der Splat-Ableitung mit ab.
## g und Ergebnis im Material-Raster (_mres) — so rechnet auch die Splat.
func _splat_region(g: Vector2) -> Rect2i:
var rect := _brush_region_r(g, _mres)
var sym := _sym_mode()
if sym == SymmetryCore.Mode.OFF:
return rect
var a := SymmetryCore.mirror_cell(sym, rect.position.x, rect.position.y, _mres)
var b := SymmetryCore.mirror_cell(sym, rect.end.x - 1, rect.end.y - 1, _mres)
return rect.merge(Rect2i(mini(a.x, b.x), mini(a.y, b.y),
absi(a.x - b.x) + 1, absi(a.y - b.y) + 1))