indicatrix-cut 0.7.2

Desktop faceting-design editor: library browsing, spectral 3D rendering, material retargeting, and a solid inspection view.
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
422
423
424
425
426
427
//! Off-UI-thread mini preview render for the Tilt Performance dialog: as the user
//! hovers/scrubs the curve, a small thumbnail shows the stone at the hovered tilt
//! angle and axis, so the numbers on the chart have a picture to go with them.
//!
//! [`camera_pose_for_hover`] derives `(cam_yaw, cam_pitch)` from the hovered
//! `(axis_index, tilt_deg)` using exactly the tilt-to-pose formula
//! `evaluate_full_axis_profile_at_azimuth` sweeps under: `tilt_deg` is tilt away from
//! table-up (not camera elevation), so `cam_pitch = 90° - |tilt_deg|` and `cam_yaw` is
//! that axis's `PROFILE_AZIMUTHS_DEG` entry, shifted by 180° whenever `tilt_deg` is
//! negative. Reusing the identical formula is what makes this preview agree with the
//! curve it illustrates, rather than showing some other, merely-plausible pose.
//!
//! This is its own one-off render, not a reuse of the live viewport: the live
//! viewport (`bridge::render_thread`) continuously accumulates samples into buffers
//! sized to the configured render resolution, driven by the actual camera the user is
//! orbiting. Hijacking that loop for a hover-driven pose would either fight the user's
//! own camera for the same buffers or need a second full accumulation buffer at full
//! viewport resolution. Instead, following the same shape `bridge::export_thread`
//! uses for its own independent one-off render, this module traces its own tiny
//! buffer directly, with its own single-shot low-sample accumulation, on its own
//! thread -- it never touches `RenderContext`'s accumulation buffers and never blocks
//! the UI thread or the live render thread.
//!
//! Debounced rather than throttled like `PreviewThrottle`'s fixed-minimum-interval
//! export thumbnail: dragging across the 181-point curve fires a request per pixel of
//! mouse movement, and an intermediate point from three renders ago is actively
//! useless the moment the user has moved on -- only wherever the cursor settles is
//! worth spending a render on. So each request starts a short sleep, and only
//! actually renders if no newer request has arrived by the time it wakes up (checked
//! via a generation counter, the same superseded-work pattern `gui::tilt_profile`
//! uses for its background sweep). A fast scrub therefore queues many cheap
//! sleep-then-bail threads but renders at most once per quiet period.

use crate::{
    MainWindow, TiltModel,
    bridge::render_thread::{RenderContext, resolve_material_with_override},
    gui::editor::FinishedStoneJob,
};
use glam::Vec3;
use indicatrix::{
    color::metrics::PROFILE_AZIMUTHS_DEG,
    geometry::{
        plane::GpuFacetPlane,
        tool::{StoneGeometry, ToolPrimitive},
    },
    optics::{
        fluorescence::Fluorescence,
        materials::GemMaterial,
        raytracer::{
            Camera, DEFAULT_FOV_DEG, add_finite_sample, pixel_rotations, sample_draws,
            trace_spectral_ray_geom,
        },
    },
    renderer::tonemap::tonemap_to_rgba,
};
use slint::{ComponentHandle, Rgba8Pixel, SharedPixelBuffer};
use std::{
    sync::{
        Arc, Mutex,
        atomic::{AtomicU64, Ordering},
    },
    time::Duration,
};

/// Preview thumbnail's edge length, in pixels. Small on purpose -- this is a "which
/// pose am I looking at" glance, not a viewport: `bridge::export_thread::preview`'s
/// `PREVIEW_MAX_LONG_EDGE` (360px) is sized for reading a full composition, this only
/// to read silhouette/brilliance-pattern at a glance.
const PREVIEW_SIZE: u32 = 96;

/// Samples per pixel for the hover preview. Deliberately tiny -- this trades visible
/// noise for staying well under the per-render cost that would make even a
/// settled-cursor preview feel laggy; the preview only needs to communicate
/// silhouette, facet pattern and rough color/brightness, not serve as a converged
/// reference image.
const PREVIEW_SPP: u32 = 2;

/// Quiet period a hover/scrub request must go unsuperseded for before this module
/// actually renders it. Short enough that a cursor that pauses briefly still gets a
/// prompt preview, long enough that a fast drag across many of the 181 hoverable
/// points never starts more than a small, bounded number of renders.
const HOVER_DEBOUNCE: Duration = Duration::from_millis(90);

/// Same tilt-to-pose formula [`evaluate_full_axis_profile_at_azimuth`] uses to build
/// the curve: given the hovered/swept axis and tilt angle (tilt away from table-up,
/// not camera elevation), returns `(cam_yaw_rad, cam_pitch_rad)` for [`Camera::new`]:
/// `cam_pitch = 90° - |tilt_deg|` (`90°` = table-up at `tilt_deg == 0`, `0°` = edge-on
/// at `|tilt_deg| == 90`), and the sign of `tilt_deg` selects which of the axis's two
/// opposite azimuths (`PROFILE_AZIMUTHS_DEG[axis_index]` or that plus 180°) it's on.
///
/// Takes `tilt_deg` as `f64` (rather than this module's own `f32`) so
/// `gui::tilt::video_export` can pose a frame at an EXACT fractional angle (e.g. a
/// 0.01°-step sweep) without the precision loss an `f32` round-trip would add on top
/// of an already-fine step -- see [`camera_pose_for_hover`], this module's own `f32`
/// call site, for the hover tooltip's coarser (mouse-pixel-snapped) needs.
///
/// [`evaluate_full_axis_profile_at_azimuth`]: indicatrix::color::metrics::evaluate_full_axis_profile_at_azimuth
pub(in crate::gui) fn camera_pose_for_axis_tilt(axis_index: usize, tilt_deg: f64) -> (f32, f32) {
    let base_azimuth_deg = f64::from(PROFILE_AZIMUTHS_DEG.get(axis_index).copied().unwrap_or(0.0));
    let azimuth_deg = if tilt_deg < 0.0 {
        base_azimuth_deg + 180.0
    } else {
        base_azimuth_deg
    };
    let pitch_deg = 90.0 - tilt_deg.abs();
    (
        azimuth_deg.to_radians() as f32,
        pitch_deg.to_radians() as f32,
    )
}

/// `f32` convenience wrapper around [`camera_pose_for_axis_tilt`] for this module's own
/// mouse-pixel-driven hover preview, which never needs more than `f32` precision.
fn camera_pose_for_hover(axis_index: usize, tilt_deg: f32) -> (f32, f32) {
    camera_pose_for_axis_tilt(axis_index, f64::from(tilt_deg))
}

/// Bundles every input [`render_hover_preview`] needs -- geometry, material, the
/// derived camera pose, and the scene's lighting/quality settings -- into one struct
/// rather than a ten-parameter function signature.
struct HoverPreviewScene<'a> {
    planes: &'a [GpuFacetPlane],
    /// The concave tools cut out of `planes`; empty for a planar stone.
    tools: &'a [ToolPrimitive],
    /// The material's fluorescent emitters (`RenderContext::active_fluorescence`); empty for
    /// every non-fluorescent material.
    fluorescence: &'a Fluorescence,
    material: &'a GemMaterial,
    cam_yaw: f32,
    cam_pitch: f32,
    distance: f32,
    lighting_preset: indicatrix::optics::raytracer::LightingPreset,
    exposure: f32,
    light_yaw: f32,
    light_pitch: f32,
    max_bounces: u32,
}

/// Traces the small preview thumbnail directly, following `bridge::export_thread`'s
/// "call the raytracer directly rather than reworking the progressive-render loop"
/// pattern at a fixed small size/sample count. Runs entirely on the calling
/// (background) thread -- callers must not call this from the UI thread.
///
/// Material resolution deliberately mirrors `gui::tilt_profile`'s background sweep
/// (`resolve_material_with_override`): the RI override/named custom material DOES
/// reach this preview, same as the curve it sits beside. What still does NOT reach
/// it -- deliberately, not a bug -- is any of the live viewport's DISPLAY overrides
/// (inclusion/subsurface scattering, c-axis orientation, edge rounding, physical
/// stone size, frosted girdle): `caller` below always traces with `&[]` facet
/// finishes (an all-polished stone) against the bare resolved material, so the
/// curve and this thumbnail describe the design's real optics under a fixed,
/// comparable presentation rather than whatever display knobs happen to be dialled
/// in on screen. `performance_graph_dialog.slint`'s caption states this on screen
/// rather than leaving it for a cutter to discover by comparing images.
fn render_hover_preview(scene: &HoverPreviewScene<'_>) -> SharedPixelBuffer<Rgba8Pixel> {
    let width = PREVIEW_SIZE;
    let height = PREVIEW_SIZE;
    // Same FOV convention as every other one-off `Camera::new` call site in this app.
    let camera = Camera::new(
        scene.cam_yaw,
        scene.cam_pitch,
        scene.distance,
        DEFAULT_FOV_DEG,
    );
    let environment = scene
        .lighting_preset
        .studio(scene.exposure, scene.light_yaw, scene.light_pitch)
        .with_backdrop(indicatrix::optics::raytracer::BACKDROP_GREY);

    let mut accum = vec![Vec3::ZERO; (width * height) as usize];
    for y in 0..height {
        for (x, pixel) in accum
            .iter_mut()
            .skip((y * width) as usize)
            .take(width as usize)
            .enumerate()
        {
            let global_pixel_idx = y * width + x as u32;
            let rot = pixel_rotations(global_pixel_idx);
            let mut sample_sum = Vec3::ZERO;
            for sample_num in 0..PREVIEW_SPP {
                let draws = sample_draws(global_pixel_idx, sample_num, &rot);
                let ray = camera.generate_ray(
                    x as f32,
                    y as f32,
                    width as f32,
                    height as f32,
                    draws.jitter_x,
                    draws.jitter_y,
                );
                // Preview always renders an all-polished stone (`&[]` facet finishes)
                // regardless of the live viewport's frosted-girdle toggle -- this
                // illustrates the curve's own pose/geometry, not every display option.
                let sample = trace_spectral_ray_geom(
                    ray,
                    StoneGeometry {
                        planes: scene.planes,
                        tools: scene.tools,
                    },
                    scene.material,
                    scene.fluorescence,
                    scene.max_bounces,
                    environment,
                    draws.seed,
                    draws.hero_rand,
                    None,
                );
                // Same dropped-but-counted non-finite rule as every render backend
                // (the divisor below stays `PREVIEW_SPP`).
                add_finite_sample(&mut sample_sum, sample);
            }
            *pixel = sample_sum;
        }
    }

    let rgba = tonemap_to_rgba(&accum, 1.0 / PREVIEW_SPP as f32);
    let mut buffer = SharedPixelBuffer::<Rgba8Pixel>::new(width, height);
    let dst = buffer.make_mut_slice();
    let src: &[Rgba8Pixel] = bytemuck::cast_slice(&rgba);
    dst.copy_from_slice(src);
    buffer
}

/// Wires `MainWindow::request_tilt_hover_preview(axis_index, tilt_deg)` (fired by
/// `performance_graph_dialog.slint`'s hover tooltip) to the debounced background
/// render above, pushing the finished thumbnail into `tilt_hover_preview_image` once
/// it lands. Split out of `run_gui`/`build_main_window` purely to keep those
/// functions under clippy's function-length lint.
pub(in crate::gui) fn setup_tilt_hover_preview_callback(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
) {
    // Bumped on every hover/scrub request; a sleeping worker that wakes up to find
    // it's no longer the latest generation renders nothing at all, so a scrub across
    // many points starts many cheap sleep-then-bail threads but at most one render.
    let generation = Arc::new(AtomicU64::new(0));

    let ui_weak = ui.as_weak();
    let render_ctx = render_ctx.clone();
    ui.global::<TiltModel>().on_request_tilt_hover_preview(
        move |axis_index: i32, tilt_deg: f32| {
            let Some(ui) = ui_weak.upgrade() else {
                return;
            };
            if axis_index < 0 {
                return;
            }

            let my_generation = generation.fetch_add(1, Ordering::SeqCst) + 1;
            let generation = generation.clone();
            let render_ctx = render_ctx.clone();
            let ui_weak_bg = ui.as_weak();
            let axis_index = axis_index as usize;
            // The preview belongs to the tilt curves, which describe the FINISHED gem
            // whatever the Cut slider says (`gui::editor::finished_stone`). The job is
            // made here, on the UI thread, because it reads the editor's state (a design
            // copy and the solve cache, cheap); finishing it may need a full solve, which
            // happens on the worker below, after the debounce, never on the UI thread.
            let finished_job = crate::gui::editor::finished_stone_job(&ui, &render_ctx);

            std::thread::spawn(move || {
                std::thread::sleep(HOVER_DEBOUNCE);
                if generation.load(Ordering::SeqCst) != my_generation {
                    // Superseded before the debounce window even closed -- the cursor kept
                    // moving, so there is no point rendering a pose nobody is looking at
                    // anymore.
                    return;
                }
                // At most one hover solves a cold design at a time (the others wait here and
                // then find its masts in the cache): see `FinishedStoneJob::resolve`.
                let finished = match finished_job.map(FinishedStoneJob::resolve) {
                    None => None,
                    Some(Ok(stone)) => Some(stone),
                    // The design does not solve, or the editor moved on while the stone was
                    // being prepared: there is no honest thumbnail of the finished stone, so
                    // the last one stays rather than a half-cut stone appearing beside curves
                    // that describe the finished one.
                    Some(Err(_)) => return,
                };
                if generation.load(Ordering::SeqCst) != my_generation {
                    // The cursor moved on while this request waited for the solve.
                    return;
                }

                let (
                    planes,
                    tools,
                    fluorescence,
                    material_name,
                    material_override,
                    custom_materials,
                    distance,
                    lighting_preset,
                    exposure,
                    light_yaw,
                    light_pitch,
                    max_bounces,
                    stone_width_mm,
                ) = {
                    let ctx = render_ctx
                        .lock()
                        .unwrap_or_else(std::sync::PoisonError::into_inner);
                    (
                        ctx.active_planes.clone(),
                        ctx.active_tools.clone(),
                        ctx.active_fluorescence(),
                        ctx.material_name.clone(),
                        // Includes the Live Render toolbar's view-only colour.
                        ctx.tinted_material_override(),
                        ctx.custom_materials.clone(),
                        ctx.distance,
                        ctx.lighting_preset,
                        ctx.exposure,
                        ctx.light_yaw,
                        ctx.light_pitch,
                        ctx.max_bounces,
                        ctx.stone_width_mm,
                    )
                };
                let (planes, tools) = finished.map_or((planes, tools), |stone| {
                    (Arc::new(stone.planes), Arc::new(stone.tools))
                });
                // Same override preference as the sweep itself -- see
                // `tilt_profile`'s own comment. Refuses (see `resolve_material`'s own
                // doc comment) rather than rendering the hover preview as Diamond or
                // as a previous design's material when the current one does not
                // resolve -- there is nothing honest to preview, so this drops the
                // request rather than substituting one.
                let Some(material) = resolve_material_with_override(
                    &GemMaterial::all_materials(),
                    &custom_materials,
                    material_override.as_ref(),
                    &material_name,
                ) else {
                    return;
                };
                // The render's size rule (absorption calibration and Stone Size), like the sweep.
                let material =
                    indicatrix::render_setup::material_for_stone(material, stone_width_mm, &planes);
                let (cam_yaw, cam_pitch) = camera_pose_for_hover(axis_index, tilt_deg);

                let buffer = render_hover_preview(&HoverPreviewScene {
                    planes: &planes,
                    tools: &tools,
                    fluorescence: fluorescence
                        .as_deref()
                        .unwrap_or_else(|| Fluorescence::none()),
                    material: &material,
                    cam_yaw,
                    cam_pitch,
                    distance,
                    lighting_preset,
                    exposure,
                    light_yaw,
                    light_pitch,
                    max_bounces,
                });

                let _ = ui_weak_bg.upgrade_in_event_loop(move |ui| {
                    if generation.load(Ordering::SeqCst) != my_generation {
                        // Superseded while the (comparatively slow) render itself was
                        // running -- drop it rather than flash an already-stale pose on
                        // screen right before the fresher one lands.
                        return;
                    }
                    ui.global::<TiltModel>()
                        .set_hover_preview_image(slint::Image::from_rgba8(buffer));
                });
            });
        },
    );
}

#[cfg(test)]
mod tests {
    use super::camera_pose_for_hover;
    use indicatrix::color::metrics::PROFILE_AZIMUTHS_DEG;

    /// Positive tilt stays on the axis's own (positive) azimuth, pitch equal to
    /// `90 - tilt` (tilt is measured away from table-up, not camera elevation).
    #[test]
    fn positive_tilt_uses_the_axis_own_azimuth() {
        let (yaw, pitch) = camera_pose_for_hover(1, 30.0);
        assert!((yaw - PROFILE_AZIMUTHS_DEG[1].to_radians()).abs() < 1e-6);
        assert!((pitch - (90.0 - 30.0f32).to_radians()).abs() < 1e-6);
    }

    /// Negative tilt flips to the opposite (+180 deg) azimuth, pitch equal to
    /// `90 - |tilt|`.
    #[test]
    fn negative_tilt_uses_the_opposite_azimuth_and_positive_pitch() {
        let (yaw, pitch) = camera_pose_for_hover(2, -25.0);
        let expected_yaw = (PROFILE_AZIMUTHS_DEG[2] + 180.0).to_radians();
        assert!((yaw - expected_yaw).abs() < 1e-6);
        assert!((pitch - (90.0 - 25.0f32).to_radians()).abs() < 1e-6);
    }

    /// The shared table-up point: `tilt_deg == 0` must resolve to pitch 90°
    /// (table-up), on the axis's own positive azimuth.
    #[test]
    fn zero_tilt_is_table_up_on_the_positive_azimuth() {
        let (yaw, pitch) = camera_pose_for_hover(0, 0.0);
        assert!((yaw - PROFILE_AZIMUTHS_DEG[0].to_radians()).abs() < 1e-6);
        assert!((pitch - 90.0f32.to_radians()).abs() < 1e-6);
    }

    /// The two edge-on extremes: `tilt_deg == +-90` must resolve to pitch 0°
    /// (edge-on/profile), on the positive/negative azimuth respectively.
    #[test]
    fn extreme_tilt_is_edge_on() {
        let (positive_yaw, positive_pitch) = camera_pose_for_hover(3, 90.0);
        assert!((positive_yaw - PROFILE_AZIMUTHS_DEG[3].to_radians()).abs() < 1e-6);
        assert!(positive_pitch.abs() < 1e-6);

        let (negative_yaw, negative_pitch) = camera_pose_for_hover(3, -90.0);
        assert!((negative_yaw - (PROFILE_AZIMUTHS_DEG[3] + 180.0).to_radians()).abs() < 1e-6);
        assert!(negative_pitch.abs() < 1e-6);
    }

    /// An out-of-range axis index falls back to azimuth 0 rather than panicking --
    /// `PROFILE_AZIMUTHS_DEG.get(axis_index)` is `None` past index 3.
    #[test]
    fn out_of_range_axis_index_falls_back_to_azimuth_zero() {
        let (yaw, _pitch) = camera_pose_for_hover(99, 10.0);
        assert!((yaw - 0.0f32.to_radians()).abs() < 1e-6);
    }
}