Skip to main content

damascene_core/scene/
style.rs

1//! Per-mark styles and scene-level styling: materials, point/line styles,
2//! the light rig, the reference grid, and the overall [`SceneStyle`].
3//!
4//! All colours here are **authoring-space** [`Color`]; the backend
5//! converts them to the runner's working linear space at upload (via
6//! `crate::paint::rgba_f32_in`), so the scene tracks damascene's colour
7//! management and is HDR-ready. Nothing here encodes for output.
8
9use glam::Vec3;
10
11use crate::color::Color;
12use crate::shader::{ShaderHandle, UniformBlock};
13
14/// Material for a mesh mark.
15///
16/// The stock recipes ([`Material::Matte`], [`Material::Glossy`],
17/// [`Material::Flat`]) cover V1. [`Material::Custom`] is carried in the type
18/// from day one so adding it is non-breaking, but it is implemented post-V1
19/// (plan M5): an app reskins the fragment via damascene's existing custom-shader
20/// path while damascene keeps the vertex layout, buffers, passes, depth, and
21/// device. Supplying a custom *pipeline* (not just a material) is `surface()`,
22/// not this.
23#[derive(Clone, Debug)]
24pub enum Material {
25    /// Forward-lit diffuse surface, shaded by the [`LightRig`].
26    Matte { base: Color },
27    /// Forward-lit diffuse surface with a Blinn-Phong specular highlight, for
28    /// a glossier read. The highlight takes the key light's colour.
29    Glossy {
30        base: Color,
31        /// Highlight strength, `[0, 1]`. `0` is matte.
32        specular: f32,
33        /// Phong exponent: higher is a tighter, glassier highlight (clamped
34        /// to `>= 1`).
35        shininess: f32,
36    },
37    /// Unlit constant colour (e.g. emissive markers, schematic fills).
38    Flat { color: Color },
39    /// App-supplied material shader. Post-V1; see the type docs.
40    Custom {
41        shader: ShaderHandle,
42        uniforms: UniformBlock,
43    },
44}
45
46impl Material {
47    /// Forward-lit diffuse surface.
48    pub fn matte(base: Color) -> Self {
49        Material::Matte { base }
50    }
51
52    /// Unlit constant colour.
53    pub fn flat(color: Color) -> Self {
54        Material::Flat { color }
55    }
56
57    /// Forward-lit with a moderate specular highlight (`specular = 0.5`,
58    /// `shininess = 32`). Tune the fields directly for a sharper or softer
59    /// gloss.
60    pub fn glossy(base: Color) -> Self {
61        Material::Glossy {
62            base,
63            specular: 0.5,
64            shininess: 32.0,
65        }
66    }
67}
68
69impl Default for Material {
70    fn default() -> Self {
71        Material::Matte {
72            base: Color::srgb_u8(214, 220, 230),
73        }
74    }
75}
76
77/// Whether a point size / line width is in screen pixels (constant on
78/// screen regardless of zoom) or world units (scales with the scene).
79#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
80pub enum SizeMode {
81    #[default]
82    ScreenSpace,
83    World,
84}
85
86/// Marker shape for point marks.
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
88pub enum PointShape {
89    #[default]
90    Circle,
91    Square,
92}
93
94/// Style for a point/scatter mark. Per-point colour lives in the geometry
95/// ([`crate::scene::ScenePoint`]); this carries size and shape.
96#[derive(Clone, Copy, Debug, PartialEq)]
97pub struct PointStyle {
98    pub size: f32,
99    pub shape: PointShape,
100    pub size_mode: SizeMode,
101}
102
103impl Default for PointStyle {
104    fn default() -> Self {
105        Self {
106            size: 5.0,
107            shape: PointShape::Circle,
108            size_mode: SizeMode::ScreenSpace,
109        }
110    }
111}
112
113/// Stroke pattern for line marks.
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
115pub enum LinePattern {
116    #[default]
117    Solid,
118    Dashed,
119}
120
121/// Style for a line mark. Per-segment colour lives in the geometry
122/// ([`crate::scene::LineSegment`]); this carries width and pattern.
123#[derive(Clone, Copy, Debug, PartialEq)]
124pub struct LineStyle {
125    pub width: f32,
126    pub pattern: LinePattern,
127    pub size_mode: SizeMode,
128}
129
130impl Default for LineStyle {
131    fn default() -> Self {
132        Self {
133            width: 1.5,
134            pattern: LinePattern::Solid,
135            size_mode: SizeMode::ScreenSpace,
136        }
137    }
138}
139
140/// The fixed, small lighting rig: one directional key light plus a
141/// hemispheric ambient fill. Closed-scope — enough to make small models read
142/// as 3D without a deferred/SSAO pass.
143///
144/// The ambient term is **hemispheric**: upward-facing surfaces pick up
145/// [`sky_color`](Self::sky_color), downward-facing ones
146/// [`ground_color`](Self::ground_color), blended by the surface normal's
147/// vertical component and scaled by [`ambient`](Self::ambient). Set sky and
148/// ground equal for a flat ambient.
149#[derive(Clone, Copy, Debug, PartialEq)]
150pub struct LightRig {
151    /// World-space direction **toward** the key light (the `L` in
152    /// `dot(N, L)`). Need not be normalised; the backend normalises.
153    pub key_direction: Vec3,
154    pub key_color: Color,
155    pub key_intensity: f32,
156    /// Hemispheric ambient seen by upward-facing surfaces (the "sky").
157    pub sky_color: Color,
158    /// Hemispheric ambient seen by downward-facing surfaces (the "ground").
159    pub ground_color: Color,
160    /// Overall scale of the hemispheric ambient fill, `[0, 1]`, lifting
161    /// shadowed faces.
162    pub ambient: f32,
163}
164
165impl Default for LightRig {
166    fn default() -> Self {
167        Self {
168            key_direction: Vec3::new(0.4, 0.7, 0.2).normalize(),
169            key_color: Color::srgb_u8(255, 255, 255),
170            key_intensity: 1.0,
171            sky_color: Color::srgb_u8(236, 242, 255),
172            ground_color: Color::srgb_u8(140, 144, 150),
173            ambient: 0.35,
174        }
175    }
176}
177
178/// Which world planes carry reference grid lines.
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180pub struct GridPlanes {
181    pub xy: bool,
182    pub xz: bool,
183    pub yz: bool,
184}
185
186impl GridPlanes {
187    pub const NONE: GridPlanes = GridPlanes {
188        xy: false,
189        xz: false,
190        yz: false,
191    };
192    /// The ground plane — the common default for data/model viewers.
193    pub const XZ: GridPlanes = GridPlanes {
194        xy: false,
195        xz: true,
196        yz: false,
197    };
198}
199
200impl Default for GridPlanes {
201    fn default() -> Self {
202        GridPlanes::XZ
203    }
204}
205
206/// Optional per-axis world bounds for the reference grid, axis lines, and
207/// ticks. When an axis is `Some((min, max))`, the grid plane lines spanning
208/// it, that axis's line, and its ticks/title are clipped to `[min, max]`
209/// instead of the symmetric `[-extent, extent]`; `None` falls back to the
210/// symmetric span. Lets a naturally one-sided axis (e.g. CIE L\* ∈ [0, 100])
211/// bound the drawn space to where data can actually live.
212#[derive(Clone, Copy, Debug, PartialEq, Default)]
213pub struct AxisBounds {
214    pub x: Option<(f32, f32)>,
215    pub y: Option<(f32, f32)>,
216    pub z: Option<(f32, f32)>,
217}
218
219impl AxisBounds {
220    /// The bound for axis `i` (0 = X, 1 = Y, 2 = Z), if set.
221    pub(crate) fn axis(&self, i: usize) -> Option<(f32, f32)> {
222        match i {
223            0 => self.x,
224            1 => self.y,
225            2 => self.z,
226            _ => None,
227        }
228    }
229}
230
231/// Reference grid configuration. The backend generates the line geometry
232/// from these settings and draws it through the line pipeline; core just
233/// carries the settings.
234#[derive(Clone, Copy, Debug, PartialEq)]
235pub struct GridSettings {
236    pub planes: GridPlanes,
237    /// World-space distance between major grid lines.
238    pub spacing: f32,
239    /// Half-size of the grid: the symmetric `[-extent, extent]` span used by
240    /// any axis without an explicit [`bounds`](Self::bounds) entry.
241    pub extent: f32,
242    /// Minor subdivisions between major lines (`1` = none).
243    pub subdivisions: u32,
244    pub color: Color,
245    /// Optional per-axis world bounds overriding the symmetric `extent`.
246    pub bounds: AxisBounds,
247}
248
249impl Default for GridSettings {
250    fn default() -> Self {
251        Self {
252            planes: GridPlanes::default(),
253            spacing: 1.0,
254            extent: 10.0,
255            subdivisions: 1,
256            color: Color::srgb_u8a(120, 120, 132, 90),
257            bounds: AxisBounds::default(),
258        }
259    }
260}
261
262impl GridSettings {
263    /// Effective world `[min, max]` for each axis (X, Y, Z): the per-axis
264    /// [`bounds`](Self::bounds) entry when set, else the symmetric
265    /// `[-extent, extent]`. Each span is normalised so `min <= max`. This is
266    /// the single source of truth read by both the GPU grid/axis-line
267    /// generation and the CPU-side tick/title labelling.
268    pub(crate) fn axis_spans(&self) -> [(f32, f32); 3] {
269        let e = self.extent.max(0.0);
270        let fb = (-e, e);
271        std::array::from_fn(|i| {
272            let (a, b) = self.bounds.axis(i).unwrap_or(fb);
273            (a.min(b), a.max(b))
274        })
275    }
276}
277
278/// Scene-level styling. The working colour space is *not* stored here —
279/// it is the runner's, read by the backend at render time so the scene
280/// renders in whatever space the UI is in.
281#[derive(Clone, Copy, Debug, PartialEq)]
282pub struct SceneStyle {
283    pub grid: GridSettings,
284    /// Background fill for the scene viewport. `None` leaves it
285    /// transparent so the UI behind shows through; `Some` fills it.
286    pub background: Option<Color>,
287    /// MSAA sample count for the offscreen scene target (`1` or `4`).
288    /// Defaults to `4` — small graphs sit next to crisp UI text, so the
289    /// scene must be antialiased and resolved before compositing.
290    pub msaa_samples: u32,
291    /// Draw axis lines/labels.
292    pub show_axes: bool,
293}
294
295impl Default for SceneStyle {
296    fn default() -> Self {
297        Self {
298            grid: GridSettings::default(),
299            background: None,
300            msaa_samples: 4,
301            show_axes: true,
302        }
303    }
304}
305
306impl SceneStyle {
307    /// World-space bounds of the reference grid + axes, for sizing the
308    /// camera's near/far so they're never clipped. `None` when nothing
309    /// reference-like is drawn. Builds the actual (possibly asymmetric) box
310    /// from the per-axis spans, so a bounded tall axis (e.g. L\* ∈ [0, 100])
311    /// stays enclosed; slight overestimation of the flat planes only widens
312    /// the depth range harmlessly.
313    pub fn reference_extent(&self) -> Option<crate::scene::Aabb> {
314        let draws_grid = self.grid.planes != GridPlanes::NONE && self.grid.extent.max(0.0) > 0.0;
315        let draws_axes = self.show_axes;
316        if !draws_grid && !draws_axes {
317            return None;
318        }
319        let spans = self.grid.axis_spans();
320        // Axis lines fall back to a slightly larger reach than the grid
321        // (`extent.max(spacing).max(1)`) so a tiny/zero grid still shows
322        // unit axes; an explicit bound governs both.
323        let axis_fallback = self.grid.extent.max(self.grid.spacing).max(1.0);
324        let mut lo = [0.0f32; 3];
325        let mut hi = [0.0f32; 3];
326        for i in 0..3 {
327            let (mut amin, mut amax) = (0.0f32, 0.0f32);
328            if let Some((bmin, bmax)) = self.grid.bounds.axis(i) {
329                amin = amin.min(bmin.min(bmax));
330                amax = amax.max(bmin.max(bmax));
331            } else {
332                if draws_grid {
333                    amin = amin.min(spans[i].0);
334                    amax = amax.max(spans[i].1);
335                }
336                if draws_axes {
337                    amin = amin.min(-axis_fallback);
338                    amax = amax.max(axis_fallback);
339                }
340            }
341            lo[i] = amin;
342            hi[i] = amax;
343        }
344        let aabb = crate::scene::Aabb::from_points([
345            glam::Vec3::from_array(lo),
346            glam::Vec3::from_array(hi),
347        ]);
348        aabb.is_valid().then_some(aabb)
349    }
350}