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