Skip to main content

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}