Skip to main content

rlx_core/render/scenes/
shape_field.rs

1//! **The mark roster, drawn at frame scale as a distance field**
2//! (ADR-0105, Plan 0091).
3//!
4//! Every other scene here hands the palette a *level* — a noise field, a
5//! chemical concentration, a particle's depth. This one hands it a **distance**,
6//! and that single substitution is the whole scene:
7//!
8//! ```text
9//! palette coordinate = mark_distance(p) * color_span + color_center
10//! ```
11//!
12//! (that is `coord_mode`'s default; the second coordinate is below.)
13//!
14//! Because a band of the palette coordinate is now a band of constant distance,
15//! turning `palette_steps` up produces **concentric offset contours of the
16//! chosen shape** — not concentric circles, and not an outline sampled to
17//! straight segments. It is per pixel, resolution-independent, and there is no
18//! geometry to facet (which is the entire argument of ADR-0105 against routing
19//! this through the line renderer). `palette_contour` then draws thin outlines
20//! at those band boundaries, and this is the third scene that param does
21//! anything in.
22//!
23//! # Two coordinates now, and the second is the one the references asked for
24//! (ADR-0111, Plan 0098)
25//!
26//! An offset family is an **erosion**, and erosion rounds a reflex corner while
27//! keeping convex ones sharp — so a nested heart keeps its bottom point and
28//! loses its top notch as the contours move inward, and no amount of tuning
29//! reaches the construction two batches of user reference images have asked for.
30//! `coord_mode = "1"` hands the palette
31//!
32//! ```text
33//! s = length(p) / r_boundary(theta)
34//! ```
35//!
36//! instead — `0` at the centre and exactly `1` on the outline, the same contract,
37//! but its level sets are **scaled copies** of the outline. The ring count is
38//! then `palette_steps` alone and the innermost figure is a scaled copy at any
39//! count, so notch sharpness stops trading against ring count.
40//!
41//! **The distance stays the default and stays bit-identical**, which is what
42//! keeps every shipped preset and every golden baseline on the arithmetic it has
43//! today. The two are not interchangeable settings of one knob: `color_span`
44//! means a different thing under each, because the exterior is divided by the
45//! shape's inradius under one and grows linearly in `r` under the other.
46//!
47//! # It shares the shape vocabulary rather than restating it
48//!
49//! The silhouettes come from `marks` — the same WGSL chunk
50//! `swarm` and `emitter` splice in, and the same CPU-side quantizers for the
51//! `shape` selector and the `points` count. So a mark a particle can wear and a
52//! figure this scene can be cannot drift apart, and the roster stays closed at
53//! five names (ADR-0084's consequence, restated in ADR-0105).
54//!
55//! # A silhouette can also be authored (ADR-0107)
56//!
57//! A `[path]` table hands this scene a closed contour parsed from inline SVG
58//! path data, and it takes the place of the roster's arm in exactly one spot:
59//! where the shader asks for the figure's coordinate. Everything downstream —
60//! both `coord_mode`s, `gamma`, the banding, the contours, the palette — is the
61//! same code reading the same `d`, so an authored figure gets the whole colour
62//! surface for free. The roster is untouched and still closed at five names; a
63//! preset naming no path executes not one instruction of the contour walk.
64//!
65//! Two things fall out of the distance being a **`min` over segments**. It is
66//! `O(N)` per pixel and the field is fullscreen, so the arity is a per-frame
67//! cost paid whether or not the figure is on screen — `MAX_SAMPLES` is where
68//! `core/tests/path_cost.rs` measured that becoming half the floor tier's frame
69//! budget, and asking for more is a load error. And **fill and stroke stop
70//! being two routes**: the interior is `d < 1` and the outline is
71//! `abs(d - 1) < w` on the one evaluation, which is what the `stroke` param is.
72//!
73//! What *is* new is that this scene reads the field **outside** the silhouette,
74//! where the particle path never looked. Plan 0091 Phase 2 measured that region
75//! and repaired the two arms that were wrong out there; see `marks.rs`'s own
76//! header for what it found and what it deliberately left approximate.
77//!
78//! # The aspect comes from the render target (ADR-0037)
79//!
80//! There is no internal grid here to take an aspect from by accident, which
81//! removes the usual mechanism for that bug but not the obligation. The figure
82//! is drawn in a square-unit space built by stretching NDC x by the **render
83//! target's** aspect, so a disc is round at every window shape. `tests` renders
84//! at 2:1 and 1:2 and measures the figure's own width against its height,
85//! because both 1920x1080 and this box's 2048x1152 quantize to exactly 16:9 —
86//! where no test can tell a right aspect source from a wrong one.
87
88// Hot-path panic-denial pragma, as everywhere under `scenes/`.
89#![deny(
90    clippy::unwrap_used,
91    clippy::expect_used,
92    clippy::indexing_slicing,
93    clippy::panic,
94    clippy::unreachable
95)]
96
97use crate::render::gpu;
98
99use super::Scene;
100use super::common;
101use super::marks;
102use crate::dsp::AnalysisFrame;
103use crate::preset::path::{MAX_ARC_PIECES, MAX_SAMPLES};
104use crate::render::palette::{self, Palette};
105use crate::render::scenes::{ParamKind, ParamSpec, default_of};
106
107/// How many `vec4` one arc piece occupies: its circle, its sector test, its two
108/// endpoints, and its signed sweep. See the WGSL's `arc_chain_sd` for what each
109/// field is for.
110const VEC4S_PER_PIECE: usize = 4;
111
112/// How many `vec4` the uniform's geometry array holds — enough for
113/// [`MAX_ARC_PIECES`] arc pieces, which is more than the polyline's two points
114/// per element ever needs.
115///
116/// The geometry rides the **uniform** buffer rather than a storage one, and that
117/// is an ADR-0058 choice rather than a performance one: a fragment-visible
118/// read-only storage entry after this layout's uniform would make its shape
119/// byte-identical to `shape-collage-bind-layout`, which is live in the same
120/// frame during a preset dissolve. Two layouts of one shape alias on the DX12
121/// WARP adapter the whole golden suite captures on, so the collision would be
122/// blessed rather than caught. Packing into the uniform leaves the layout's
123/// four entries exactly as they were.
124const PATH_VEC4S: usize = VEC4S_PER_PIECE * MAX_ARC_PIECES;
125
126/// The WGSL below spells the array length as a literal — `format!` cannot reach
127/// into a raw string full of braces — so the two are held together here. Raise
128/// either bound and this fails the build rather than letting the shader read
129/// past what the uniform carries.
130const _: () = assert!(
131    PATH_VEC4S == 128 && PATH_VEC4S >= MAX_SAMPLES / 2,
132    "the WGSL `path` array must be PATH_VEC4S long, and hold either geometry"
133);
134
135/// `scale` default — the figure's outline sits at 0.6 of the frame's short
136/// half-axis, which leaves room for several contour bands around it before they
137/// leave the frame. The whole point of this scene is what happens *outside* the
138/// silhouette, so a figure filling the frame would be the wrong default.
139const DEFAULT_SCALE: f32 = default_of(PARAMS, "scale");
140/// Smallest `scale` the shader is handed. Not zero: at zero the figure has no
141/// size and every pixel is infinitely far outside it in units of nothing, so
142/// the coordinate degenerates rather than fading out.
143const MIN_SCALE: f32 = 0.01;
144/// Largest `scale`. Past this the figure is far outside the frame and the whole
145/// screen is one interior band — reachable, but it is the end of the useful
146/// range rather than an arbitrary cap.
147const MAX_SCALE: f32 = 20.0;
148
149/// `rotation` default — **0, and an exact arithmetic identity**: the shader
150/// tests for it and skips the rotation entirely, so every shipped preset and
151/// every golden baseline stays on the arithmetic it has today.
152///
153/// Radians, matching `lines/star.rs` and `lines/lsystem.rs` — the two other
154/// figure-drawing scenes that carry this name. Unclamped for the same reason
155/// they are: an angle wraps, so there is no end of the useful range to hold it
156/// inside; a non-finite binding falls back to the identity because `cos(NaN)`
157/// would take the whole frame with it.
158const DEFAULT_ROTATION: f32 = default_of(PARAMS, "rotation");
159
160/// `stroke` default — **0, the filled figure, and an exact arithmetic
161/// identity**: the shader tests for it and skips the stroke mask entirely.
162const DEFAULT_STROKE: f32 = default_of(PARAMS, "stroke");
163/// Largest `stroke`. One coordinate unit is the figure's whole interior — 0 at
164/// its deepest point, 1 on the outline — so a half-width of 1 is a band
165/// reaching from the centre to twice the outline, and past that the stroke has
166/// stopped being an outline of anything.
167const MAX_STROKE: f32 = 1.0;
168
169/// `morph` default — **0, the authored figure**, and an exact identity: at 0 the
170/// packed contour is `[path] d` verbatim, with no interpolation run at all. A
171/// preset declaring no `morph_to` has nothing to travel towards and this is
172/// inert whatever it is bound to, exactly as the attractor's `morph` is.
173const DEFAULT_MORPH: f32 = default_of(PARAMS, "morph");
174
175/// `gamma` default — **the identity**, and it is exactly `1.0` on the way to the
176/// uniform because the shader's identity branch tests for it (`pow(x, 1.0)` is
177/// not bit-exact, ADR-0092's care).
178const DEFAULT_GAMMA: f32 = default_of(PARAMS, "gamma");
179/// The range `gamma` is held in. Same shape and the same reasoning as
180/// `ink_gamma` and `bg_ramp_gamma`: positive on both sides, wide enough that the
181/// clamp is the end of the useful range rather than a limit an author meets.
182const MIN_GAMMA: f32 = 0.05;
183const MAX_GAMMA: f32 = 20.0;
184
185/// The `coord_mode` roster, in the order the numeric parameter selects them.
186///
187/// `0` hands the palette the normalized **distance** to the figure, whose level
188/// sets are offset curves; `1` hands it `r / r_boundary(theta)`, whose level
189/// sets are **scaled copies** of the outline
190/// (ADR-0111). Both are `0` at the figure's centre and exactly `1` on its outline; what
191/// differs is the shape of everything in between.
192pub(crate) const COORD_MODES: [&str; 2] = ["distance", "radius"];
193
194/// `coord_mode` default — **0, the distance**, and that is an obligation rather
195/// than a preference: it is the arithmetic every shipped preset and every golden
196/// baseline has today.
197const DEFAULT_COORD_MODE: f32 = default_of(PARAMS, "coord_mode");
198const MIN_COORD_MODE: f32 = 0.0;
199const MAX_COORD_MODE: f32 = COORD_MODES.len() as f32 - 1.0;
200
201/// Shared palette colour knobs (ADR-0021). `color_span = 0.6` puts the
202/// silhouette's interior (`d` in `0..1`) across the gradient's first 60 %, so
203/// the exterior contours have somewhere to go.
204const DEFAULT_COLOR_SPAN: f32 = default_of(PARAMS, "color_span");
205const DEFAULT_COLOR_CENTER: f32 = default_of(PARAMS, "color_center");
206
207/// How many of the palette's [`LUT_SIZE`] texels a resting `color_span` spends on
208/// the **figure's own interior**.
209///
210/// Both coordinates are `0` at the figure's centre and exactly `1` on its
211/// outline, so the interior is one unit of the coordinate whatever the shape and
212/// whatever `gamma` does to the spacing inside it — which makes this share
213/// exact. What it does *not* know is how much of the frame that interior covers:
214/// a figure spanning half the screen stretches those texels across hundreds of
215/// pixels, and one filling a corner does not. That is why the warning built on
216/// it says estimate.
217pub(crate) fn interior_texels(color_span: f32) -> f32 {
218    color_span.abs() * crate::render::palette::LUT_SIZE as f32
219}
220
221/// Below this many texels across the interior, the linear-filtered LUT is
222/// stretching too few distinct colours across the figure and the result reads as
223/// an upscaled gradient rather than as shading (design-backlog 0099).
224///
225/// **The property is the count, not this constant.** A figure's interior drawn
226/// through N texels carries at most N colours no matter how large it is on
227/// screen, so somewhere below a few dozen the sampler is interpolating more than
228/// it is reading. The Plan 0091 Phase 6 star probes bracket where that becomes
229/// visible — 8.6 texels read as *"dirty and upscaled"*, 32.3 did not — and this
230/// is the middle of that bracket rounded to a power of two. It is a warning
231/// threshold, so a value inside it is legal and renders exactly as asked;
232/// `palette_steps` is the remedy, because a quantized coordinate samples one
233/// texel per band and interpolates nothing.
234pub(crate) const MIN_INTERIOR_TEXELS: f32 = 16.0;
235
236const SHADER: &str = r#"
237struct Params {
238    // x: aspect (from the RENDER TARGET), y: shape index (quantized CPU-side),
239    // z: points (quantized CPU-side), w: scale
240    a: vec4<f32>,
241    // xy: pan (the shared ViewTransform, ADR-0018), z: color_span,
242    // w: color_center
243    b: vec4<f32>,
244    // x: saturation, y: palette_mix, z: palette_steps (integral, quantized
245    // CPU-side), w: palette_contour
246    c: vec4<f32>,
247    // x: occlude (ADR-0085), y: gamma (the response exponent on the distance,
248    // exactly 1.0 for the identity), z: coord_mode (quantized CPU-side; 0 = the
249    // distance, 1 = the scaled-copy radius), w: rotation in radians, exactly 0.0
250    // for the identity.
251    d: vec4<f32>,
252    // xyz: the star arm's shape params (valley, curve, jitter), conditioned
253    // CPU-side. Inert on every other silhouette.
254    e: vec4<f32>,
255    // x: path point count (0 = no authored contour, and every line of the path
256    // arms below is unreached), y: the contour's inradius — the divisor that
257    // makes the distance 0 at its deepest interior point, measured CPU-side,
258    // z: stroke half-width in coordinate units (exactly 0.0 = filled),
259    // w: arc piece count — nonzero means `path` holds an ARC CHAIN rather than
260    // a polyline, and `x` is then unread.
261    f: vec4<f32>,
262    // The authored contour (ADR-0107), in one of two packings.
263    //
264    // **As a polyline** (`f.w == 0`): TWO POINTS PER ELEMENT, point `i` at
265    // `path[i >> 1].xy` for even `i` and `.zw` for odd. Packed because a uniform
266    // array's elements are 16-byte aligned, so an `array<vec2<f32>, N>` would
267    // spend half the buffer on padding.
268    //
269    // **As an arc chain** (`f.w > 0`): FOUR ELEMENTS PER PIECE, piece `i` at
270    // `path[i * 4 ..]`:
271    //   +0  (kind, cx, cy, radius)    kind 0 = straight run, 1 = arc
272    //   +1  (mx, my, cos_half, 0)     the sector's mid direction and half-angle
273    //   +2  (ax, ay, bx, by)          the piece's two endpoints
274    //   +3  (start, sweep, 0, 0)      the signed sweep, for the crossing test
275    path: array<vec4<f32>, 128>,
276}
277
278// **One bind group, sampler first and uniform last — and that arrangement is
279// what buys this pipeline a layout shape nothing else holds** (ADR-0058: two
280// byte-identical layouts alias on the DX12 WARP adapter, and the whole golden
281// suite runs there, so a collision is blessed rather than caught).
282//
283// It is deliberately not `fragment_field`'s two-group split, because that split
284// has no free shape left for a tenth scene. A lone uniform group can vary only
285// by visibility and by whether it declares a `min_binding_size`, and all four
286// combinations are taken: `[Uniform:FRAGMENT]` by the fragment field, the RD
287// init and the test disc; `+size` by the backdrop; `VERTEX_FRAGMENT` by the
288// line renderer; and `VERTEX_FRAGMENT+size` by the emitter. Merging the groups
289// is what keeps this unique WITHOUT padding a layout with a binding the shader
290// does not use, which is the cure ADR-0058's Alternative A refuses.
291//
292// Pick another free shape rather than tidying this back into two groups.
293@group(0) @binding(0) var lut_samp: sampler;
294@group(0) @binding(1) var lut_a: texture_2d<f32>;
295@group(0) @binding(2) var lut_b: texture_2d<f32>;
296@group(0) @binding(3) var<uniform> params: Params;
297
298// Shared `saturation` (mirrors core/src/render/palette.rs::desaturate verbatim).
299fn apply_saturation(c: vec3<f32>, s: f32) -> vec3<f32> {
300    let luma = dot(c, vec3<f32>(0.299, 0.587, 0.114));
301    return vec3<f32>(luma) + (c - vec3<f32>(luma)) * s;
302}
303
304// Shared `palette_steps` (mirrors core/src/render/palette.rs::band_coord
305// verbatim, ADR-0078): snap the palette coordinate to a band centre before the
306// LUT read. Below 1.5 steps it is the exact identity, not a one-band degenerate.
307fn band_coord(t: f32, steps: f32) -> f32 {
308    if (steps < 1.5) {
309        return t;
310    }
311    return (floor(t * steps) + 0.5) / steps;
312}
313
314// Shared `palette_contour` (ADR-0078 / ADR-0133; the WGSL is the implementation,
315// copied verbatim at each fragment-stage site — palette.rs has no CPU
316// counterpart to be canonical, since `fwidth` exists only here).
317//
318// Darkens within one PIXEL of a band edge, so the line has the same weight where
319// the field is shallow and where it is steep — AND ONLY WHERE THE INK ACTUALLY
320// CHANGES (ADR-0133). It samples the two band centres either side of the nearest
321// edge and returns unchanged when they resolve to the same colour within half a
322// code value, which is below the LUT's own 8-bit quantization. On a smooth
323// palette two distinct centres always differ by at least one code value, so
324// every edge draws exactly as it did at any `palette_steps`; inside a plateau
325// the LUT is literally constant and the samples are bit-equal, so the line
326// vanishes there and survives at the run boundaries. One rule, both behaviours,
327// no new parameter.
328//
329// The two LUTs, the sampler and `palette_mix` are EXPLICIT parameters rather
330// than module-scope globals this happens to find: all four sites name them the
331// same today, so implicit capture would compile — and would silently bind the
332// shared function to whatever a future site called its textures.
333//
334// `textureSampleLevel`, not `textureSample`: the LUT has one mip, and an
335// explicit LOD keeps these reads free of the uniformity requirement that a
336// sample after a conditional return would otherwise carry.
337fn band_contour(
338    t: f32,
339    steps: f32,
340    amount: f32,
341    lut_a: texture_2d<f32>,
342    lut_b: texture_2d<f32>,
343    lut_samp: sampler,
344    mix_ab: f32,
345) -> f32 {
346    let f = t * steps;
347    let w = max(fwidth(f), 1e-5);
348    if (steps < 1.5 || amount <= 0.0) {
349        return 1.0;
350    }
351    let n = round(f);
352    let m = clamp(mix_ab, 0.0, 1.0);
353    let lo = mix(
354        textureSampleLevel(lut_a, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
355        textureSampleLevel(lut_b, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
356        m
357    );
358    let hi = mix(
359        textureSampleLevel(lut_a, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
360        textureSampleLevel(lut_b, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
361        m
362    );
363    if (all(abs(hi - lo) < vec3<f32>(0.5 / 255.0))) {
364        return 1.0;
365    }
366    let d = min(fract(f), 1.0 - fract(f));
367    return 1.0 - clamp(amount, 0.0, 1.0) * (1.0 - smoothstep(0.0, w, d));
368}
369
370// Point `i` of the authored contour, unpacked from the two-per-vec4 array.
371fn path_pt(i: u32) -> vec2<f32> {
372    let v = params.path[i >> 1u];
373    if ((i & 1u) == 0u) {
374        return v.xy;
375    }
376    return v.zw;
377}
378
379// **The authored contour's signed distance**: `min` over the distance to each
380// closing segment, signed by a crossing count (ADR-0107).
381//
382// The sign is a RAY-CROSSING PARITY rather than an orientation test, so it does
383// not care which way the author wound their path — which is what lets Phase 1
384// keep the contour's own winding and leave the alignment to the morph.
385//
386// `min` over segment distances has no quads, no overlap and no vertex bead: it
387// is exactly correct at every join, which is why ADR-0098's faceting objection
388// against a polyline stroke does not transfer to a polyline FILL. The cost is
389// `O(n)` per pixel and it is paid at every pixel of the frame whether or not the
390// figure is on screen, which is what the arity ceiling exists to bound.
391fn path_sd(p: vec2<f32>, n: u32) -> f32 {
392    var best = 1e20;
393    var s = 1.0;
394    // The previous point is carried rather than re-indexed, so each iteration
395    // makes one dynamically indexed uniform read instead of two. It measured as
396    // free — `path_cost.rs` reports the same ms/frame either way, so the loop's
397    // cost is its arithmetic and not its loads — and it stays because it is the
398    // simpler loop, not because it bought anything.
399    var b = path_pt(n - 1u);
400    for (var i = 0u; i < n; i = i + 1u) {
401        let a = path_pt(i);
402        let e = b - a;
403        let w = p - a;
404        // The nearest point ON THE SEGMENT, not on its infinite line: the clamp
405        // is what makes a sample beyond an end measure to the vertex.
406        let t = clamp(dot(w, e) / max(dot(e, e), 1e-20), 0.0, 1.0);
407        let q = w - e * t;
408        best = min(best, dot(q, q));
409        let c1 = p.y >= a.y;
410        let c2 = p.y < b.y;
411        let c3 = e.x * w.y > e.y * w.x;
412        if ((c1 && c2 && c3) || (!c1 && !c2 && !c3)) {
413            s = -s;
414        }
415        b = a;
416    }
417    return s * sqrt(best);
418}
419
420// **The authored contour's signed distance, as a chain of circular arcs**
421// (ADR-0098's primitive, ADR-0107's figure).
422//
423// The same two quantities as `path_sd` — a `min` over pieces for the magnitude,
424// a ray-crossing parity for the sign — over a chain that a curve needs FIVE TO
425// TEN TIMES fewer of than the polyline it was fitted from. A piece costs more
426// than a segment; whether that trade is a win is `path_cost.rs`'s reading, not
427// an assertion here.
428//
429// **No `atan2` on the distance path.** Whether the nearest point on the circle
430// lies within the piece's sweep is a sector test, and a sector test is a dot
431// product against the sweep's mid direction — both precomputed CPU-side. The
432// crossing test below does need the angle, but only for a piece the scan line
433// actually meets, which is a small minority of them.
434fn arc_chain_sd(p: vec2<f32>, n: u32) -> f32 {
435    let TAU = 6.28318530718;
436    var best = 1e20;
437    var crossings = 0u;
438    for (var i = 0u; i < n; i = i + 1u) {
439        let base = i * 4u;
440        let head = params.path[base];
441        let ends = params.path[base + 2u];
442        let a = ends.xy;
443        let b = ends.zw;
444
445        if (head.x < 0.5) {
446            // A straight run — the fitter emits these for a corner it must keep
447            // and for an arc whose radius is too large to shade stably, so this
448            // arm carries a real share of a polygonal figure.
449            let e = b - a;
450            let w = p - a;
451            let t = clamp(dot(w, e) / max(dot(e, e), 1e-20), 0.0, 1.0);
452            let q = w - e * t;
453            best = min(best, dot(q, q));
454            // The half-open rule on y, exactly as the polyline uses it: a joint
455            // lying on the scan line belongs to one piece, not to both.
456            let c1 = p.y >= a.y;
457            let c2 = p.y < b.y;
458            let c3 = e.x * w.y > e.y * w.x;
459            if ((c1 && c2 && c3) || (!c1 && !c2 && !c3)) {
460                crossings = crossings + 1u;
461            }
462            continue;
463        }
464
465        let c = head.yz;
466        let r = head.w;
467        let sector = params.path[base + 1u];
468        let sweep = params.path[base + 3u];
469        let w = p - c;
470        let l = length(w);
471        // Inside the sweep, the nearest point on the circle is the nearest point
472        // on the arc; outside it, the nearest point is whichever end is closer.
473        if (l > 1e-9 && dot(w / l, sector.xy) >= sector.z) {
474            let d = abs(l - r);
475            best = min(best, d * d);
476        } else {
477            best = min(best, min(dot(p - a, p - a), dot(p - b, p - b)));
478        }
479
480        // The crossing test: where the scan line `y = p.y` meets this circle, to
481        // the RIGHT of `p`, and inside the sweep.
482        let dy = p.y - c.y;
483        let disc = r * r - dy * dy;
484        if (disc > 0.0) {
485            let sx = sqrt(disc);
486            for (var k = 0u; k < 2u; k = k + 1u) {
487                let xr = c.x + select(-sx, sx, k == 1u);
488                if (xr <= p.x) {
489                    continue;
490                }
491                // Half-open on the sweep — `u < span`, not `<=` — so a joint on
492                // the scan line is counted by the piece that starts there and
493                // not also by the one that ends there.
494                let ang = atan2(dy, xr - c.x);
495                var u = (ang - sweep.x) * sign(sweep.y);
496                u = u - TAU * floor(u / TAU);
497                if (u < abs(sweep.y)) {
498                    crossings = crossings + 1u;
499                }
500            }
501        }
502    }
503    return select(1.0, -1.0, (crossings & 1u) == 1u) * sqrt(best);
504}
505
506// The contour's radius along the ray from the figure's centre through `p` — the
507// divisor of `coord_mode = 1`'s scaled-copy coordinate (ADR-0111), on an
508// authored contour instead of a rostered arm.
509//
510// The OUTERMOST crossing is taken. A single closed contour that is star-shaped
511// about its centre has exactly one, and the choice only shows on one that is
512// not (a crescent), where the outer edge is the boundary and the concavity is
513// interior to the coordinate.
514fn path_boundary_radius(p: vec2<f32>, n: u32) -> f32 {
515    let l = length(p);
516    if (l < 1e-6) {
517        return 1e-6;
518    }
519    let u = p / l;
520    var r = 0.0;
521    var b = path_pt(n - 1u);
522    for (var i = 0u; i < n; i = i + 1u) {
523        let a = path_pt(i);
524        let e = b - a;
525        // Cross both sides of `s*u = a + e*t` with `u` to drop `s`, then solve
526        // for the segment parameter `t`.
527        let denom = e.x * u.y - e.y * u.x;
528        if (abs(denom) > 1e-9) {
529            let t = (a.y * u.x - a.x * u.y) / denom;
530            if (t >= 0.0 && t <= 1.0) {
531                let s = dot(a + e * t, u);
532                r = max(r, s);
533            }
534        }
535        b = a;
536    }
537    return max(r, 1e-6);
538}
539
540@fragment
541fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
542    let aspect = params.a.x;
543    let shape = params.a.y;
544    let points = params.a.z;
545    let scale = params.a.w;
546    let pan = params.b.xy;
547    let color_span = params.b.z;
548    let color_center = params.b.w;
549    let saturation = params.c.x;
550    let palette_mix = params.c.y;
551    let palette_steps = params.c.z;
552    let palette_contour = params.c.w;
553    let gamma = params.d.y;
554    let coord_mode = params.d.z;
555    let rotation = params.d.w;
556    let star = params.e.xyz;
557    let path_n = u32(params.f.x);
558    let path_inradius = params.f.y;
559    let stroke = params.f.z;
560    let path_arcs = u32(params.f.w);
561
562    // Square units, from the RENDER TARGET's aspect (ADR-0037): stretching x
563    // makes one unit of `uv` the same length on both axes, so the figure below
564    // is the shape it claims to be and not the window's shape.
565    var uv = in.ndc;
566    uv.x = uv.x * aspect;
567
568    // The figure's own frame: `pan` moves its centre, `scale` sets its size.
569    //
570    // **`rotation` is applied AFTER the pan, and that is a choice.** Turning the
571    // sample point before subtracting `pan` would swing the figure around the
572    // frame's centre — an orbit — and turning it after swings it about its own.
573    // Both are defensible and they look completely different; this scene draws
574    // ONE figure, and a figure that spins in place is what `rotation` means on
575    // `lines/star.rs` and `lines/lsystem.rs` too.
576    //
577    // It is done in `uv`, which is already SQUARE units (ADR-0037): x has been
578    // stretched by the render target's aspect, so one unit is the same length on
579    // both axes and this is a rotation. In raw NDC the same two lines would
580    // SHEAR — invisible at 16:9, where the stretch is nearly 1, and obvious at
581    // 2:1. `tests` renders a square at 2:1 and turns it a quarter turn.
582    //
583    // A branch rather than an unconditional multiply, so 0 is an exact identity
584    // and no shipped preset moves through `cos`/`sin` (ADR-0092's care, the same
585    // reason `gamma` has one).
586    var q = uv - pan;
587    if (rotation != 0.0) {
588        let cr = cos(rotation);
589        let sr = sin(rotation);
590        // The INVERSE rotation on the sample point, so a positive `rotation`
591        // turns the figure counter-clockwise on screen rather than the frame.
592        q = vec2<f32>(cr * q.x + sr * q.y, cr * q.y - sr * q.x);
593    }
594    let p = q / scale;
595
596    // THE substitution this scene exists for: the palette coordinate is a
597    // FIGURE coordinate rather than a level. Both modes are 0 at the figure's
598    // centre and exactly 1 on its outline, and both grow outward — what differs
599    // is what a band of the coordinate is a band OF.
600    //
601    // An `if` rather than a `select`, and that is not style: `select` evaluates
602    // both arms, and the second arm here is a whole second shape evaluation. The
603    // mode is a per-draw uniform, so this branch is uniform across a warp and
604    // the hardware takes one arm rather than both.
605    //
606    // An authored contour takes the same two modes on the same terms
607    // (ADR-0107): what changes is where the silhouette came from, not what a
608    // band of the coordinate is a band of. `path_n` is 0 for every preset that
609    // declares no `[path]`, so those take the roster arms below and not one
610    // instruction of the contour walk executes.
611    var d: f32;
612    if (path_arcs >= 1u) {
613        // The arc chain, chosen CPU-side and only where it can serve: the
614        // distance coordinate, and no morph in flight. `path_inradius` is the
615        // POLYLINE's, which describes the same figure to within the fit's own
616        // lateral budget — a sub-pixel difference in a divisor.
617        d = max(1.0 + arc_chain_sd(p, path_arcs) / max(path_inradius, 1e-6), 0.0);
618    } else if (path_n >= 3u) {
619        if (coord_mode < 0.5) {
620            // `1 + sd / inradius` — the SAME normalization `mark_distance`
621            // applies to the roster, so an authored figure reads 0 at its
622            // deepest interior point and exactly 1 on its outline like every
623            // other silhouette this scene draws. Held at 0 from below because
624            // the inradius is measured on a grid and can land a hair short of
625            // the true deepest point; a negative coordinate would be a NaN
626            // under a bound `gamma` (`pow` of a negative base).
627            d = max(1.0 + path_sd(p, path_n) / max(path_inradius, 1e-6), 0.0);
628        } else {
629            d = length(p) / path_boundary_radius(p, path_n);
630        }
631    } else if (coord_mode < 0.5) {
632        // Mode 0 — a band of the coordinate is a band of constant DISTANCE,
633        // which is the definition of an offset curve (ADR-0105). This is the
634        // default and it is bit-for-bit the arithmetic that shipped.
635        d = mark_distance(p, shape, points, star);
636    } else {
637        // Mode 1 — a band of the coordinate is a band of constant SCALING, so
638        // its level sets are scaled copies of the outline (ADR-0111). On a
639        // polygon that keeps the corners the offsets round off; on a heart it
640        // keeps the notch, which is the construction the reference images are.
641        d = length(p) / max(mark_boundary_radius(p, shape, points, star), 1e-6);
642    }
643
644    // **The stroke's screen width, taken before any branch.** A derivative has
645    // to be evaluated in uniform control flow, and hoisting it is what keeps
646    // that true however the branch below is compiled — `band_contour` hoists
647    // its own for the same reason.
648    let d_width = max(fwidth(d), 1e-5);
649    // The response exponent, applied to the distance BEFORE it becomes a palette
650    // coordinate — so it reshapes where the contours sit rather than which
651    // colours they take. Above 1 the bands crowd toward the centre, which is what
652    // the reference images do and what a raw (evenly spaced) distance cannot.
653    // `select` rather than a branch, and the identity is exact: `pow(x, 1.0)` is
654    // not bit-exact, so an unbound preset must not go through it (ADR-0092).
655    let shaped = select(pow(d, gamma), d, gamma == 1.0);
656    let coord = shaped * color_span + color_center;
657
658    // Hard bands, then the contour drawn from the SAME coordinate (ADR-0078).
659    let banded = band_coord(coord, palette_steps);
660    let ca = textureSample(lut_a, lut_samp, vec2<f32>(banded, 0.5)).rgb;
661    let cb = textureSample(lut_b, lut_samp, vec2<f32>(banded, 0.5)).rgb;
662    var col = mix(ca, cb, clamp(palette_mix, 0.0, 1.0));
663    col = col * band_contour(
664        coord, palette_steps, palette_contour, lut_a, lut_b, lut_samp, palette_mix
665    );
666    col = apply_saturation(col, saturation);
667
668    // **Fill and stroke are one field, not two routes** (ADR-0107). `d` is the
669    // single evaluation above; the interior is `d < 1` and the outline is
670    // `abs(d - 1) < w`, so a stroke cannot drift off the fill it belongs to
671    // because there is nothing for it to drift from. (The ADR writes the pair
672    // as `d < 0` and `abs(d) < w` against a raw signed distance; this scene's
673    // coordinate is that distance normalized to 1 on the outline, so the two
674    // tests are the same two tests shifted by one.)
675    //
676    // Exactly 0 is the identity and takes the branch away, which is what keeps
677    // every shipped preset and every golden baseline on the arithmetic it has.
678    if (stroke > 0.0) {
679        col = col * (1.0 - smoothstep(stroke - d_width, stroke + d_width, abs(d - 1.0)));
680    }
681
682    // Alpha: this field covers every pixel, which is the coverage it honestly
683    // has (ADR-0056). `occlude` scales how much of that the backdrop underneath
684    // resolves against (ADR-0085). Reached only when no post stage is active;
685    // the chain owns the seam otherwise and the renderer hands a literal 1.0.
686    return vec4<f32>(col, params.d.x);
687}
688"#;
689
690#[repr(C)]
691#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
692struct Params {
693    a: [f32; 4],
694    b: [f32; 4],
695    c: [f32; 4],
696    d: [f32; 4],
697    e: [f32; 4],
698    f: [f32; 4],
699    /// The authored contour, two points per element — see the WGSL's `path_pt`.
700    /// Written every frame with the rest of the struct; it changes only on a
701    /// preset switch, and 1.5 KB of `write_buffer` is far below the cost of
702    /// splitting it into a second binding whose layout shape would then have to
703    /// be argued against ADR-0058.
704    path: [[f32; 4]; PATH_VEC4S],
705}
706
707/// A fullscreen signed-distance figure from the shared mark roster, coloured
708/// through the shared palette.
709pub struct ShapeFieldScene {
710    /// The pipeline, the uniform buffer, the 256x1 gradient LUT pair (A/B) the
711    /// fragment samples + crossfades for colour (ADR-0021), and the one bind
712    /// group this scene binds.
713    gpu: gpu::FullscreenScene,
714    /// The silhouette and its point count, raw as the preset bound them —
715    /// `marks::mark_shape` / `mark_points` quantize on the way to the uniform,
716    /// which is where a selector's precondition belongs (the `kaleido_edge`
717    /// precedent).
718    shape: f32,
719    points: f32,
720    /// The `star` arm's three shape params, raw as the preset bound them
721    /// (Plan 0091 Phase 5). `marks::star_*` condition them on the way to the
722    /// uniform. Inert on every other silhouette, and nothing warns —
723    /// `presets/README.md` carries that.
724    star_valley: f32,
725    star_curve: f32,
726    star_jitter: f32,
727    scale: f32,
728    /// The shared palette knobs (ADR-0021). This scene has no `hue` or
729    /// `brightness`.
730    colour: common::PaletteParams,
731    /// The shared view transform (ADR-0018).
732    pan: common::PanParams,
733    color_span: f32,
734    color_center: f32,
735    /// The response exponent on the distance, raw as the preset bound it;
736    /// [`applied_gamma`] conditions it on the way to the uniform.
737    gamma: f32,
738    /// Which coordinate the palette is handed, raw as the preset bound it;
739    /// [`applied_coord_mode`] quantizes it on the way to the uniform, which is
740    /// where a selector's precondition belongs.
741    coord_mode: f32,
742    /// The figure's own turn, in radians, raw as the preset bound it. Applied
743    /// about the figure's centre rather than the frame's — see the shader.
744    rotation: f32,
745    /// The stroke half-width in coordinate units, raw as the preset bound it;
746    /// [`applied_stroke`] conditions it on the way to the uniform. `0` — the
747    /// default — is the filled figure and an exact arithmetic identity.
748    stroke: f32,
749    /// The authored contour, and the one `morph` travels towards (ADR-0107).
750    /// Both are set by [`Scene::configure`] on a preset switch and by nothing
751    /// else — geometry is structural, not a param, so `reset_params` does not
752    /// touch them.
753    ///
754    /// Fewer than 3 points in `path_from` means no authored contour and the
755    /// scene draws the `marks` roster, which is what every preset declaring no
756    /// `[path]` gets. An empty `path_to` means no morph target, so `morph` is
757    /// inert and the packed contour is `path_from` verbatim.
758    path_from: Vec<[f32; 2]>,
759    path_to: Vec<[f32; 2]>,
760    /// The same authored outline as a **G1-continuous chain of circular arcs**,
761    /// fitted at load through the line renderer's own fitter (ADR-0098). Empty
762    /// where the fit was not worth keeping, and unread while a morph is in
763    /// flight or under the scaled-copy coordinate — see [`Self::pack_path`].
764    pieces: Vec<crate::render::scenes::lines::biarc::Piece>,
765    /// The contour packed for the uniform — the interpolation of the two above
766    /// at this frame's `morph`, rebuilt in `render`.
767    ///
768    /// A field rather than a local so the per-frame pack writes into an
769    /// allocation made once. This is the render thread, not the audio callback,
770    /// but the rule that a per-frame path does not allocate is the same one.
771    path: Box<[[f32; 4]; PATH_VEC4S]>,
772    /// The two contours' inradii — the distance from each one's deepest interior
773    /// point to its own outline, measured by [`contour_inradius`] at configure
774    /// time.
775    ///
776    /// It is the divisor that makes the authored figure's coordinate `0` at that
777    /// deepest point and `1` on the outline, which is the contract every
778    /// silhouette in this scene meets (`marks`' own header states it for the
779    /// roster).
780    ///
781    /// **Mid-morph the two are interpolated rather than re-measured.** The true
782    /// inradius of an interpolated contour is not the interpolation of the two
783    /// inradii, and measuring it is a grid search — load-time work, not
784    /// per-frame. The error is bounded and one-sided in the direction that
785    /// matters: the shader clamps the coordinate at 0 from below, so an
786    /// underestimate costs nothing and an overestimate leaves the innermost
787    /// sliver short of the palette's first texel.
788    path_inradius: f32,
789    path_inradius_to: f32,
790    /// How far along `path_from` -> `path_to` the figure is, raw as the preset
791    /// bound it. `0` — the default — is the authored figure, and an exact
792    /// identity: the interpolation is skipped entirely.
793    morph: f32,
794    /// How much of this field's (total) coverage the backdrop resolves against
795    /// (ADR-0085). Set by the renderer every frame — **not** a named param, so
796    /// it is not reset by `reset_params`.
797    occlude: f32,
798}
799
800impl ShapeFieldScene {
801    /// Build the scene's pipeline and uniform buffer on `device`.
802    pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
803        // The mark roster's chunk is prepended, exactly as the two particle
804        // scenes do it: it declares no bindings and no entry points, so
805        // splicing it in changes nothing about this pipeline's layout.
806        let source = format!("{}{SHADER}", marks::sdf_wgsl());
807        let shader = gpu::fullscreen_shader(
808            device,
809            "shape-field-shader",
810            gpu::FULLSCREEN_VS_NDC,
811            &source,
812        );
813        let parts = gpu::FullscreenParts::new(device, "shape-field", std::mem::size_of::<Params>());
814        // One group, sampler first and uniform last — see the WGSL's note for
815        // why this shape and not `fragment_field`'s two-group split. The uniform
816        // entry is a full literal rather than `gpu::uniform` because that helper
817        // passes `min_binding_size: None`, and declaring one is half of what
818        // makes this shape unique.
819        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
820            label: Some("shape-field-bind-layout"),
821            entries: &[
822                gpu::sampler(0),
823                gpu::texture(1, true),
824                gpu::texture(2, true),
825                wgpu::BindGroupLayoutEntry {
826                    binding: 3,
827                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
828                    ty: wgpu::BindingType::Buffer {
829                        ty: wgpu::BufferBindingType::Uniform,
830                        has_dynamic_offset: false,
831                        min_binding_size: wgpu::BufferSize::new(
832                            std::mem::size_of::<Params>() as u64
833                        ),
834                    },
835                    count: None,
836                },
837            ],
838        });
839        // This layout binds the sampler first and the two textures after it, so
840        // the pair's role-ordered array is destructured into binding order here.
841        let [lut_a, lut_b, lut_sampler] = parts.luts().bind_entries(1, 2, 0);
842        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
843            label: Some("shape-field-bind-group"),
844            layout: &bind_layout,
845            entries: &[
846                lut_sampler,
847                lut_a,
848                lut_b,
849                wgpu::BindGroupEntry {
850                    binding: 3,
851                    resource: parts.uniforms().as_entire_binding(),
852                },
853            ],
854        });
855
856        Self {
857            gpu: parts.finish(
858                device,
859                &shader,
860                &[&bind_layout],
861                bind_group,
862                None,
863                surface_format,
864                wgpu::BlendState::REPLACE,
865                "shape-field",
866            ),
867            shape: marks::DEFAULT_SHAPE,
868            points: marks::DEFAULT_POINTS,
869            star_valley: marks::DEFAULT_STAR_VALLEY,
870            star_curve: marks::DEFAULT_STAR_CURVE,
871            star_jitter: marks::DEFAULT_STAR_JITTER,
872            scale: DEFAULT_SCALE,
873            colour: common::PaletteParams::new(0.0, common::DEFAULT_BRIGHTNESS),
874            pan: common::PanParams::default(),
875            color_span: DEFAULT_COLOR_SPAN,
876            color_center: DEFAULT_COLOR_CENTER,
877            gamma: DEFAULT_GAMMA,
878            coord_mode: DEFAULT_COORD_MODE,
879            rotation: DEFAULT_ROTATION,
880            stroke: DEFAULT_STROKE,
881            morph: DEFAULT_MORPH,
882            path_from: Vec::new(),
883            path_to: Vec::new(),
884            pieces: Vec::new(),
885            path: Box::new([[0.0; 4]; PATH_VEC4S]),
886            path_inradius: 1.0,
887            path_inradius_to: 1.0,
888            occlude: crate::render::post::DEFAULT_OCCLUDE,
889        }
890    }
891}
892
893/// The `scale` the shader is handed: held inside the range the arithmetic needs,
894/// with a non-finite binding falling back to the default.
895///
896/// The clamp is CPU-side for the reason `background::applied_ramp_gamma` states:
897/// it can never be reached with a NaN, where WGSL's `clamp` is
898/// implementation-defined, and the default stays **exactly** the default on the
899/// way to the uniform.
900fn applied_scale(scale: f32) -> f32 {
901    if scale.is_finite() {
902        scale.clamp(MIN_SCALE, MAX_SCALE)
903    } else {
904        DEFAULT_SCALE
905    }
906}
907
908/// The `rotation` the shader is handed: passed through, with a non-finite
909/// binding falling back to the identity.
910///
911/// No clamp, because an angle wraps and there is no end of the range to hold it
912/// inside — the same treatment `lines/star.rs` gives the name. The finiteness
913/// guard is not decoration: `cos(NaN)` is `NaN`, and one `NaN` in the figure's
914/// own frame takes every pixel of the frame with it.
915fn applied_rotation(rotation: f32) -> f32 {
916    if rotation.is_finite() {
917        rotation
918    } else {
919        DEFAULT_ROTATION
920    }
921}
922
923/// The `coord_mode` the shader is handed: clamped into the roster, then
924/// **rounded to an integer**, with a non-finite binding falling back to the
925/// default — and **forced back to the distance on a `ring`**.
926///
927/// The quantizing half is `marks::mark_shape`'s treatment for
928/// `marks::mark_shape`'s reason, and the `kaleido_edge` precedent behind both. A
929/// mode's values are **identities** rather than a quantity: `[smoothing]` and
930/// preset dissolves interpolate a binding continuously from one setting to
931/// another, so easing the distance to the radius passes through 0.4 and 0.6, and
932/// there is nothing halfway between an offset curve and a scaled copy for the
933/// shader to draw there.
934///
935/// # The `ring` fallback, and why it is not silent
936///
937/// An annulus's centre is in its hole, so `r / r_boundary` has no single value
938/// there — the one behavioural choice ADR-0111 leaves open. Plan 0098
939/// Phase 4 rendered the three defensible answers before picking, and what
940/// settled it is that defining the boundary as the outer rim produces a figure
941/// **byte-identical to a `disc`**: the coordinate collapses to `length(p)` and
942/// the hole stops existing, so a preset naming one roster entry would be shown
943/// another. That is the negative ADR-0111 records, reached in practice.
944///
945/// So the combination is refused rather than approximated, and the refusal is
946/// **announced**: `Preset::from_toml_str` warns at load when a preset rests on
947/// it (ADR-0020's shape, the `thickness` dead-zone precedent). The silent
948/// fallback was the third candidate and it is the one this rejects — it renders
949/// the same pixels as this does and costs an author the afternoon.
950fn applied_coord_mode(mode: f32, shape: f32) -> f32 {
951    if shape == marks::RING_SHAPE {
952        return DEFAULT_COORD_MODE;
953    }
954    if mode.is_finite() {
955        mode.clamp(MIN_COORD_MODE, MAX_COORD_MODE).round()
956    } else {
957        DEFAULT_COORD_MODE
958    }
959}
960
961/// The exponent the shader will **actually apply** for a bound `gamma`: a
962/// non-finite binding falls back to the identity, and a finite one is held
963/// inside the positive range ([`MIN_GAMMA`], [`MAX_GAMMA`]).
964///
965/// CPU-side for `ink::applied_gamma`'s two reasons: `1.0` stays **exactly**
966/// `1.0` on the way to the uniform, which is what the shader's identity branch
967/// tests, and the clamp can never be reached with a NaN, where WGSL's `clamp` is
968/// implementation-defined.
969fn applied_gamma(gamma: f32) -> f32 {
970    if gamma.is_finite() {
971        gamma.clamp(MIN_GAMMA, MAX_GAMMA)
972    } else {
973        DEFAULT_GAMMA
974    }
975}
976
977/// The `stroke` the shader is handed: held inside its range, with a non-finite
978/// binding falling back to the filled figure.
979///
980/// **`0` survives as exactly `0`**, which the shader's identity branch tests
981/// for: a filled figure must execute none of the stroke arithmetic, so every
982/// shipped preset and every golden baseline stays on what it has (ADR-0092's
983/// care, the same reason `gamma` and `rotation` have identity branches).
984fn applied_stroke(stroke: f32) -> f32 {
985    if stroke.is_finite() {
986        stroke.clamp(0.0, MAX_STROKE)
987    } else {
988        DEFAULT_STROKE
989    }
990}
991
992/// The `morph` the contour is interpolated at: held inside `0..=1`, with a
993/// non-finite binding falling back to the authored figure.
994///
995/// Clamped rather than wrapped, and not extrapolated past either end: outside
996/// `0..=1` the interpolation leaves both authored silhouettes behind and the
997/// figure is one nobody drew — which is a different thing from the mid-morph
998/// shapes nobody drew, because those at least lie between two that someone did.
999fn applied_morph(morph: f32) -> f32 {
1000    if morph.is_finite() {
1001        morph.clamp(0.0, 1.0)
1002    } else {
1003        DEFAULT_MORPH
1004    }
1005}
1006
1007/// The contour's **inradius**: the distance from its deepest interior point to
1008/// its own outline, in the normalized `[-1, 1]` frame the contour lives in.
1009///
1010/// Measured rather than derived, because a closed contour has no closed form for
1011/// it. A coarse grid over the box finds the deepest cell, then three rounds of
1012/// local search shrink around it — so the reading is the grid's resolution only
1013/// until the refinement, and the refinement halves its neighbourhood each round.
1014///
1015/// **It can still land a hair short**, which is why the shader clamps the
1016/// coordinate at 0 from below rather than trusting this. Short is the safe
1017/// direction: it makes the innermost sliver of the figure read as the palette's
1018/// first texel, where over-reporting would leave the interior never reaching it.
1019fn contour_inradius(points: &[[f32; 2]]) -> f32 {
1020    /// Cells per axis of the first pass, over the `[-1, 1]` box.
1021    const GRID: i32 = 96;
1022    /// Local refinement rounds, each halving the search radius.
1023    const REFINE: u32 = 12;
1024
1025    let depth_at = |p: [f32; 2]| -> f32 {
1026        // Unsigned distance to the closing polygon, and a crossing parity for
1027        // whether `p` is inside it — the CPU counterpart of the WGSL's
1028        // `path_sd`, kept to the one quantity the shader needs from the CPU
1029        // rather than mirroring the whole field.
1030        let n = points.len();
1031        let mut best = f32::INFINITY;
1032        let mut inside = false;
1033        for i in 0..n {
1034            let (Some(&a), Some(&b)) = (points.get(i), points.get((i + n - 1) % n)) else {
1035                continue;
1036            };
1037            let e = [b[0] - a[0], b[1] - a[1]];
1038            let w = [p[0] - a[0], p[1] - a[1]];
1039            let ee = (e[0] * e[0] + e[1] * e[1]).max(1e-20);
1040            let t = ((w[0] * e[0] + w[1] * e[1]) / ee).clamp(0.0, 1.0);
1041            let q = [w[0] - e[0] * t, w[1] - e[1] * t];
1042            best = best.min(q[0] * q[0] + q[1] * q[1]);
1043            let c1 = p[1] >= a[1];
1044            let c2 = p[1] < b[1];
1045            let c3 = e[0] * w[1] > e[1] * w[0];
1046            if (c1 && c2 && c3) || (!c1 && !c2 && !c3) {
1047                inside = !inside;
1048            }
1049        }
1050        if inside { best.sqrt() } else { 0.0 }
1051    };
1052
1053    let mut best_p = [0.0f32, 0.0];
1054    let mut best_d = depth_at(best_p);
1055    for gy in 0..=GRID {
1056        for gx in 0..=GRID {
1057            let p = [
1058                (gx as f32 / GRID as f32) * 2.0 - 1.0,
1059                (gy as f32 / GRID as f32) * 2.0 - 1.0,
1060            ];
1061            let d = depth_at(p);
1062            if d > best_d {
1063                best_d = d;
1064                best_p = p;
1065            }
1066        }
1067    }
1068    let mut radius = 2.0 / GRID as f32;
1069    for _ in 0..REFINE {
1070        for (dx, dy) in [
1071            (-1.0f32, 0.0f32),
1072            (1.0, 0.0),
1073            (0.0, -1.0),
1074            (0.0, 1.0),
1075            (-1.0, -1.0),
1076            (1.0, -1.0),
1077            (-1.0, 1.0),
1078            (1.0, 1.0),
1079        ] {
1080            let p = [best_p[0] + dx * radius, best_p[1] + dy * radius];
1081            let d = depth_at(p);
1082            if d > best_d {
1083                best_d = d;
1084                best_p = p;
1085            }
1086        }
1087        radius *= 0.5;
1088    }
1089    // A contour with no interior the search could find would divide the whole
1090    // frame by zero; the floor keeps the coordinate finite and the figure reads
1091    // as all exterior, which is what a zero-area contour is.
1092    best_d.max(1e-4)
1093}
1094
1095/// The palette coordinate this scene hands the LUT, as a CPU mirror of the
1096/// shader's two lines — so the exponent's properties are testable without a GPU
1097/// (the arrangement `ink::key` and `tonemap::map` both use).
1098#[cfg(test)]
1099pub(crate) fn coord(distance: f32, gamma: f32, color_span: f32, color_center: f32) -> f32 {
1100    let g = applied_gamma(gamma);
1101    let shaped = if g == 1.0 { distance } else { distance.powf(g) };
1102    shaped * color_span + color_center
1103}
1104
1105/// The parameter names this scene consumes — the vocabulary a preset binding is
1106/// checked against at load (ADR-0020). **Keep in sync with `set_param` below**;
1107/// `declared_params_match_set_param` in `core/tests/preset.rs` fails if the two
1108/// drift.
1109pub const PARAMS: &[ParamSpec] = &[
1110    crate::render::scenes::marks::SHAPE,
1111    crate::render::scenes::marks::POINTS,
1112    crate::render::scenes::marks::STAR_VALLEY,
1113    crate::render::scenes::marks::STAR_CURVE,
1114    crate::render::scenes::marks::STAR_JITTER,
1115    ParamSpec {
1116        name: "scale",
1117        default: 0.6,
1118        range: Some([0.05, 2.0]),
1119        doc: "Size of the shape within the frame.",
1120        kind: ParamKind::Modal,
1121    },
1122    crate::render::scenes::common::PAN_X,
1123    crate::render::scenes::common::PAN_Y,
1124    ParamSpec {
1125        name: "color_span",
1126        default: 0.6,
1127        range: Some([0.0, 1.0]),
1128        doc: "How much of the palette the field's range covers.",
1129        kind: ParamKind::Modal,
1130    },
1131    ParamSpec {
1132        name: "color_center",
1133        default: 0.0,
1134        range: Some([-1.0, 1.0]),
1135        doc: "Shifts which part of that range lands in the middle of the palette.",
1136        kind: ParamKind::Modal,
1137    },
1138    crate::render::scenes::common::SATURATION,
1139    crate::render::scenes::common::PALETTE_MIX,
1140    crate::render::scenes::common::PALETTE_STEPS,
1141    crate::render::scenes::common::PALETTE_CONTOUR,
1142    ParamSpec {
1143        name: "gamma",
1144        default: 1.0,
1145        range: Some([0.25, 4.0]),
1146        doc: "Shapes the falloff from the shape's edge; below 1 it bites sooner.",
1147        kind: ParamKind::Modal,
1148    },
1149    ParamSpec {
1150        name: "coord_mode",
1151        default: 0.0,
1152        // The top of the range is the roster's last index, which is where
1153        // `applied_coord_mode` clamps. A range above it advertises a mode that
1154        // silently resolves to another one.
1155        range: Some([MIN_COORD_MODE, MAX_COORD_MODE]),
1156        doc: "Which coordinate frame the distance is measured in, which changes the shape's whole geometry.",
1157        kind: ParamKind::Structural,
1158    },
1159    ParamSpec {
1160        name: "rotation",
1161        default: 0.0,
1162        range: Some([0.0, 1.0]),
1163        doc: "Turns the shape, as a fraction of a full turn.",
1164        kind: ParamKind::Modal,
1165    },
1166    ParamSpec {
1167        name: "stroke",
1168        default: 0.0,
1169        range: Some([0.0, 1.0]),
1170        doc: "Draws the outline instead of the filled figure, at this half-width; 0 fills.",
1171        kind: ParamKind::Modal,
1172    },
1173    ParamSpec {
1174        name: "morph",
1175        default: 0.0,
1176        range: Some([0.0, 1.0]),
1177        doc: "Travels the authored path towards its morph_to silhouette; inert without one.",
1178        kind: ParamKind::Modal,
1179    },
1180];
1181
1182impl ShapeFieldScene {
1183    /// Pack this frame's contour into the uniform array and report `(point
1184    /// count, inradius)` for the uniform's scalars.
1185    ///
1186    /// **The morph is interpolated here, on the CPU, once per frame** — not per
1187    /// pixel in the shader. The alternative was to hand the GPU both contours
1188    /// and lerp inside the distance loop, which would double the uniform, double
1189    /// the per-pixel loads, and re-derive at 2 M pixels a value that changes once
1190    /// a frame. `morph` is a parameter, not geometry.
1191    ///
1192    /// At `morph = 0`, or with no target, the authored contour is copied
1193    /// verbatim and no interpolation runs — the identity every other param on
1194    /// this scene keeps.
1195    fn pack_path(&mut self, coord_mode: f32) -> (usize, usize, f32) {
1196        let n = self.path_from.len().min(MAX_SAMPLES);
1197        if n < 3 {
1198            return (0, 0, 1.0);
1199        }
1200        let morphing = self.path_to.len() == self.path_from.len();
1201
1202        // **The arc chain serves where it can, and the polyline everywhere
1203        // else.** Two things put a figure back on points, and both are the
1204        // chain's own limits rather than a preference:
1205        //
1206        // - **a morph in flight.** Phase-correspondent points interpolate; two
1207        //   arc chains have no such correspondence, and inventing one is the
1208        //   representation problem ADR-0075 exists about. So a morphing pair
1209        //   travels on the polyline it was aligned as.
1210        // - **the scaled-copy coordinate.** `coord_mode = 1` needs the boundary
1211        //   radius along a ray, which is a second intersection routine the chain
1212        //   does not carry.
1213        if !morphing && coord_mode < 0.5 && !self.pieces.is_empty() {
1214            let pieces = self.pieces.len().min(MAX_ARC_PIECES);
1215            self.pack_pieces(pieces);
1216            return (0, pieces, self.path_inradius);
1217        }
1218
1219        let t = if morphing {
1220            applied_morph(self.morph)
1221        } else {
1222            0.0
1223        };
1224        for i in 0..n {
1225            let Some(&a) = self.path_from.get(i) else {
1226                continue;
1227            };
1228            let p = match self.path_to.get(i) {
1229                Some(&b) if t != 0.0 => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
1230                _ => a,
1231            };
1232            let slot = i >> 1;
1233            let half = (i & 1) * 2;
1234            if let Some(v) = self.path.get_mut(slot) {
1235                if let Some(x) = v.get_mut(half) {
1236                    *x = p[0];
1237                }
1238                if let Some(y) = v.get_mut(half + 1) {
1239                    *y = p[1];
1240                }
1241            }
1242        }
1243        let inradius = self.path_inradius + (self.path_inradius_to - self.path_inradius) * t;
1244        (n, 0, inradius.max(1e-4))
1245    }
1246
1247    /// Pack the fitted arc chain into the uniform: four elements per piece, in
1248    /// the layout the WGSL's `Params.path` comment spells out.
1249    ///
1250    /// The sector test's mid direction and half-angle are computed **here**, so
1251    /// the fragment's own test is a dot product rather than an `atan2` per piece
1252    /// per pixel. Same for the endpoints, which the outside-the-sweep arm needs.
1253    fn pack_pieces(&mut self, count: usize) {
1254        use crate::render::scenes::lines::biarc::Piece;
1255        for i in 0..count {
1256            let Some(&piece) = self.pieces.get(i) else {
1257                continue;
1258            };
1259            let base = i * VEC4S_PER_PIECE;
1260            let (a, b) = (piece.start_point(), piece.end_point());
1261            let (head, sector, sweep) = match piece {
1262                Piece::Arc {
1263                    centre,
1264                    radius,
1265                    start,
1266                    sweep,
1267                } => {
1268                    let mid = start + sweep * 0.5;
1269                    (
1270                        [1.0, centre[0], centre[1], radius],
1271                        [mid.cos(), mid.sin(), (sweep.abs() * 0.5).cos(), 0.0],
1272                        [start, sweep, 0.0, 0.0],
1273                    )
1274                }
1275                Piece::Line { .. } => ([0.0; 4], [0.0; 4], [0.0; 4]),
1276            };
1277            for (offset, value) in [
1278                (0, head),
1279                (1, sector),
1280                (2, [a[0], a[1], b[0], b[1]]),
1281                (3, sweep),
1282            ] {
1283                if let Some(slot) = self.path.get_mut(base + offset) {
1284                    *slot = value;
1285                }
1286            }
1287        }
1288    }
1289}
1290
1291impl Scene for ShapeFieldScene {
1292    fn name(&self) -> &'static str {
1293        "shape field"
1294    }
1295
1296    fn set_occlude(&mut self, occlude: f32) {
1297        self.occlude = occlude;
1298    }
1299
1300    fn set_palette(&mut self, palette: &Palette) {
1301        self.gpu.set_palette(palette);
1302    }
1303
1304    fn reset_params(&mut self) {
1305        self.shape = marks::DEFAULT_SHAPE;
1306        self.points = marks::DEFAULT_POINTS;
1307        self.star_valley = marks::DEFAULT_STAR_VALLEY;
1308        self.star_curve = marks::DEFAULT_STAR_CURVE;
1309        self.star_jitter = marks::DEFAULT_STAR_JITTER;
1310        self.scale = DEFAULT_SCALE;
1311        self.colour.reset();
1312        self.pan.reset();
1313        self.color_span = DEFAULT_COLOR_SPAN;
1314        self.color_center = DEFAULT_COLOR_CENTER;
1315        self.gamma = DEFAULT_GAMMA;
1316        self.coord_mode = DEFAULT_COORD_MODE;
1317        self.rotation = DEFAULT_ROTATION;
1318        self.stroke = DEFAULT_STROKE;
1319        self.morph = DEFAULT_MORPH;
1320    }
1321
1322    /// The `[path]` table (ADR-0107), which is the only structural config this
1323    /// scene takes.
1324    ///
1325    /// **Called on every `shape_field` preset switch, table or no table** — the
1326    /// loader hands `Some(Path { shape: None })` for a preset that declares
1327    /// none, exactly so this runs and clears the contour. Without that, a switch
1328    /// from a path preset to a roster one would keep drawing the outgoing
1329    /// preset's silhouette.
1330    fn configure(
1331        &mut self,
1332        cfg: &super::lines::GeneratorConfig,
1333    ) -> Option<super::lines::CapOverflow> {
1334        if let super::lines::GeneratorConfig::Path { shape, morph_to } = cfg {
1335            self.path_from.clear();
1336            self.path_to.clear();
1337            self.pieces.clear();
1338            self.path_inradius = 1.0;
1339            self.path_inradius_to = 1.0;
1340            if let Some(contour) = shape {
1341                // The load boundary already refused an arity above the ceiling,
1342                // so the `take` is a belt on a boundary that holds rather than a
1343                // decimation an author is not told about.
1344                self.path_from
1345                    .extend(contour.points().iter().take(MAX_SAMPLES).copied());
1346                self.path_inradius = contour_inradius(&self.path_from);
1347                self.pieces.extend_from_slice(contour.pieces());
1348            }
1349            // The pair is aligned at load; a target of a different arity would
1350            // mean the loader let one through, so it is dropped rather than
1351            // interpolated against the wrong correspondent.
1352            if let Some(target) = morph_to
1353                .as_ref()
1354                .filter(|t| t.points().len() == self.path_from.len())
1355            {
1356                self.path_to.extend(target.points().iter().copied());
1357                self.path_inradius_to = contour_inradius(&self.path_to);
1358            }
1359        }
1360        None
1361    }
1362
1363    fn set_param(&mut self, name: &str, value: f32) {
1364        // The shared param blocks first, this scene's own names after
1365        // (`scenes::common`).
1366        if self.colour.set(name, value) || self.pan.set(name, value) {
1367            return;
1368        }
1369        match name {
1370            "shape" => self.shape = value,
1371            "points" => self.points = value,
1372            "star_valley" => self.star_valley = value,
1373            "star_curve" => self.star_curve = value,
1374            "star_jitter" => self.star_jitter = value,
1375            "scale" => self.scale = value,
1376            "color_span" => self.color_span = value,
1377            "color_center" => self.color_center = value,
1378            "gamma" => self.gamma = value,
1379            "coord_mode" => self.coord_mode = value,
1380            "rotation" => self.rotation = value,
1381            "stroke" => self.stroke = value,
1382            "morph" => self.morph = value,
1383            _ => {}
1384        }
1385    }
1386
1387    fn update(&mut self, _frame: &AnalysisFrame) {
1388        // Fully parameter-driven; the analysis reaches this scene only through
1389        // the preset expressions bound to its parameters.
1390    }
1391
1392    fn render(
1393        &mut self,
1394        queue: &wgpu::Queue,
1395        encoder: &mut wgpu::CommandEncoder,
1396        view: &wgpu::TextureView,
1397        aspect: f32,
1398    ) {
1399        // Quantized once, because `applied_coord_mode` has to see the same value
1400        // the shader will: the `ring` refusal is a fact about the SELECTED arm,
1401        // not about the raw binding.
1402        let shape = marks::mark_shape(self.shape);
1403        self.gpu.flush_palette(queue);
1404        let coord_mode = applied_coord_mode(self.coord_mode, shape);
1405        let (path_count, path_arcs, path_inradius) = self.pack_path(coord_mode);
1406
1407        let params = Params {
1408            // `aspect` is the argument the chain hands down for the target this
1409            // scene is drawing into — never a size this scene chose (ADR-0037).
1410            a: [
1411                aspect.max(0.1),
1412                shape,
1413                marks::mark_points(self.points),
1414                applied_scale(self.scale),
1415            ],
1416            b: [self.pan.x, self.pan.y, self.color_span, self.color_center],
1417            c: [
1418                self.colour.saturation,
1419                self.colour.mix,
1420                palette::band_steps(self.colour.steps),
1421                palette::band_contour(self.colour.contour),
1422            ],
1423            d: [
1424                self.occlude,
1425                applied_gamma(self.gamma),
1426                coord_mode,
1427                applied_rotation(self.rotation),
1428            ],
1429            e: [
1430                marks::star_valley(self.star_valley),
1431                marks::star_curve(self.star_curve),
1432                marks::star_jitter(self.star_jitter),
1433                0.0,
1434            ],
1435            f: [
1436                path_count as f32,
1437                path_inradius,
1438                applied_stroke(self.stroke),
1439                path_arcs as f32,
1440            ],
1441            path: *self.path,
1442        };
1443        self.gpu.write_uniform(queue, &params);
1444        self.gpu
1445            .draw(encoder, "shape-field-pass", view, wgpu::LoadOp::Load);
1446    }
1447}
1448
1449#[cfg(test)]
1450mod tests;