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}