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)))