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}