indicatrix-cut 0.7.1

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
//! The GUI's cache over the primary-ray-only guide-buffer prepass, so a remote-sourced
//! image (radiance only, no guide buffers over the wire) can still be denoised by the
//! same edge-avoiding À-Trous filter the local path uses.
//!
//! # Where the prepass lives
//!
//! The prepass itself ([`generate_guide_buffers`], [`generate_guide_buffers_cancellable`],
//! [`GuideBuffers`]) is `indicatrix::renderer::guide_pass` -- moved there unchanged so a
//! coordinator can compute the identical guides for its denoised `DISPLAY_FRAME`s -- and
//! re-exported here, so every GUI call site keeps this module's path. Only the cache
//! stays GUI-side: its key hashes the planes with `render_thread::hash_planes`, which the
//! GUI's other frame caches share.
//!
//! # Why caching, and on what key
//!
//! The guide buffers depend only on camera pose (`yaw`/`pitch`/`distance`), the
//! active facet geometry, and the material's `n_d` (the path signature refracts at it)
//! -- never on which backend produced the radiance, and never on light direction or
//! exposure. [`GuideCache`] recomputes only when
//! [`GuideCache::ensure_geom`]'s key (resolution + pose + a hash of the facet planes +
//! `n_d`) differs from the last call, not on every `FRAME` redraw of an in-progress
//! accumulation.
//!
//! # Why it runs off the UI thread
//!
//! One prepass costs ~82ms at 1920x1080 and ~288ms at 3840x2160 (see the `indicatrix`
//! module) -- large enough at 4K to freeze the UI thread if run synchronously from the
//! Slint event loop. `gui::remote::start_remote_render` therefore kicks the prepass off
//! on a background thread as soon as the `RenderRequest` is dispatched, via
//! [`generate_guide_buffers_cancellable`], overlapping it with the network round trip.
//! Cancellation is cooperative, via an `Arc<AtomicBool>` checked between rows: if the
//! pose changes again before a background generation finishes, `gui::remote` abandons
//! it and starts a fresh one. [`GuideCache::ensure`] stays synchronous -- it's what a
//! background result gets folded into ([`GuideCache::adopt`]) once `gui::remote`
//! confirms, via [`GuideCache::key_for`]/[`GuideCache::matches_key`], that it matches
//! the pose currently on screen. A frame arriving before its background generation
//! finishes is rendered with a plain tonemap instead; a later redraw denoises once
//! ready.

use indicatrix::{
    geometry::tool::StoneGeometry,
    optics::raytracer::{Camera, DEFAULT_FOV_DEG},
    render_setup::hash_geometry,
};
use std::sync::atomic::AtomicBool;

// The pure prepass moved to `indicatrix::renderer::guide_pass` (so a coordinator can
// compute the same guides for its denoised display frames); re-exported so every GUI
// call site keeps its `bridge::frame_cache::guide_pass::...` path.
pub use indicatrix::renderer::guide_pass::{
    GuideBuffers, generate_guide_buffers_cancellable_geom_with_index,
    generate_guide_buffers_into_geom_with_index,
};
// Only the tests build guides synchronously into a fresh allocation.
#[cfg(test)]
pub use indicatrix::renderer::guide_pass::generate_guide_buffers;

/// Everything that determines the guide buffers: resolution, camera pose, and the
/// active facet geometry (light/material/exposure are deliberately excluded -- see the
/// module doc comment). `planes_hash` is `indicatrix::render_setup::hash_geometry` of the
/// planes and tools, which for a planar stone is exactly the `hash_planes` every other
/// per-design cache keys on, rather than a second, parallel implementation.
///
/// `pub`, not private: `gui::remote`'s background guide-generation path tags its result
/// with this same key (via [`GuideCache::key_for`]). Fields stay private so a
/// `GuideKey` can only be constructed via `key_for`, never hand-assembled with a
/// mismatched or stale hash.
#[derive(Debug, Clone, PartialEq)]
pub struct GuideKey {
    width: u32,
    height: u32,
    yaw: f32,
    pitch: f32,
    distance: f32,
    planes_hash: u64,
    /// The active material's `n_d`: the path signature refracts at it (`0.0` = none).
    n_d: f32,
}

/// Caches one [`GuideBuffers`], regenerating it only when [`GuideKey`] changes. See the
/// module doc comment for why pose + geometry alone (not per-frame) is the right
/// invalidation granularity.
///
/// `buffers` is a plain (never-`Option`) field, deliberately: `key` starts `None`, so
/// the very first [`Self::ensure`] call always sees a "stale" key and populates
/// `buffers` before anything ever reads it -- there is no separate empty/uninitialized
/// state to unwrap out of, which is what lets `ensure` return a plain `&GuideBuffers`
/// with no panicking accessor.
#[derive(Debug)]
pub struct GuideCache {
    key: Option<GuideKey>,
    buffers: GuideBuffers,
    /// Incremented each time [`Self::ensure`] actually regenerates the buffers. Lets
    /// tests observe cache hits/misses without inspecting buffer contents; not read by
    /// production code.
    generation: u64,
}

impl Default for GuideCache {
    fn default() -> Self {
        Self::new()
    }
}

impl GuideCache {
    /// Creates an empty instance.
    #[must_use]
    pub fn new() -> Self {
        Self {
            key: None,
            buffers: GuideBuffers::miss(0, 0),
            generation: 0,
        }
    }

    /// How many times [`Self::ensure`] has actually regenerated the guide buffers.
    /// Test-only hook -- `#[cfg(test)]` rather than plain `pub` to avoid an
    /// `#[allow(dead_code)]` where nothing outside tests calls it.
    #[cfg(test)]
    #[must_use]
    pub const fn generation(&self) -> u64 {
        self.generation
    }

    /// Returns the guide buffers valid for `(width, height, yaw, pitch, distance,
    /// planes)`, regenerating the primary-ray prepass only if that key differs from the
    /// last call -- an unchanged pose/gem on a subsequent call (e.g. a later `FRAME`
    /// event from the same in-progress remote render) is a cache hit and costs nothing
    /// beyond the key comparison.
    ///
    /// Test-only: the live code always has a material and goes through
    /// [`Self::ensure_geom`]. Uses `n_d = 0.0`, i.e. no path signature.
    #[cfg(test)]
    pub fn ensure(
        &mut self,
        width: u32,
        height: u32,
        yaw: f32,
        pitch: f32,
        distance: f32,
        planes: &[indicatrix::geometry::plane::GpuFacetPlane],
    ) -> &GuideBuffers {
        self.ensure_geom(
            width,
            height,
            yaw,
            pitch,
            distance,
            StoneGeometry::planes_only(planes),
            0.0,
        )
    }

    /// [`Self::ensure`] for a stone with concave tools: the primary rays hit the
    /// notches too, so the denoiser's depth/normal/facet-id edges follow them.
    ///
    /// `n_d` is the active material's `DispersionModel::n_d()`; the path signature
    /// refracts through the stone at that index, so it is part of the key and a
    /// material change regenerates the buffers. `n_d <= 1.0` means no signature.
    // Pose, geometry and index are one flat key; a bundle struct would only rename them.
    #[allow(clippy::too_many_arguments)]
    pub fn ensure_geom(
        &mut self,
        width: u32,
        height: u32,
        yaw: f32,
        pitch: f32,
        distance: f32,
        geom: StoneGeometry<'_>,
        n_d: f32,
    ) -> &GuideBuffers {
        let key = Self::key_for_geom(width, height, yaw, pitch, distance, geom, n_d);
        if self.key.as_ref() != Some(&key) {
            let camera = Camera::new(yaw, pitch, distance, DEFAULT_FOV_DEG);
            let completed = generate_guide_buffers_into_geom_with_index(
                width,
                height,
                &camera,
                geom,
                n_d,
                &AtomicBool::new(false),
                &mut self.buffers,
            );
            debug_assert!(completed, "an uncancelled prepass always completes");
            self.key = Some(key);
            self.generation += 1;
        }
        &self.buffers
    }

    /// Computes the [`GuideKey`] for `(width, height, yaw, pitch, distance, planes)` --
    /// the same identity [`Self::ensure`] uses internally. Exposed so a caller that
    /// generates guide buffers outside this cache (`gui::remote`'s background prepass)
    /// can tag its result with the exact key [`Self::matches_key`]/[`Self::adopt`] will
    /// compare against.
    ///
    /// Test-only, like [`Self::ensure`]: `n_d = 0.0`.
    #[cfg(test)]
    #[must_use]
    pub fn key_for(
        width: u32,
        height: u32,
        yaw: f32,
        pitch: f32,
        distance: f32,
        planes: &[indicatrix::geometry::plane::GpuFacetPlane],
    ) -> GuideKey {
        Self::key_for_geom(
            width,
            height,
            yaw,
            pitch,
            distance,
            StoneGeometry::planes_only(planes),
            0.0,
        )
    }

    /// [`Self::key_for`] for a stone with concave tools, and the material's `n_d` (see
    /// [`Self::ensure_geom`]).
    #[must_use]
    pub fn key_for_geom(
        width: u32,
        height: u32,
        yaw: f32,
        pitch: f32,
        distance: f32,
        geom: StoneGeometry<'_>,
        n_d: f32,
    ) -> GuideKey {
        GuideKey {
            width,
            height,
            yaw,
            pitch,
            distance,
            planes_hash: hash_geometry(geom),
            n_d,
        }
    }

    /// True if `key` already matches this cache's current contents, i.e. a
    /// [`Self::ensure`] call with the same key would be a cache hit. Lets a caller
    /// confirm the cache is "ready" for a given pose/geometry without risking
    /// `ensure`'s synchronous regenerate path.
    #[must_use]
    pub fn matches_key(&self, key: &GuideKey) -> bool {
        self.key.as_ref() == Some(key)
    }

    /// Adopts externally-computed guide buffers (a background
    /// [`generate_guide_buffers_cancellable`] result) as this cache's contents for
    /// `key`, without running the prepass or touching [`Self::generation`] (that
    /// counter tracks only this cache's own prepass runs). The caller is responsible
    /// for `buffers` actually matching `key` -- `gui::remote`'s async path only calls
    /// this after confirming the background result's key matches the pose currently on
    /// screen.
    pub fn adopt(&mut self, key: GuideKey, buffers: GuideBuffers) {
        self.key = Some(key);
        self.buffers = buffers;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use indicatrix::geometry::cuts::StandardGemCuts;

    #[test]
    fn guide_cache_reuses_buffers_when_the_key_is_unchanged() {
        let planes = StandardGemCuts::standard_round_brilliant();
        let mut cache = GuideCache::new();

        cache.ensure(8, 8, 0.60, 0.45, 2.4, &planes);
        assert_eq!(cache.generation(), 1);

        // Same width/height/pose/geometry -- must be a cache hit (generation
        // unchanged), which is the whole "recompute on pose/gem change, not per
        // frame" contract this module exists for.
        cache.ensure(8, 8, 0.60, 0.45, 2.4, &planes);
        assert_eq!(
            cache.generation(),
            1,
            "an unchanged pose/geometry must reuse the cached guide buffers"
        );
        cache.ensure(8, 8, 0.60, 0.45, 2.4, &planes);
        assert_eq!(cache.generation(), 1);
    }

    #[test]
    fn guide_cache_regenerates_on_yaw_change() {
        let planes = StandardGemCuts::standard_round_brilliant();
        let mut cache = GuideCache::new();
        cache.ensure(8, 8, 0.60, 0.45, 2.4, &planes);
        cache.ensure(8, 8, 0.90, 0.45, 2.4, &planes);
        assert_eq!(
            cache.generation(),
            2,
            "a changed yaw must invalidate the cache"
        );
    }

    #[test]
    fn guide_cache_regenerates_on_pitch_or_distance_change() {
        let planes = StandardGemCuts::standard_round_brilliant();
        let mut cache = GuideCache::new();
        cache.ensure(8, 8, 0.60, 0.45, 2.4, &planes);
        cache.ensure(8, 8, 0.60, 0.80, 2.4, &planes);
        assert_eq!(
            cache.generation(),
            2,
            "a changed pitch must invalidate the cache"
        );

        cache.ensure(8, 8, 0.60, 0.80, 3.0, &planes);
        assert_eq!(
            cache.generation(),
            3,
            "a changed distance must invalidate the cache"
        );
    }

    #[test]
    fn guide_cache_regenerates_when_the_gem_geometry_changes() {
        let srb = StandardGemCuts::standard_round_brilliant();
        let emerald = StandardGemCuts::emerald_cut();
        let mut cache = GuideCache::new();
        cache.ensure(8, 8, 0.60, 0.45, 2.4, &srb);
        cache.ensure(8, 8, 0.60, 0.45, 2.4, &emerald);
        assert_eq!(
            cache.generation(),
            2,
            "a changed set of cutting instructions must invalidate the cache even with an \
             unchanged camera pose"
        );
    }

    /// The path signature refracts at the material's `n_d`, so a material change with an
    /// unchanged pose must regenerate the guides, and the same index must not.
    #[test]
    fn guide_cache_regenerates_when_the_material_index_changes() {
        let planes = StandardGemCuts::standard_round_brilliant();
        let geom = StoneGeometry::planes_only(&planes);
        let mut cache = GuideCache::new();
        cache.ensure_geom(8, 8, 0.60, 0.45, 2.4, geom, 2.417);
        cache.ensure_geom(8, 8, 0.60, 0.45, 2.4, geom, 2.417);
        assert_eq!(cache.generation(), 1);
        cache.ensure_geom(8, 8, 0.60, 0.45, 2.4, geom, 1.52);
        assert_eq!(
            cache.generation(),
            2,
            "a changed n_d must invalidate the cache"
        );
    }

    #[test]
    fn guide_cache_regenerates_on_resolution_change() {
        let planes = StandardGemCuts::standard_round_brilliant();
        let mut cache = GuideCache::new();
        cache.ensure(8, 8, 0.60, 0.45, 2.4, &planes);
        cache.ensure(16, 8, 0.60, 0.45, 2.4, &planes);
        assert_eq!(
            cache.generation(),
            2,
            "a changed output resolution must invalidate the cache"
        );
    }

    /// The cache is keyed on camera pose and geometry ONLY -- light direction is not
    /// part of the key, because a primary-ray-only prepass never samples lighting at
    /// all. This isn't something a caller could get wrong by passing a light angle in
    /// (there is no such parameter), but it's worth pinning as the documented design
    /// decision: reusing guides across a light-only change must never regenerate them.
    #[test]
    fn ensure_signature_has_no_light_parameters() {
        let planes = StandardGemCuts::standard_round_brilliant();
        let mut cache = GuideCache::new();
        cache.ensure(8, 8, 0.60, 0.45, 2.4, &planes);
        cache.ensure(8, 8, 0.60, 0.45, 2.4, &planes);
        assert_eq!(cache.generation(), 1);
    }

    #[test]
    fn guide_cache_key_for_matches_what_ensure_uses_internally() {
        let planes = StandardGemCuts::standard_round_brilliant();
        let mut cache = GuideCache::new();
        let key = GuideCache::key_for(8, 8, 0.60, 0.45, 2.4, &planes);

        assert!(
            !cache.matches_key(&key),
            "a freshly-constructed cache must not match any key yet"
        );
        cache.ensure(8, 8, 0.60, 0.45, 2.4, &planes);
        assert!(
            cache.matches_key(&key),
            "the key ensure() just populated must equal key_for()'s independently \
             computed key for the identical pose/geometry"
        );
    }

    #[test]
    fn guide_cache_adopt_installs_externally_computed_buffers_without_recomputing() {
        let planes = StandardGemCuts::standard_round_brilliant();
        let camera = Camera::new(0.60, 0.45, 2.4, DEFAULT_FOV_DEG);
        let buffers = generate_guide_buffers(8, 8, &camera, &planes);
        let key = GuideCache::key_for(8, 8, 0.60, 0.45, 2.4, &planes);

        let mut cache = GuideCache::new();
        cache.adopt(key.clone(), buffers.clone());

        assert_eq!(
            cache.generation(),
            0,
            "adopt() folds in an externally-computed result -- it must not be counted \
             as this cache having run its own prepass"
        );
        assert!(cache.matches_key(&key));

        // A subsequent ensure() for the identical key must be a pure cache hit: same
        // generation, same (adopted) buffers, no recompute triggered.
        let cached = cache.ensure(8, 8, 0.60, 0.45, 2.4, &planes);
        assert_eq!(cached.depth, buffers.depth);
        assert_eq!(cache.generation(), 0);
    }
}