nodes/controls.pyΒΆ

Part of Procedural Planets.

  1"""SliderPanel: bottom-left anchored noise sliders.
  2
  3Each slider drives one field of the planet's ShapeSettings. Dragging emits
  4`value_changed` per pixel, so the panel does not rebuild the planet itself: it
  5hands every change to the `on_change` callback, which coalesces them into at
  6most one regenerate every few frames (see `PlanetRoot._on_slider`).
  7
  8Layout: anchored to the bottom-left, clearing the controls strip. Height grows
  9with the slider count.
 10"""
 11
 12from __future__ import annotations
 13
 14from collections.abc import Callable
 15from dataclasses import dataclass
 16
 17from simvx.core import Vec2
 18from simvx.core.ui import (
 19    AnchorPreset,
 20    HBoxContainer,
 21    Label,
 22    Panel,
 23    Slider,
 24    VBoxContainer,
 25)
 26from simvx.core.ui.containers import SizingMode
 27
 28from .shape import NoiseLayer, ShapeSettings
 29
 30
 31@dataclass
 32class SliderSpec:
 33    """Declarative spec for a slider row."""
 34
 35    key: str
 36    label: str
 37    min_v: float
 38    max_v: float
 39    step: float
 40    value: float
 41    fmt: str = "{:.2f}"
 42
 43
 44def _build_specs(settings: ShapeSettings) -> list[SliderSpec]:
 45    base = settings.layers[0].simple if settings.layers else None
 46    ridge = settings.layers[1].ridge if len(settings.layers) > 1 else None
 47    return [
 48        SliderSpec("resolution", "Resolution", 16, 192, 16, 64, "{:.0f}"),
 49        SliderSpec("planet_radius", "Radius", 0.5, 3.0, 0.05, settings.planet_radius),
 50        SliderSpec("base_strength", "Mountain strength", 0.0, 1.0, 0.01, base.strength if base else 0.30),
 51        SliderSpec("base_layers", "Mountain octaves", 1, 6, 1, base.num_layers if base else 4, "{:.0f}"),
 52        SliderSpec("base_persistence", "Mountain persistence", 0.10, 1.0, 0.01, base.persistence if base else 0.50),
 53        SliderSpec("base_roughness", "Mountain roughness", 1.0, 3.0, 0.05, base.roughness if base else 1.85),
 54        SliderSpec("base_freq", "Mountain freq", 0.1, 3.0, 0.05, base.base_roughness if base else 1.0),
 55        SliderSpec("ridge_strength", "Ridge strength", 0.0, 1.0, 0.01, ridge.strength if ridge else 0.60),
 56        SliderSpec("ridge_layers", "Ridge octaves", 1, 6, 1, ridge.num_layers if ridge else 5, "{:.0f}"),
 57    ]
 58
 59
 60class SliderRow(HBoxContainer):
 61    """One slider + label + readout in a horizontal row."""
 62
 63    def __init__(self, spec: SliderSpec, on_change: Callable[[str, float], None], **kwargs):
 64        super().__init__(**kwargs)
 65        self.spec = spec
 66        self._on_change = on_change
 67
 68        self.size = Vec2(360, 22)
 69        self.separation = 6.0
 70
 71        self.label = Label(text=spec.label)
 72        self.label.size = Vec2(150, 22)
 73        self.add_child(self.label)
 74
 75        self.slider = Slider(spec.min_v, spec.max_v, value=spec.value)
 76        self.slider.step = spec.step
 77        self.slider.size = Vec2(150, 18)
 78        self.slider.value_changed.connect(self._handle_changed)
 79        self.add_child(self.slider)
 80
 81        self.readout = Label(text=spec.fmt.format(spec.value))
 82        self.readout.size = Vec2(50, 22)
 83        self.add_child(self.readout)
 84
 85    def _handle_changed(self, v: float) -> None:
 86        self.readout.text = self.spec.fmt.format(v)
 87        self._on_change(self.spec.key, v)
 88
 89    def set_value(self, v: float) -> None:
 90        self.slider.value = v
 91        self.readout.text = self.spec.fmt.format(v)
 92
 93
 94class SliderPanel(Panel):
 95    """Bottom-left panel of noise sliders.
 96
 97    Builds rows from a ShapeSettings spec. `on_change(key, value)` fires on
 98    every drag tick, caller is expected to coalesce into a regenerate.
 99    """
100
101    def __init__(
102        self,
103        settings: ShapeSettings,
104        on_change: Callable[[str, float], None],
105        bottom_margin: float = 8.0,
106        **kwargs,
107    ):
108        super().__init__(**kwargs)
109        self._settings = settings
110        self._on_change = on_change
111
112        # Anchored to the bottom-left, clearing `bottom_margin` px of the
113        # bottom edge so the controls strip can sit underneath it.
114        self.set_anchor_preset(AnchorPreset.BOTTOM_LEFT)
115        self.bg_colour = (0.05, 0.05, 0.08, 0.85)
116
117        specs = _build_specs(settings)
118        row_h = 24
119        rows_h = row_h * len(specs) + 12
120
121        panel_w = 380
122        panel_h = rows_h + 36
123        self.size = Vec2(panel_w, panel_h)
124        # BOTTOM_LEFT collapses both anchor pairs onto the parent's bottom-left
125        # corner, so each axis takes its size from the margin *difference* and
126        # its origin from the leading margin:
127        #   left   = margin_left,            width  = margin_right - margin_left
128        #   top    = ph + margin_top,        height = margin_bottom - margin_top
129        gutter = 8.0
130        self.margin_left = gutter
131        self.margin_right = gutter + panel_w
132        self.margin_top = -(bottom_margin + panel_h)
133        self.margin_bottom = -bottom_margin
134
135        self._vbox = VBoxContainer()
136        self._vbox.position = Vec2(10, 10)
137        self._vbox.size = Vec2(360, rows_h + 16)
138        self._vbox.separation = 2.0
139        self._vbox.sizing = SizingMode.CONTENT
140        self.add_child(self._vbox)
141
142        title = Label(text="Noise sliders  -  drag to deform planet")
143        title.size = Vec2(360, 20)
144        self._vbox.add_child(title)
145
146        self._rows: dict[str, SliderRow] = {}
147        for spec in specs:
148            row = SliderRow(spec, on_change)
149            self._rows[spec.key] = row
150            self._vbox.add_child(row)
151
152    def get_value(self, key: str) -> float:
153        return self._rows[key].slider.value
154
155
156def apply_slider_value(settings: ShapeSettings, key: str, value: float) -> None:
157    """Mutate `settings` in place to reflect a slider change."""
158    if not settings.layers:
159        return
160    base_layer: NoiseLayer = settings.layers[0]
161    ridge_layer: NoiseLayer | None = settings.layers[1] if len(settings.layers) > 1 else None
162    if key == "planet_radius":
163        settings.planet_radius = value
164    elif key == "base_strength":
165        base_layer.simple.strength = value
166    elif key == "base_layers":
167        base_layer.simple.num_layers = max(1, int(round(value)))
168    elif key == "base_persistence":
169        base_layer.simple.persistence = value
170    elif key == "base_roughness":
171        base_layer.simple.roughness = value
172    elif key == "base_freq":
173        base_layer.simple.base_roughness = value
174    elif key == "ridge_strength" and ridge_layer is not None:
175        ridge_layer.ridge.strength = value
176    elif key == "ridge_layers" and ridge_layer is not None:
177        ridge_layer.ridge.num_layers = max(1, int(round(value)))