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}