celestialsim/builder.rs
1//! `CesBuilder` — the terrain-builder resource.
2//!
3//! A planet has ONE `builder` (its terrain source); when it is unset the planet
4//! renders a plain white sphere. The base `CesBuilder` is the SINGLE builder
5//! class: whether it is a built-in noise example or YOUR custom terrain is
6//! decided by two fields — `device` (GPU/CPU) and a hidden `builtin_shader`
7//! selector (`None` = custom, `Terrain` = the built-in noise). The four paths
8//! ([`BuilderRoute`]) are:
9//!
10//! * `builtin_shader == Terrain`, `device == GPU` — built-in GPU noise (the rich
11//! inline shader, driven by the noise knobs). The auto-added default, shipped
12//! as the `CesGPUNoiseExample` GDScript (carries the knobs as its own
13//! `@export`s).
14//! * `builtin_shader == Terrain`, `device == CPU` — the same noise baked on CPU
15//! worker threads ([`crate::noise_provider::NoiseProvider`]); shipped as
16//! `CesCPUNoiseExample`.
17//! * `builtin_shader == None`, `device == GPU` — YOUR terrain: point
18//! `shader_file` at a `.glsl` defining `terrain_height` / `terrain_color`
19//! (compiled at runtime). May add `@export var name: float` knobs (surfaced to
20//! the GLSL as `#define NAME`).
21//! * `builtin_shader == None`, `device == CPU` — YOUR terrain in GDScript:
22//! `extends CesBuilder`, define `height` / `color` / `normal`, and set the
23//! device to `CPU`. If it instead defines `_bake_requested`, the bake is
24//! ASYNCHRONOUS (CEL-86) — see [`BuilderRoute::CpuCustomAsync`].
25//!
26//! The planet routes on (`device`, `builtin_shader`) plus, for the CPU-custom
27//! pair, whether the script defines `_bake_requested` — see [`route_of`].
28//! The noise knobs live on the example GDScripts (not this base); a custom
29//! builder simply doesn't declare them.
30
31use std::sync::Arc;
32
33use godot::classes::Resource;
34use godot::prelude::*;
35use godot::register::info::{PropertyInfo, PropertyUsageFlags};
36
37use crate::async_bake::{self, RawSubmission, SubmitQueue};
38use crate::descriptors::{HeightGpu, TextureGpu};
39use crate::noise_provider::NoiseParams;
40
41/// The GDScript method whose presence selects the async CPU bake route.
42pub const BAKE_REQUESTED: &str = "_bake_requested";
43/// Optional GDScript method gating whether the baked surface is shown yet.
44pub const BASE_READY: &str = "_base_ready";
45
46/// Which device runs a builder — an exported dropdown on [`CesBuilder`].
47/// Combined with [`BuiltinShader`] it selects the [`BuilderRoute`].
48#[allow(clippy::upper_case_acronyms)]
49#[derive(GodotConvert, Var, Export, Default, Clone, Copy, PartialEq, Eq, Debug)]
50#[godot(via = i64)]
51pub enum BuilderDevice {
52 /// GPU: either the built-in inline noise shader, or a custom `.glsl` (see
53 /// [`CesBuilder::shader_file`]) compiled at runtime. The default.
54 #[default]
55 GPU = 0,
56 /// CPU: either the built-in noise baked on worker threads, or custom terrain
57 /// in GDScript (`extends CesBuilder`, define `height`/`color`/`normal`).
58 CPU = 1,
59}
60
61/// Which BUILT-IN library shader a builder runs — a hidden (STORAGE-only)
62/// selector on [`CesBuilder`]. `None` = a custom builder (user `.glsl` or
63/// GDScript); `Terrain` = the built-in noise example. Combined with
64/// [`BuilderDevice`] it selects the [`BuilderRoute`].
65#[derive(GodotConvert, Var, Export, Default, Clone, Copy, PartialEq, Eq, Debug)]
66#[godot(via = i64)]
67pub enum BuiltinShader {
68 /// No built-in shader — a custom builder (GPU `.glsl` or CPU GDScript).
69 #[default]
70 None = 0,
71 /// The built-in terrain noise example (GPU inline shader / CPU provider).
72 Terrain = 1,
73}
74
75/// The terrain paths the planet routes between. Derived from a builder's
76/// [`CesBuilder::device`] and [`CesBuilder::builtin_shader`], plus — for the
77/// CPU-custom pair — whether the GDScript defines [`BAKE_REQUESTED`]. NOT an
78/// exported field.
79#[derive(Clone, Copy, PartialEq, Eq, Debug)]
80pub enum BuilderRoute {
81 /// Built-in GPU noise (rich inline shader, driven by knobs).
82 GpuNoise,
83 /// Built-in noise baked on CPU worker threads.
84 CpuNoise,
85 /// A custom builder on [`BuilderDevice::GPU`] — a `.glsl` surface.
86 GpuCustom,
87 /// A custom builder on [`BuilderDevice::CPU`] — GDScript `height`/`color`/
88 /// `normal`, baked synchronously on the main thread.
89 CpuCustom,
90 /// A custom builder on [`BuilderDevice::CPU`] defining `_bake_requested` —
91 /// the planet hands it chunks and it submits surfaces back whenever they
92 /// are ready (threads, network, disk). See [`crate::async_bake`].
93 CpuCustomAsync,
94}
95
96/// Resolve a builder's routing path.
97///
98/// `(device, builtin_shader)` decides everything except which of the two CPU-
99/// custom routes applies: a script that defines [`BAKE_REQUESTED`] is async.
100/// Auto-detection (rather than a flag) is what lets every pre-existing
101/// `height(dirs)` builder keep working with no edit.
102pub fn route_of(builder: &Gd<CesBuilder>) -> BuilderRoute {
103 let (builtin, device) = {
104 let b = builder.bind();
105 (b.builtin_shader, b.device)
106 };
107 match (builtin, device) {
108 (BuiltinShader::Terrain, BuilderDevice::GPU) => BuilderRoute::GpuNoise,
109 (BuiltinShader::Terrain, BuilderDevice::CPU) => BuilderRoute::CpuNoise,
110 (BuiltinShader::None, BuilderDevice::GPU) => BuilderRoute::GpuCustom,
111 (BuiltinShader::None, BuilderDevice::CPU) => {
112 if builder.clone().upcast::<Object>().has_method(BAKE_REQUESTED) {
113 BuilderRoute::CpuCustomAsync
114 } else {
115 BuilderRoute::CpuCustom
116 }
117 }
118 }
119}
120
121/// Plain, testable noise params (the Example builders' knobs). Defaults MUST
122/// reproduce [`HeightGpu::default`] / [`TextureGpu::default`] so a default
123/// builder renders like the historical HQ terrain.
124#[derive(Clone, Copy, Debug, PartialEq)]
125pub struct BuilderParams {
126 pub frequency: f32,
127 pub height_octaves: f32,
128 pub height_amp: f32,
129 pub height_gain: f32,
130 pub height_lacunarity: f32,
131 pub ridge_tiles: f32,
132 pub ridge_octaves: f32,
133 pub ridge_gain: f32,
134 pub ridge_lacunarity: f32,
135 pub ridge_strength: f32,
136 pub height_scale: f32,
137 pub fd_eps: f32,
138 pub water_height: f32,
139}
140
141impl Default for BuilderParams {
142 fn default() -> Self {
143 let h = HeightGpu::default();
144 let t = TextureGpu::default();
145 Self {
146 frequency: h.frequency,
147 height_octaves: h.height_octaves,
148 height_amp: h.height_amp,
149 height_gain: h.height_gain,
150 height_lacunarity: h.height_lacunarity,
151 ridge_tiles: h.ridge_tiles,
152 ridge_octaves: h.ridge_octaves,
153 ridge_gain: h.ridge_gain,
154 ridge_lacunarity: h.ridge_lacunarity,
155 ridge_strength: h.ridge_strength,
156 height_scale: h.height_scale,
157 fd_eps: h.fd_eps,
158 water_height: t.water_height,
159 }
160 }
161}
162
163/// Map the params onto the geometry (height) GPU struct (`enabled` always on —
164/// the planet's builder SELECTION gates displacement, not this flag).
165pub fn builder_height_gpu(p: &BuilderParams) -> HeightGpu {
166 HeightGpu {
167 frequency: p.frequency,
168 height_octaves: p.height_octaves,
169 height_amp: p.height_amp,
170 height_gain: p.height_gain,
171 height_lacunarity: p.height_lacunarity,
172 ridge_tiles: p.ridge_tiles,
173 ridge_octaves: p.ridge_octaves,
174 ridge_gain: p.ridge_gain,
175 ridge_lacunarity: p.ridge_lacunarity,
176 ridge_strength: p.ridge_strength,
177 height_scale: p.height_scale,
178 fd_eps: p.fd_eps,
179 enabled: 1.0,
180 }
181}
182
183/// Map the params onto the surface (texture) GPU struct.
184pub fn builder_texture_gpu(p: &BuilderParams) -> TextureGpu {
185 TextureGpu { water_height: p.water_height }
186}
187
188/// The terrain source: a `Resource` you assign to [`Celestial::builder`]. It answers
189/// "what does this planet's surface look like?" — height, colour, and (since it also
190/// owns the `water_*` group) the ocean.
191///
192/// A planet has exactly one. Sub-class it in GDScript (`extends CesBuilder`) to write
193/// your own terrain, or use one of the shipped example builders; leave the property
194/// empty and the planet renders a plain white sphere. Every edit — a knob, the
195/// shader file, the water level — reshades the resident chunks live.
196///
197/// The routing between the built-in noise, a custom `.glsl` and custom GDScript is
198/// described in the [module docs](self); user-facing guides live at
199/// <https://celestialsim.github.io/CelestialSim/>.
200///
201/// [`Celestial::builder`]: crate::celestial::Celestial
202#[derive(GodotClass)]
203#[class(base = Resource, tool, init)]
204pub struct CesBuilder {
205 base: Base<Resource>,
206
207 /// Which device produces the surface. [`BuilderDevice::GPU`] (the default) is the
208 /// fast path: thousands of threads realize and bake each chunk with no readback.
209 /// [`BuilderDevice::CPU`] bakes chunk surfaces from Rust worker threads (built-in
210 /// noise) or from your GDScript `height`/`color` (custom) — far slower, and the
211 /// synchronous GDScript route runs on the main thread, so keep `tile_res`
212 /// moderate there. Switching this reshapes the inspector (only the relevant
213 /// fields stay visible) via `on_validate_property`.
214 #[var(get = get_device, set = set_device)]
215 #[export]
216 pub device: BuilderDevice,
217
218 /// HIDDEN (STORAGE-only) selector for WHICH built-in library shader this
219 /// builder runs. `None` = custom (user `.glsl`/GDScript); `Terrain` = the
220 /// built-in noise. Set by the example GDScripts' `_init`; combined with
221 /// `device` it selects the [`BuilderRoute`].
222 #[var(get = get_builtin_shader, set = set_builtin_shader)]
223 #[export]
224 pub builtin_shader: BuiltinShader,
225
226 /// `GPU`-device custom only: `res://` path to your terrain `.glsl`
227 /// (defines `terrain_height` / `terrain_color`, optional `terrain_normal`).
228 #[var(get = get_shader_file, set = set_shader_file)]
229 #[export(file = "*.glsl")]
230 pub shader_file: GString,
231
232 /// **Water level** — normalized sea level (0..1). A NATIVE field (was a
233 /// per-subclass GDScript `@export`) so EVERY builder — noise or custom —
234 /// carries the same water level. Also drives terrain shore colouring and is
235 /// surfaced to custom shaders as `CELS_WATER_HEIGHT`. `0.5` = sea at the
236 /// planet radius (noise midpoint / custom `h = 0` baseline); raise to flood
237 /// low land. Always visible. A `changed`-emitting setter (below) reshades the
238 /// WHOLE terrain on edit — the water level controls the land/sea split, not
239 /// just the sphere — so a plain field would leave the terrain stale.
240 #[var(get = get_water_height, set = set_water_height)]
241 #[export(range = (0.0, 1.0, 0.001))]
242 #[init(val = 0.549)]
243 pub water_height: f32,
244
245 /// **Water toggle** — draw the analytic ocean at `water_height` (default on).
246 /// Off removes the sea surface entirely (the land/sea colouring of the terrain
247 /// still follows `water_height`) and hides the appearance params below, which
248 /// reappear — with their stored values — when it is switched back on.
249 #[export]
250 #[init(val = true)]
251 pub water_enabled: bool,
252 /// Body colour of deep water, far from shore; blended toward
253 /// `water_shallow_color` as the sea floor rises. Default a dark blue.
254 #[export]
255 #[init(val = Color::from_rgb(0.05, 0.22, 0.42))]
256 pub water_deep_color: Color,
257 /// Body colour of shallow water near the shoreline — the coastal tint. Default a
258 /// light teal.
259 #[export]
260 #[init(val = Color::from_rgb(0.20, 0.55, 0.70))]
261 pub water_shallow_color: Color,
262 /// How strongly the wave normals perturb the surface: 0 = a flat mirror,
263 /// 1 = maximum choppiness. Default 0.55.
264 #[export(range = (0.0, 1.0, 0.01))]
265 #[init(val = 0.55)]
266 pub water_wave_strength: f32,
267 /// Spatial frequency of the wave pattern — higher = smaller, tighter waves.
268 /// Default 0.15.
269 #[export(range = (0.01, 1.0, 0.01))]
270 #[init(val = 0.15)]
271 pub water_wave_scale: f32,
272 /// How fast the wave pattern scrolls; 0 freezes the sea. Default 0.04.
273 #[export(range = (0.0, 0.5, 0.005))]
274 #[init(val = 0.04)]
275 pub water_wave_speed: f32,
276 /// Tint of the underwater fog (Beer–Lambert) applied when the camera is below
277 /// the surface. Default a murky blue.
278 #[export]
279 #[init(val = Color::from_rgb(0.04, 0.16, 0.28))]
280 pub water_underwater_color: Color,
281 /// Underwater fog density, per world unit of water column: higher = visibility
282 /// drops off faster once submerged; 0 = perfectly clear water. Default 0.02.
283 #[export(range = (0.0, 0.2, 0.001))]
284 #[init(val = 0.02)]
285 pub water_underwater_density: f32,
286
287 /// Async-bake hand-back channel (CEL-86). NOT a Godot property: plain shared
288 /// state that `submit_chunk` pushes into from any thread and the planet
289 /// drains on the main thread. Owned here, so nothing points back at the
290 /// planet.
291 submits: Arc<SubmitQueue>,
292}
293
294/// Water APPEARANCE exports, hidden in the inspector when `water_enabled` is off
295/// (the toggle and `water_height` level stay visible). See `on_validate_property`.
296const WATER_LOOK_PROPS: &[&str] = &[
297 "water_deep_color",
298 "water_shallow_color",
299 "water_wave_strength",
300 "water_wave_scale",
301 "water_wave_speed",
302 "water_underwater_color",
303 "water_underwater_density",
304];
305
306#[godot_api]
307impl IResource for CesBuilder {
308 /// Reshape the inspector by (`device`, `builtin_shader`) — read as plain
309 /// fields, NEVER via `self.to_gd()` (which would free a refcount-0
310 /// introspection object and crash). `builtin_shader` is always hidden
311 /// (STORAGE only). `shader_file` shows ONLY for the GPU-custom route. Every
312 /// other property (including the example GDScripts' `@export` knobs) is left
313 /// visible. Hidden fields keep `STORAGE` so values still persist.
314 fn on_validate_property(&self, property: &mut PropertyInfo) {
315 let name = property.property_name.to_string();
316 // `device` and `builtin_shader` are set in code, never edited by hand: a
317 // bare CesBuilder is always GPU (a CPU builder MUST be a subclass that
318 // defines height/color and sets `device = 1` in `_init`), so exposing the
319 // dropdown would only let someone pick a broken CPU-on-bare-builder combo.
320 if name == "builtin_shader" || name == "device" {
321 property.usage = PropertyUsageFlags::STORAGE;
322 return;
323 }
324 if name == "shader_file" {
325 let show = self.device == BuilderDevice::GPU && self.builtin_shader == BuiltinShader::None;
326 if !show {
327 property.usage = PropertyUsageFlags::STORAGE;
328 }
329 return;
330 }
331 // Water APPEARANCE params show only when the water toggle is on (the
332 // toggle + `water_height` level stay visible so the sea can be placed /
333 // enabled). Hidden fields keep STORAGE so their values persist.
334 if WATER_LOOK_PROPS.contains(&name.as_str()) && !self.water_enabled {
335 property.usage = PropertyUsageFlags::STORAGE;
336 return;
337 }
338 // anything a user/example subclass adds: leave as-is.
339 }
340}
341
342impl CesBuilder {
343 /// Read a noise knob by GDScript-property name from the script instance,
344 /// falling back to `default` when the property is absent (a bare custom
345 /// builder has no knobs). Safe in an ordinary method (`to_gd()` re-acquires
346 /// the live `Gd`); NEVER call from `on_validate_property`.
347 fn read_knob(&self, name: &str, default: f32) -> f32 {
348 self.to_gd().get(name).try_to::<f32>().unwrap_or(default)
349 }
350
351 /// Collect the noise knobs (declared as `@export`s on the example GDScripts)
352 /// into the plain params struct, defaulting to the HQ terrain when absent.
353 pub fn params(&self) -> BuilderParams {
354 let d = BuilderParams::default();
355 BuilderParams {
356 frequency: self.read_knob("frequency", d.frequency),
357 height_octaves: self.read_knob("octaves", d.height_octaves),
358 height_amp: self.read_knob("amp", d.height_amp),
359 height_gain: self.read_knob("gain", d.height_gain),
360 height_lacunarity: self.read_knob("lacunarity", d.height_lacunarity),
361 ridge_tiles: self.read_knob("ridge_tiles", d.ridge_tiles),
362 ridge_octaves: self.read_knob("ridge_octaves", d.ridge_octaves),
363 ridge_gain: self.read_knob("ridge_gain", d.ridge_gain),
364 ridge_lacunarity: self.read_knob("ridge_lacunarity", d.ridge_lacunarity),
365 ridge_strength: self.read_knob("ridge_strength", d.ridge_strength),
366 height_scale: self.read_knob("height_scale", d.height_scale),
367 fd_eps: self.read_knob("fd_eps", d.fd_eps),
368 water_height: self.read_knob("water_height", d.water_height),
369 }
370 }
371
372 /// Geometry (height) params for the inline example shader.
373 pub fn to_height_gpu(&self) -> HeightGpu {
374 builder_height_gpu(&self.params())
375 }
376 /// Surface (texture) params for the inline example shader.
377 pub fn to_texture_gpu(&self) -> TextureGpu {
378 builder_texture_gpu(&self.params())
379 }
380 /// CPU-noise params for [`crate::noise_provider::NoiseProvider`]
381 /// (`CpuNoise`). `radius` is the planet radius (for the FD normal).
382 pub fn to_noise_params(&self, radius: f32) -> NoiseParams {
383 let p = self.params();
384 NoiseParams {
385 tiles: p.frequency,
386 octaves: p.height_octaves.round().clamp(1.0, 12.0) as u32,
387 gain: p.height_gain,
388 lacunarity: p.height_lacunarity,
389 amp: p.height_amp,
390 height_scale: p.height_scale,
391 water_height: p.water_height,
392 ridge_tiles: p.ridge_tiles,
393 ridge_octaves: p.ridge_octaves.round().clamp(1.0, 12.0) as u32,
394 ridge_gain: p.ridge_gain,
395 ridge_lacunarity: p.ridge_lacunarity,
396 ridge_strength: p.ridge_strength,
397 radius,
398 }
399 }
400
401 /// The builder's normalized sea level (0..1). Read as a knob so a custom
402 /// GPU builder (no `water_height` @export) falls back to the default.
403 pub fn water_height(&self) -> f32 {
404 self.read_knob("water_height", BuilderParams::default().water_height)
405 }
406
407 /// The builder's geometry displacement multiplier. Read as a knob so a
408 /// custom GPU builder (no `height_scale` @export) falls back to the default.
409 pub fn height_scale(&self) -> f32 {
410 self.read_knob("height_scale", BuilderParams::default().height_scale)
411 }
412
413 /// The async-bake hand-back queue (the planet drains it each frame).
414 pub fn submits(&self) -> Arc<SubmitQueue> {
415 Arc::clone(&self.submits)
416 }
417}
418
419// ---- CpuCustom surface functions ------------------------------------------
420//
421// A `CpuCustom` builder is a GDScript `extends CesBuilder` that DEFINES (not
422// overrides — the base deliberately has no such methods, so there is no
423// native-shadow warning) the same three functions as the GPU `.glsl`, BATCHED
424// over one chunk's texel directions:
425//
426// func height(dirs: PackedVector3Array) -> PackedFloat32Array
427// func color(dirs: PackedVector3Array, hs: PackedFloat32Array) -> PackedColorArray
428// func normal(dirs: PackedVector3Array, hs: PackedFloat32Array) -> PackedVector3Array # OPTIONAL
429//
430// The planet calls whichever are present (`Object::has_method`); a missing
431// `height` = flat, missing `color` = white, missing `normal` = finite-difference.
432//
433// ---- CpuCustomAsync surface functions (CEL-86) -----------------------------
434//
435// Instead, a builder may define `_bake_requested` — then the bake is async and
436// the planet never blocks on it:
437//
438// func _bake_requested(requests: Array) -> void # main thread; MUST NOT BLOCK
439// func _base_ready() -> bool # OPTIONAL (default true)
440//
441// Each request is `{handle, corners, tile_res, depth}`. Bake however you like
442// (WorkerThreadPool, HTTPRequest, a disk cache) and call `submit_chunk` when a
443// surface is ready — from any thread, at any time. Submitting an already-
444// resident chunk again REFINES it (a coarse tile now, a finer one when the
445// download lands); submitting a chunk that has left the view is dropped, which
446// is what makes cancellation a no-op rather than an API.
447
448#[godot_api]
449impl CesBuilder {
450 // ---- async bake (CEL-86) ------------------------------------------------
451
452 /// Hand a finished chunk surface back to the planet. **Callable from any
453 /// thread** (it takes a mutex and returns; the planet drains next frame).
454 ///
455 /// * `handle` — the `handle` from the matching `_bake_requested` entry.
456 /// * `heights` — `tile_res²` displacements in YOUR vertical unit, row-major
457 /// (the `height_scale` property converts them to displaced radius).
458 /// * `colors` — `tile_res²` albedos, row-major.
459 /// * `normals` — optional; leave empty to have the library finite-difference
460 /// the height grid for you.
461 ///
462 /// Idempotent: call it again for the same handle to refine that chunk (a coarse
463 /// tile now, a finer one when the download lands). A submission for a chunk that
464 /// has left the view is simply dropped — that is the whole cancellation story.
465 /// Wrong-length arrays are rejected (one error is printed, then silence);
466 /// non-finite heights are sanitized to `0.0`.
467 ///
468 /// The full contract (the `_bake_requested` request format, `_base_ready`) is in
469 /// the "Advanced: async bake" section of the custom-CPU-terrain guide at
470 /// <https://celestialsim.github.io/CelestialSim/>.
471 #[func]
472 pub fn submit_chunk(
473 &self,
474 handle: i64,
475 heights: PackedFloat32Array,
476 colors: PackedColorArray,
477 // Empty (the default) ⇒ no normals ⇒ finite-difference. The `&` is
478 // required: packed arrays are passed to Godot by reference.
479 #[opt(default = &PackedVector3Array::new())] normals: PackedVector3Array,
480 ) {
481 // Copy out of the PackedArrays: they are not `Send`, and the queue must
482 // be readable from the main thread while a worker keeps pushing.
483 self.submits.push(RawSubmission {
484 handle,
485 heights: heights.as_slice().to_vec(),
486 colors: colors.as_slice().to_vec(),
487 normals: normals.as_slice().to_vec(),
488 });
489 }
490
491 /// The `tile_res²` texel-centre world directions of a chunk, row-major —
492 /// the same mapping the shaders use.
493 ///
494 /// Not included in a bake request (786 KB per chunk at `tile_res = 256`, and
495 /// a streaming builder wants a lat/lon box, not directions), so materialize
496 /// them only if you need them. Pure: safe to call from a worker thread.
497 #[func]
498 pub fn chunk_dirs(&self, corners: PackedVector3Array, tile_res: i64) -> PackedVector3Array {
499 let c = corners.as_slice();
500 if c.len() != 3 || tile_res <= 0 {
501 godot_error!("chunk_dirs: expected 3 corners and tile_res > 0");
502 return PackedVector3Array::new();
503 }
504 let dirs = async_bake::chunk_dirs([c[0], c[1], c[2]], tile_res as u32);
505 PackedVector3Array::from(&dirs[..])
506 }
507
508 // ---- property accessors (each emits `changed` so live edits reshade) ----
509
510 #[func]
511 pub fn get_device(&self) -> BuilderDevice {
512 self.device
513 }
514 #[func]
515 pub fn set_device(&mut self, v: BuilderDevice) {
516 if self.device != v {
517 self.device = v;
518 // Reshape the inspector (show/hide fields for the new mode) and let
519 // the planet re-route/rebuild.
520 self.base_mut().notify_property_list_changed();
521 self.base_mut().emit_changed();
522 }
523 }
524 #[func]
525 pub fn get_builtin_shader(&self) -> BuiltinShader {
526 self.builtin_shader
527 }
528 #[func]
529 pub fn set_builtin_shader(&mut self, v: BuiltinShader) {
530 if self.builtin_shader != v {
531 self.builtin_shader = v;
532 self.base_mut().notify_property_list_changed();
533 self.base_mut().emit_changed();
534 }
535 }
536 #[func]
537 pub fn get_shader_file(&self) -> GString {
538 self.shader_file.clone()
539 }
540 #[func]
541 pub fn set_shader_file(&mut self, v: GString) {
542 if self.shader_file != v {
543 self.shader_file = v;
544 self.base_mut().emit_changed();
545 }
546 }
547 #[func]
548 pub fn get_water_height(&self) -> f32 {
549 self.water_height
550 }
551 #[func]
552 pub fn set_water_height(&mut self, v: f32) {
553 if self.water_height != v {
554 self.water_height = v;
555 // The water level is a TERRAIN parameter (it sets the land/sea split
556 // and shore colouring), not only the analytic sphere's radius. A bare
557 // `#[export]` would move the sphere (read live by `update_water`) but
558 // leave the baked terrain stale. Emitting `changed` runs the planet's
559 // connected reshade — re-realizing + re-baking the whole planet at the
560 // new level — exactly like the noise-knob poll does for script vars.
561 self.base_mut().emit_changed();
562 }
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::descriptors::assemble;
570
571 #[test]
572 fn default_params_map_to_hq_defaults() {
573 let p = BuilderParams::default();
574 assert_eq!(builder_height_gpu(&p), HeightGpu::default());
575 assert_eq!(builder_texture_gpu(&p), TextureGpu::default());
576 }
577
578 #[test]
579 fn default_builder_assembles_to_current_terrain() {
580 let p = BuilderParams::default();
581 let from_builder = assemble(&builder_height_gpu(&p), &builder_texture_gpu(&p));
582 let from_defaults = assemble(&HeightGpu::default(), &TextureGpu::default());
583 assert_eq!(from_builder, from_defaults);
584 }
585
586 #[test]
587 fn default_device_is_gpu() {
588 assert_eq!(BuilderDevice::default(), BuilderDevice::GPU);
589 }
590
591 #[test]
592 fn default_builtin_shader_is_none() {
593 assert_eq!(BuiltinShader::default(), BuiltinShader::None);
594 }
595
596 #[test]
597 fn water_edit_only_touches_texture_gpu() {
598 let mut p = BuilderParams::default();
599 p.water_height = 0.9;
600 assert_eq!(builder_texture_gpu(&p).water_height, 0.9);
601 assert_eq!(builder_height_gpu(&p), HeightGpu::default());
602 }
603}