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 Anchor,
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.anchor = Anchor.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 gutter = 8.0
125 self.inset = (gutter, bottom_margin)
126
127 self._vbox = VBoxContainer()
128 self._vbox.position = Vec2(10, 10)
129 self._vbox.size = Vec2(360, rows_h + 16)
130 self._vbox.separation = 2.0
131 self._vbox.sizing = SizingMode.CONTENT
132 self.add_child(self._vbox)
133
134 title = Label(text="Noise sliders - drag to deform planet")
135 title.size = Vec2(360, 20)
136 self._vbox.add_child(title)
137
138 self._rows: dict[str, SliderRow] = {}
139 for spec in specs:
140 row = SliderRow(spec, on_change)
141 self._rows[spec.key] = row
142 self._vbox.add_child(row)
143
144 def get_value(self, key: str) -> float:
145 return self._rows[key].slider.value
146
147
148def apply_slider_value(settings: ShapeSettings, key: str, value: float) -> None:
149 """Mutate `settings` in place to reflect a slider change."""
150 if not settings.layers:
151 return
152 base_layer: NoiseLayer = settings.layers[0]
153 ridge_layer: NoiseLayer | None = settings.layers[1] if len(settings.layers) > 1 else None
154 if key == "planet_radius":
155 settings.planet_radius = value
156 elif key == "base_strength":
157 base_layer.simple.strength = value
158 elif key == "base_layers":
159 base_layer.simple.num_layers = max(1, int(round(value)))
160 elif key == "base_persistence":
161 base_layer.simple.persistence = value
162 elif key == "base_roughness":
163 base_layer.simple.roughness = value
164 elif key == "base_freq":
165 base_layer.simple.base_roughness = value
166 elif key == "ridge_strength" and ridge_layer is not None:
167 ridge_layer.ridge.strength = value
168 elif key == "ridge_layers" and ridge_layer is not None:
169 ridge_layer.ridge.num_layers = max(1, int(round(value)))