damascene-core 0.6.0

Damascene — backend-agnostic UI library core
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! Per-mark styles and scene-level styling: materials, point/line styles,
//! the light rig, the reference grid, and the overall [`SceneStyle`].
//!
//! All colours here are **authoring-space** [`Color`]; the backend
//! converts them to the runner's working linear space at upload (via
//! `crate::paint::rgba_f32_in`), so the scene tracks damascene's colour
//! management and is HDR-ready. Nothing here encodes for output.

// Lock in full per-item documentation for this module (issue #73).
#![warn(missing_docs)]

use glam::Vec3;

use crate::color::Color;
use crate::shader::{ShaderHandle, UniformBlock};

/// Material for a mesh mark.
///
/// The stock recipes ([`Material::Matte`], [`Material::Glossy`],
/// [`Material::Flat`]) cover V1. [`Material::Custom`] is carried in the type
/// from day one so adding it is non-breaking, but it is implemented post-V1
/// (plan M5): an app reskins the fragment via damascene's existing custom-shader
/// path while damascene keeps the vertex layout, buffers, passes, depth, and
/// device. Supplying a custom *pipeline* (not just a material) is `surface()`,
/// not this.
///
/// # Translucency
///
/// The material colour's alpha is the mesh's opacity, like CSS `rgba(..)` —
/// there is no separate translucent material. Alpha < 1 routes the mesh
/// through the translucent render path: depth-tested against opaque geometry
/// but not depth-written, drawn after all opaque meshes back-to-front, and
/// rendered two-sided in two passes (back faces, then front faces) so a
/// closed shell composites correctly from any angle. Point and line marks
/// still draw on top — a translucent surface never veils data marks. The
/// per-mesh painter's sort is exact for separated convex-ish shapes;
/// *intersecting* translucent meshes can blend in draw order (full
/// order-independent transparency is out of scope for the widget).
#[derive(Clone, Debug)]
pub enum Material {
    /// Forward-lit diffuse surface, shaded by the [`LightRig`].
    Matte {
        /// Diffuse base colour; its alpha is the mesh's opacity.
        base: Color,
    },
    /// Forward-lit diffuse surface with a Blinn-Phong specular highlight, for
    /// a glossier read. The highlight takes the key light's colour.
    Glossy {
        /// Diffuse base colour; its alpha is the mesh's opacity.
        base: Color,
        /// Highlight strength, `[0, 1]`. `0` is matte.
        specular: f32,
        /// Phong exponent: higher is a tighter, glassier highlight (clamped
        /// to `>= 1`).
        shininess: f32,
    },
    /// Unlit constant colour (e.g. emissive markers, schematic fills).
    Flat {
        /// The constant surface colour; its alpha is the mesh's opacity.
        color: Color,
    },
    /// App-supplied material shader. Post-V1; see the type docs.
    Custom {
        /// The app-registered fragment shader that reskins the surface.
        shader: ShaderHandle,
        /// Uniform data made available to the custom shader.
        uniforms: UniformBlock,
    },
}

impl Material {
    /// Forward-lit diffuse surface.
    pub fn matte(base: Color) -> Self {
        Material::Matte { base }
    }

    /// Unlit constant colour.
    pub fn flat(color: Color) -> Self {
        Material::Flat { color }
    }

    /// Forward-lit with a moderate specular highlight (`specular = 0.5`,
    /// `shininess = 32`). Tune the fields directly for a sharper or softer
    /// gloss.
    pub fn glossy(base: Color) -> Self {
        Material::Glossy {
            base,
            specular: 0.5,
            shininess: 32.0,
        }
    }

    /// The material colour's alpha — the mesh's opacity (see the type docs).
    /// `Custom` is treated as opaque until M5 lands.
    pub fn opacity(&self) -> f32 {
        match self {
            Material::Matte { base } | Material::Glossy { base, .. } => base.a,
            Material::Flat { color } => color.a,
            Material::Custom { .. } => 1.0,
        }
    }

    /// Whether this material routes through the translucent mesh path
    /// (depth-test only, two-sided, painter's-sorted; see the type docs).
    pub fn is_translucent(&self) -> bool {
        self.opacity() < 1.0
    }
}

impl Default for Material {
    fn default() -> Self {
        Material::Matte {
            base: Color::srgb_u8(214, 220, 230),
        }
    }
}

/// Whether a point size / line width is in screen pixels (constant on
/// screen regardless of zoom) or world units (scales with the scene).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum SizeMode {
    /// Size is in screen pixels, constant regardless of camera distance.
    #[default]
    ScreenSpace,
    /// Size is in world units, scaling with the scene as the camera moves.
    World,
}

/// Marker shape for point marks.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PointShape {
    /// Round marker.
    #[default]
    Circle,
    /// Square marker.
    Square,
}

/// Style for a point/scatter mark. Per-point colour lives in the geometry
/// ([`crate::scene::ScenePoint`]); this carries size and shape.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PointStyle {
    /// Marker size, in the units chosen by [`size_mode`](Self::size_mode).
    /// Defaults to `5.0` (screen pixels).
    pub size: f32,
    /// Marker shape. Defaults to [`PointShape::Circle`].
    pub shape: PointShape,
    /// Whether `size` is screen pixels or world units. Defaults to
    /// [`SizeMode::ScreenSpace`].
    pub size_mode: SizeMode,
}

impl Default for PointStyle {
    fn default() -> Self {
        Self {
            size: 5.0,
            shape: PointShape::Circle,
            size_mode: SizeMode::ScreenSpace,
        }
    }
}

/// Stroke pattern for line marks.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum LinePattern {
    /// Continuous stroke.
    #[default]
    Solid,
    /// Dashed stroke.
    Dashed,
}

/// Style for a line mark. Per-segment colour lives in the geometry
/// ([`crate::scene::LineSegment`]); this carries width and pattern.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LineStyle {
    /// Stroke width, in the units chosen by [`size_mode`](Self::size_mode).
    /// Defaults to `1.5` (screen pixels).
    pub width: f32,
    /// Stroke pattern. Defaults to [`LinePattern::Solid`].
    pub pattern: LinePattern,
    /// Whether `width` is screen pixels or world units. Defaults to
    /// [`SizeMode::ScreenSpace`].
    pub size_mode: SizeMode,
}

impl Default for LineStyle {
    fn default() -> Self {
        Self {
            width: 1.5,
            pattern: LinePattern::Solid,
            size_mode: SizeMode::ScreenSpace,
        }
    }
}

/// The fixed, small lighting rig: one directional key light plus a
/// hemispheric ambient fill. Closed-scope — enough to make small models read
/// as 3D without a deferred/SSAO pass.
///
/// The ambient term is **hemispheric**: upward-facing surfaces pick up
/// [`sky_color`](Self::sky_color), downward-facing ones
/// [`ground_color`](Self::ground_color), blended by the surface normal's
/// vertical component and scaled by [`ambient`](Self::ambient). Set sky and
/// ground equal for a flat ambient.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LightRig {
    /// World-space direction **toward** the key light (the `L` in
    /// `dot(N, L)`). Need not be normalised; the backend normalises.
    pub key_direction: Vec3,
    /// Authoring-space colour of the key light (also tints Glossy
    /// highlights).
    pub key_color: Color,
    /// Scalar multiplier on the key light's contribution. `1.0` is the
    /// default; `0` disables the key light, leaving only ambient.
    pub key_intensity: f32,
    /// Hemispheric ambient seen by upward-facing surfaces (the "sky").
    pub sky_color: Color,
    /// Hemispheric ambient seen by downward-facing surfaces (the "ground").
    pub ground_color: Color,
    /// Overall scale of the hemispheric ambient fill, `[0, 1]`, lifting
    /// shadowed faces.
    pub ambient: f32,
}

impl Default for LightRig {
    fn default() -> Self {
        Self {
            key_direction: Vec3::new(0.4, 0.7, 0.2).normalize(),
            key_color: Color::srgb_u8(255, 255, 255),
            key_intensity: 1.0,
            sky_color: Color::srgb_u8(236, 242, 255),
            ground_color: Color::srgb_u8(140, 144, 150),
            ambient: 0.35,
        }
    }
}

/// Which world planes carry reference grid lines.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GridPlanes {
    /// Grid lines on the XY plane (`z = 0`).
    pub xy: bool,
    /// Grid lines on the XZ plane (`y = 0`) — the ground plane.
    pub xz: bool,
    /// Grid lines on the YZ plane (`x = 0`).
    pub yz: bool,
}

impl GridPlanes {
    /// No grid planes — disables the reference grid.
    pub const NONE: GridPlanes = GridPlanes {
        xy: false,
        xz: false,
        yz: false,
    };
    /// The ground plane — the common default for data/model viewers.
    pub const XZ: GridPlanes = GridPlanes {
        xy: false,
        xz: true,
        yz: false,
    };
}

impl Default for GridPlanes {
    fn default() -> Self {
        GridPlanes::XZ
    }
}

/// Optional per-axis world bounds for the reference grid, axis lines, and
/// ticks. When an axis is `Some((min, max))`, the grid plane lines spanning
/// it, that axis's line, and its ticks/title are clipped to `[min, max]`
/// instead of the symmetric `[-extent, extent]`; `None` falls back to the
/// symmetric span. Lets a naturally one-sided axis (e.g. CIE L\* ∈ [0, 100])
/// bound the drawn space to where data can actually live.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct AxisBounds {
    /// World `(min, max)` for the X axis, or `None` for the symmetric span.
    pub x: Option<(f32, f32)>,
    /// World `(min, max)` for the Y axis, or `None` for the symmetric span.
    pub y: Option<(f32, f32)>,
    /// World `(min, max)` for the Z axis, or `None` for the symmetric span.
    pub z: Option<(f32, f32)>,
}

impl AxisBounds {
    /// The bound for axis `i` (0 = X, 1 = Y, 2 = Z), if set.
    pub(crate) fn axis(&self, i: usize) -> Option<(f32, f32)> {
        match i {
            0 => self.x,
            1 => self.y,
            2 => self.z,
            _ => None,
        }
    }
}

/// Reference grid configuration. The backend generates the line geometry
/// from these settings and draws it through the line pipeline; core just
/// carries the settings.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GridSettings {
    /// Which world planes carry grid lines. Defaults to [`GridPlanes::XZ`].
    pub planes: GridPlanes,
    /// World-space distance between major grid lines.
    pub spacing: f32,
    /// Half-size of the grid: the symmetric `[-extent, extent]` span used by
    /// any axis without an explicit [`bounds`](Self::bounds) entry.
    pub extent: f32,
    /// Minor subdivisions between major lines (`1` = none).
    pub subdivisions: u32,
    /// Authoring-space colour of the grid lines (major and minor alike).
    pub color: Color,
    /// Optional per-axis world bounds overriding the symmetric `extent`.
    pub bounds: AxisBounds,
}

impl Default for GridSettings {
    fn default() -> Self {
        Self {
            planes: GridPlanes::default(),
            spacing: 1.0,
            extent: 10.0,
            subdivisions: 1,
            color: Color::srgb_u8a(120, 120, 132, 90),
            bounds: AxisBounds::default(),
        }
    }
}

impl GridSettings {
    /// Effective world `[min, max]` for each axis (X, Y, Z): the per-axis
    /// [`bounds`](Self::bounds) entry when set, else the symmetric
    /// `[-extent, extent]`. Each span is normalised so `min <= max`. This is
    /// the single source of truth read by both the GPU grid/axis-line
    /// generation and the CPU-side tick/title labelling.
    pub(crate) fn axis_spans(&self) -> [(f32, f32); 3] {
        let e = self.extent.max(0.0);
        let fb = (-e, e);
        std::array::from_fn(|i| {
            let (a, b) = self.bounds.axis(i).unwrap_or(fb);
            (a.min(b), a.max(b))
        })
    }
}

/// Scene-level styling. The working colour space is *not* stored here —
/// it is the runner's, read by the backend at render time so the scene
/// renders in whatever space the UI is in.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SceneStyle {
    /// Reference grid configuration (planes, spacing, extent, bounds).
    pub grid: GridSettings,
    /// Background fill for the scene viewport. `None` leaves it
    /// transparent so the UI behind shows through; `Some` fills it.
    pub background: Option<Color>,
    /// MSAA sample count for the offscreen scene target (`1` or `4`).
    /// Defaults to `4` — small graphs sit next to crisp UI text, so the
    /// scene must be antialiased and resolved before compositing.
    pub msaa_samples: u32,
    /// Draw axis lines/labels.
    pub show_axes: bool,
}

impl Default for SceneStyle {
    fn default() -> Self {
        Self {
            grid: GridSettings::default(),
            background: None,
            msaa_samples: 4,
            show_axes: true,
        }
    }
}

impl SceneStyle {
    /// World-space bounds of the reference grid + axes, for sizing the
    /// camera's near/far so they're never clipped. `None` when nothing
    /// reference-like is drawn. Builds the actual (possibly asymmetric) box
    /// from the per-axis spans, so a bounded tall axis (e.g. L\* ∈ [0, 100])
    /// stays enclosed; slight overestimation of the flat planes only widens
    /// the depth range harmlessly.
    pub fn reference_extent(&self) -> Option<crate::scene::Aabb> {
        let draws_grid = self.grid.planes != GridPlanes::NONE && self.grid.extent.max(0.0) > 0.0;
        let draws_axes = self.show_axes;
        if !draws_grid && !draws_axes {
            return None;
        }
        let spans = self.grid.axis_spans();
        // Axis lines fall back to a slightly larger reach than the grid
        // (`extent.max(spacing).max(1)`) so a tiny/zero grid still shows
        // unit axes; an explicit bound governs both.
        let axis_fallback = self.grid.extent.max(self.grid.spacing).max(1.0);
        let mut lo = [0.0f32; 3];
        let mut hi = [0.0f32; 3];
        for i in 0..3 {
            let (mut amin, mut amax) = (0.0f32, 0.0f32);
            if let Some((bmin, bmax)) = self.grid.bounds.axis(i) {
                amin = amin.min(bmin.min(bmax));
                amax = amax.max(bmin.max(bmax));
            } else {
                if draws_grid {
                    amin = amin.min(spans[i].0);
                    amax = amax.max(spans[i].1);
                }
                if draws_axes {
                    amin = amin.min(-axis_fallback);
                    amax = amax.max(axis_fallback);
                }
            }
            lo[i] = amin;
            hi[i] = amax;
        }
        let aabb = crate::scene::Aabb::from_points([
            glam::Vec3::from_array(lo),
            glam::Vec3::from_array(hi),
        ]);
        aabb.is_valid().then_some(aabb)
    }
}