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