GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/backend/tessellate.rs (144.7K)
1 //! The tessellator: a `DisplayList` (or the legacy primitive tuples) turned
2 //! into `Vertex` batches with their push constants, plus the vertex helpers
3 //! apps call directly. Platform-neutral — it produces the data the renderer
4 //! draws and knows nothing of the window system; moved out of
5 //! `window_runner` so another shell can share it.
6
7 use crate::widget::WidgetHostExt;
8 use crate::widget::WidgetHost;
9 use crate::draw::Batch2D;
10
11 /// A droplet spec resolved against a concrete rect: the push-constant fields
12 /// that define its SILHOUETTE, in logical px.
13 ///
14 /// Shared by [`crate::scene::paint::Prim::Droplet`] and
15 /// [`crate::scene::paint::Prim::DropletScrim`] so the lit drop and the vignette
16 /// drawn inside it can never disagree about the shape — the whole reason the
17 /// scrim rides the droplet's shader path instead of approximating the outline
18 /// with a rounded rect.
19 struct DropletGeom {
20 hx: f32,
21 hy: f32,
22 sag: f32,
23 br: f32,
24 bw: f32,
25 k: f32,
26 sr: f32,
27 ar: f32,
28 band: f32,
29 bow: f32,
30 /// How far the contact shadow reaches below/beside the box (0 when the
31 /// spec has no shadow). The lit drop's cover quad grows by this; a scrim
32 /// never draws outside the silhouette and ignores it.
33 sh_reach: f32,
34 }
35
36 fn droplet_geom(rect: &crate::scene::layout::Rect, spec: &crate::scene::paint::DropletSpec) -> DropletGeom {
37 let hx = rect.width * 0.5;
38 let hy = rect.height * 0.5;
39 let sag = spec.sag.clamp(0.0, 0.9) * rect.height;
40 // belly <= 0 disables the belly outright (the oval-dewdrop default) — the
41 // shader skips the smin when the radius is 0.
42 let (br, bw) = if spec.belly > 0.0 {
43 let br = (spec.belly.min(1.0) * rect.height).min(hy).min(hx);
44 (br, ((hx - br).max(0.0) * spec.belly_w.clamp(0.0, 1.0)).max(1.0))
45 } else {
46 (0.0, 0.0)
47 };
48 let k = (spec.blend.max(0.0) * rect.height).max(1.0);
49 let sheet_hy = hy - sag * 0.5;
50 // Bottom (sheet_r) and top (attach) corner radii: when the pair overfills
51 // the sheet height, scale both down proportionally — 0.5 + 0.5 is the
52 // fully continuous egg.
53 let mut sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(hx);
54 let mut ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(hx);
55 let sheet_h = (2.0 * sheet_hy).max(0.0);
56 if sr + ar > sheet_h && sr + ar > 0.0 {
57 let f = sheet_h / (sr + ar);
58 sr *= f;
59 ar *= f;
60 }
61 let band = (spec.band.max(0.05) * rect.height).max(1.0);
62 // Bottom-bow edge rise; the shader derives the arc radius from it per drop
63 // (R = hx^2/2*rise).
64 let bow = (spec.bow.clamp(0.0, 0.5) * rect.height).min(hy * 0.9);
65 let sh_reach = if spec.shadow > 0.0 { (0.18 * rect.height).max(2.0) } else { 0.0 };
66 DropletGeom { hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach }
67 }
68
69 /// The tessellated display list's batches, scissors and rounded clips scaled
70 /// to physical px.
71 pub(crate) fn dl_batches_2d(dl_batches: &[DlBatch], scale_f32: f32) -> Vec<Batch2D> {
72 dl_batches
73 .iter()
74 .map(|batch| Batch2D {
75 scissor: batch.scissor.map(|clip| {
76 (
77 (clip.x * scale_f32).max(0.0) as u32,
78 (clip.y * scale_f32).max(0.0) as u32,
79 (clip.width * scale_f32) as u32,
80 (clip.height * scale_f32) as u32,
81 )
82 }),
83 clip_rrect: batch
84 .clip_rrect
85 .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32, c[3] * scale_f32, c[4] * scale_f32]),
86 start: batch.start,
87 end: batch.end,
88 plate: batch.plate,
89 blur_behind: batch.blur_behind,
90 })
91 .collect()
92 }
93
94 #[repr(C)]
95 #[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
96 pub struct Vertex {
97 pub position: [f32; 2],
98 pub color: [f32; 4],
99 pub clip_circle: [f32; 3], // [cx, cy, r]
100 }
101
102 pub fn quad_vertices(x: f32, y: f32, w: f32, h: f32, sw: f32, sh: f32, c: [f32; 4]) -> [Vertex; 6] {
103 let x0 = (x / sw) * 2.0 - 1.0;
104 let y0 = 1.0 - (y / sh) * 2.0;
105 let x1 = ((x + w) / sw) * 2.0 - 1.0;
106 let y1 = 1.0 - ((y + h) / sh) * 2.0;
107 [
108 Vertex { position: [x0, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
109 Vertex { position: [x1, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
110 Vertex { position: [x0, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
111 Vertex { position: [x1, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
112 Vertex { position: [x1, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
113 Vertex { position: [x0, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
114 ]
115 }
116
117 pub fn quad_vertices_with_clip(
118 x: f32, y: f32, w: f32, h: f32,
119 sw: f32, sh: f32,
120 color: [f32; 4],
121 clip_circle: [f32; 3],
122 ) -> [Vertex; 6] {
123 let x0 = (x / sw) * 2.0 - 1.0;
124 let y0 = 1.0 - (y / sh) * 2.0;
125 let x1 = ((x + w) / sw) * 2.0 - 1.0;
126 let y1 = 1.0 - ((y + h) / sh) * 2.0;
127 [
128 Vertex { position: [x0, y0], color, clip_circle },
129 Vertex { position: [x1, y0], color, clip_circle },
130 Vertex { position: [x0, y1], color, clip_circle },
131 Vertex { position: [x1, y0], color, clip_circle },
132 Vertex { position: [x1, y1], color, clip_circle },
133 Vertex { position: [x0, y1], color, clip_circle },
134 ]
135 }
136
137 /// A quad whose four corners each carry their own color, Gouraud-interpolated across both
138 /// triangles by the shader (`@location(0) color` has no `flat` qualifier). Corner order is
139 /// TL, TR, BR, BL. Keep the alpha equal on all four: negative alpha is the blur sentinel,
140 /// so a gradient that crossed zero would tear the triangle in half.
141 pub fn quad_vertices_shaded(
142 x: f32, y: f32, w: f32, h: f32,
143 sw: f32, sh: f32,
144 c_tl: [f32; 4], c_tr: [f32; 4], c_br: [f32; 4], c_bl: [f32; 4],
145 clip_circle: [f32; 3],
146 ) -> [Vertex; 6] {
147 let x0 = (x / sw) * 2.0 - 1.0;
148 let y0 = 1.0 - (y / sh) * 2.0;
149 let x1 = ((x + w) / sw) * 2.0 - 1.0;
150 let y1 = 1.0 - ((y + h) / sh) * 2.0;
151 [
152 Vertex { position: [x0, y0], color: c_tl, clip_circle },
153 Vertex { position: [x1, y0], color: c_tr, clip_circle },
154 Vertex { position: [x0, y1], color: c_bl, clip_circle },
155 Vertex { position: [x1, y0], color: c_tr, clip_circle },
156 Vertex { position: [x1, y1], color: c_br, clip_circle },
157 Vertex { position: [x0, y1], color: c_bl, clip_circle },
158 ]
159 }
160
161 pub fn quad_vertices_clipped(
162 x: f32, y: f32, w: f32, h: f32,
163 surface_w: f32, surface_h: f32,
164 color: [f32; 4],
165 clip: (f32, f32, f32, f32),
166 clip_circle: [f32; 3],
167 ) -> Vec<Vertex> {
168 let (cx0, cy0, cx1, cy1) = clip;
169 let ix0 = x.max(cx0);
170 let iy0 = y.max(cy0);
171 let ix1 = (x + w).min(cx1);
172 let iy1 = (y + h).min(cy1);
173 if ix1 <= ix0 || iy1 <= iy0 {
174 return Vec::new();
175 }
176 quad_vertices_with_clip(ix0, iy0, ix1 - ix0, iy1 - iy0, surface_w, surface_h, color, clip_circle).to_vec()
177 }
178
179 pub fn line_vertices(
180 x1: f32, y1: f32, x2: f32, y2: f32,
181 thickness: f32,
182 sw: f32, sh: f32,
183 c: [f32; 4]
184 ) -> [Vertex; 6] {
185 let dx = x2 - x1;
186 let dy = y2 - y1;
187 let len = (dx * dx + dy * dy).sqrt();
188 if len < 0.001 {
189 return quad_vertices(x1 - thickness/2.0, y1 - thickness/2.0, thickness, thickness, sw, sh, c);
190 }
191 let ux = dx / len;
192 let uy = dy / len;
193 let nx = -uy;
194 let ny = ux;
195
196 let half_t = thickness * 0.5;
197 let p0x = x1 + nx * half_t;
198 let p0y = y1 + ny * half_t;
199 let p1x = x1 - nx * half_t;
200 let p1y = y1 - ny * half_t;
201 let p2x = x2 - nx * half_t;
202 let p2y = y2 - ny * half_t;
203 let p3x = x2 + nx * half_t;
204 let p3y = y2 + ny * half_t;
205
206 let ndc_p0x = (p0x / sw) * 2.0 - 1.0;
207 let ndc_p0y = 1.0 - (p0y / sh) * 2.0;
208 let ndc_p1x = (p1x / sw) * 2.0 - 1.0;
209 let ndc_p1y = 1.0 - (p1y / sh) * 2.0;
210 let ndc_p2x = (p2x / sw) * 2.0 - 1.0;
211 let ndc_p2y = 1.0 - (p2y / sh) * 2.0;
212 let ndc_p3x = (p3x / sw) * 2.0 - 1.0;
213 let ndc_p3y = 1.0 - (p3y / sh) * 2.0;
214
215 let clip_circle = [0.0, 0.0, 0.0];
216 [
217 Vertex { position: [ndc_p0x, ndc_p0y], color: c, clip_circle },
218 Vertex { position: [ndc_p1x, ndc_p1y], color: c, clip_circle },
219 Vertex { position: [ndc_p2x, ndc_p2y], color: c, clip_circle },
220 Vertex { position: [ndc_p0x, ndc_p0y], color: c, clip_circle },
221 Vertex { position: [ndc_p2x, ndc_p2y], color: c, clip_circle },
222 Vertex { position: [ndc_p3x, ndc_p3y], color: c, clip_circle },
223 ]
224 }
225
226 #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
227 pub enum LineCap {
228 Arrow,
229 Round,
230 Flat,
231 }
232
233 pub fn vector_vertices(
234 x1: f32, y1: f32, x2: f32, y2: f32,
235 thickness: f32,
236 sw: f32, sh: f32,
237 c: [f32; 4],
238 line_cap: LineCap,
239 ) -> Vec<Vertex> {
240 let mut verts = Vec::new();
241 let dx = x2 - x1;
242 let dy = y2 - y1;
243 let len = (dx * dx + dy * dy).sqrt();
244 if len < 0.001 {
245 return quad_vertices(x1 - thickness/2.0, y1 - thickness/2.0, thickness, thickness, sw, sh, c).to_vec();
246 }
247
248 match line_cap {
249 LineCap::Arrow => {
250 let ux = dx / len;
251 let uy = dy / len;
252 let nx = -uy;
253 let ny = ux;
254
255 let arrow_len = (thickness * 3.0).max(10.0).min(len);
256 let arrow_width = (thickness * 2.5).max(8.0);
257
258 let line_x2 = x2 - ux * arrow_len;
259 let line_y2 = y2 - uy * arrow_len;
260
261 if len > arrow_len {
262 verts.extend_from_slice(&line_vertices(x1, y1, line_x2, line_y2, thickness, sw, sh, c));
263 }
264
265 let bx = line_x2;
266 let by = line_y2;
267
268 let w1x = bx + nx * (arrow_width * 0.5);
269 let w1y = by + ny * (arrow_width * 0.5);
270 let w2x = bx - nx * (arrow_width * 0.5);
271 let w2y = by - ny * (arrow_width * 0.5);
272
273 let ndc_tip_x = (x2 / sw) * 2.0 - 1.0;
274 let ndc_tip_y = 1.0 - (y2 / sh) * 2.0;
275 let ndc_w1x = (w1x / sw) * 2.0 - 1.0;
276 let ndc_w1y = 1.0 - (w1y / sh) * 2.0;
277 let ndc_w2x = (w2x / sw) * 2.0 - 1.0;
278 let ndc_w2y = 1.0 - (w2y / sh) * 2.0;
279
280 let clip_circle = [0.0, 0.0, 0.0];
281 verts.push(Vertex { position: [ndc_tip_x, ndc_tip_y], color: c, clip_circle });
282 verts.push(Vertex { position: [ndc_w1x, ndc_w1y], color: c, clip_circle });
283 verts.push(Vertex { position: [ndc_w2x, ndc_w2y], color: c, clip_circle });
284 }
285 LineCap::Round => {
286 push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
287 let clip_circle = [0.0, 0.0, 0.0];
288 verts.extend(circle_vertices(x2, y2, thickness / 2.0, sw, sh, c, 16, clip_circle));
289 }
290 LineCap::Flat => {
291 push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
292 }
293 }
294
295 verts
296 }
297
298 /// `line_vertices` with a half-px alpha ramp along each long edge (the arc
299 /// tessellator's poor-man's AA) — diagonal strokes resolve smoothly instead of
300 /// stair-stepping. Axis-aligned strokes keep the crisp single-quad path:
301 /// feathering a pixel-snapped hairline would only blur it.
302 fn push_feathered_line_vertices(
303 x1: f32, y1: f32, x2: f32, y2: f32,
304 thickness: f32,
305 sw: f32, sh: f32,
306 c: [f32; 4],
307 out: &mut Vec<Vertex>,
308 ) {
309 let dx = x2 - x1;
310 let dy = y2 - y1;
311 let len = (dx * dx + dy * dy).sqrt();
312 if len < 0.001 || dx.abs() < 0.01 || dy.abs() < 0.01 {
313 out.extend_from_slice(&line_vertices(x1, y1, x2, y2, thickness, sw, sh, c));
314 return;
315 }
316 let (nx, ny) = (-dy / len, dx / len);
317 let f = 0.5f32.min(thickness * 0.25);
318 let half = thickness * 0.5;
319 // (offset at band start, offset at band end, alpha at start, alpha at end)
320 let bands = [
321 (-half - f, -half + f, 0.0, c[3]),
322 (-half + f, half - f, c[3], c[3]),
323 (half - f, half + f, c[3], 0.0),
324 ];
325 for &(oa, ob, aa, ab) in &bands {
326 let ca = [c[0], c[1], c[2], aa];
327 let cb = [c[0], c[1], c[2], ab];
328 let p = |x: f32, y: f32, o: f32| -> [f32; 2] {
329 [((x + nx * o) / sw) * 2.0 - 1.0, 1.0 - ((y + ny * o) / sh) * 2.0]
330 };
331 let clip_circle = [0.0, 0.0, 0.0];
332 let (a1, b1) = (p(x1, y1, oa), p(x1, y1, ob));
333 let (a2, b2) = (p(x2, y2, oa), p(x2, y2, ob));
334 out.push(Vertex { position: a1, color: ca, clip_circle });
335 out.push(Vertex { position: b1, color: cb, clip_circle });
336 out.push(Vertex { position: b2, color: cb, clip_circle });
337 out.push(Vertex { position: a1, color: ca, clip_circle });
338 out.push(Vertex { position: b2, color: cb, clip_circle });
339 out.push(Vertex { position: a2, color: ca, clip_circle });
340 }
341 }
342
343 pub fn rounded_rect_vertices_corners(
344 x: f32, y: f32, ww: f32, h: f32,
345 r: f32,
346 sw: f32, sh: f32,
347 color: [f32; 4],
348 clip_circle: [f32; 3],
349 corners: (bool, bool, bool, bool),
350 clip_rect: Option<(f32, f32, f32, f32)>,
351 ) -> Vec<Vertex> {
352 let mut verts = Vec::new();
353 let radii = crate::widget::CornerRadii::new(
354 if corners.0 { r } else { 0.0 },
355 if corners.1 { r } else { 0.0 },
356 if corners.2 { r } else { 0.0 },
357 if corners.3 { r } else { 0.0 },
358 );
359 push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, color, clip_circle, clip_rect, &mut verts);
360 verts
361 }
362
363 /// Sample of the unit superellipse |x|^n + |y|^n = 1 at circle parameter θ —
364 /// the (cos θ, sin θ) replacement the corner fans use. Exactly the circle at
365 /// n = 2; higher `corner_shape` exponents give the DE's continuous-curvature
366 /// corners, so widget silhouettes follow the same corner family as the
367 /// SDF-lit plates. `e` is 2/n, hoisted by callers. Tangent points at the
368 /// quadrant ends are pinned to the exact axis points (see below), so fans
369 /// tile exactly against the body rects and edge strips.
370 #[inline]
371 fn superellipse_pt(theta: f32, e: f32) -> (f32, f32) {
372 let (s, c) = theta.sin_cos();
373 // f32 sin/cos are not exactly 0 at a quadrant end (sin(PI) is -8.7e-8),
374 // and the fractional power magnifies that noise by orders of magnitude:
375 // at corner_shape 4.5 it is 7e-4, which on the desktop grid's 138 px
376 // cell corners put the fan's tangent vertex 0.1 px short of the body
377 // quad's edge. The fan's edge then tilts away from the quad's, and the
378 // row of pixel centres between them is covered by neither: a stray
379 // gap-coloured line 32 px long at every cell's left edge, and a lone
380 // gap pixel on the bottom row where the arc meets the body. Snap the
381 // ends to the exact axis points so fans tile against the rects.
382 let axis = |v: f32| -> f32 {
383 if v.abs() < 1e-6 {
384 0.0
385 } else if v.abs() > 1.0 - 1e-6 {
386 v.signum()
387 } else {
388 v.signum() * v.abs().powf(e)
389 }
390 };
391 (axis(c), axis(s))
392 }
393
394 /// Feathered glow ([`Prim::Glow`]): the rounded rect's interior fills at the
395 /// color's alpha and concentric outline rings fade it to zero across `reach`
396 /// px outside the boundary. Alpha rides the VERTICES, so the GPU interpolates
397 /// a per-pixel-smooth falloff between rings — stacked translucent layers band
398 /// visibly; this cannot. Ring alphas sit on a quadratic ease-out, giving the
399 /// vignette profile piecewise-linearly with kinks below visibility at glow
400 /// alphas. Corners sample [`superellipse_pt`], so a glow's silhouette sits in
401 /// the same corner family as the cells, nodes, and plates it highlights.
402 pub fn push_glow_vertices(
403 x: f32, y: f32, ww: f32, h: f32,
404 radius: f32, reach: f32,
405 sw: f32, sh: f32,
406 color: [f32; 4],
407 clip_circle: [f32; 3],
408 out: &mut Vec<Vertex>,
409 ) {
410 if ww <= 0.0 || h <= 0.0 || color[3].abs() <= 0.0005 || sw <= 0.0 || sh <= 0.0 {
411 return;
412 }
413 let r0 = radius.clamp(0.0, ww.min(h) * 0.5);
414 let ctl = (x + r0, y + r0);
415 let ctr = (x + ww - r0, y + r0);
416 let cbr = (x + ww - r0, y + h - r0);
417 let cbl = (x + r0, y + h - r0);
418 const K: usize = 10;
419 use std::f32::consts::PI;
420 let corner_e = 2.0 / crate::layout::corner_shape();
421 // One outline ring `off` px outside the boundary, clockwise from the
422 // top-left arc; every ring shares the layout, so strips never twist.
423 let ring = |off: f32| -> Vec<[f32; 2]> {
424 let r = (r0 + off).max(0.0);
425 let mut pts = Vec::with_capacity(4 * (K + 1));
426 let corners = [
427 (ctl, PI, 1.5 * PI),
428 (ctr, 1.5 * PI, 2.0 * PI),
429 (cbr, 0.0, 0.5 * PI),
430 (cbl, 0.5 * PI, PI),
431 ];
432 for ((cx, cy), a0, a1) in corners {
433 for k in 0..=K {
434 let a = a0 + (a1 - a0) * (k as f32 / K as f32);
435 let (ux, uy) = superellipse_pt(a, corner_e);
436 pts.push([cx + r * ux, cy + r * uy]);
437 }
438 }
439 pts
440 };
441 let to_v = |p: [f32; 2], a: f32| Vertex {
442 position: [(p[0] / sw) * 2.0 - 1.0, 1.0 - (p[1] / sh) * 2.0],
443 color: [color[0], color[1], color[2], a],
444 clip_circle,
445 };
446
447 let rings: Vec<(Vec<[f32; 2]>, f32)> = [0.0f32, 0.35, 0.7, 1.0]
448 .iter()
449 .map(|&t| (ring(reach * t), color[3] * (1.0 - t) * (1.0 - t)))
450 .collect();
451 let n = rings[0].0.len();
452
453 // Interior: a fan from the rect center over the innermost ring (a rounded
454 // rect is convex, so the fan covers it exactly), uniform core alpha.
455 let center = [x + ww * 0.5, y + h * 0.5];
456 for i in 0..n {
457 let p1 = rings[0].0[i];
458 let p2 = rings[0].0[(i + 1) % n];
459 out.push(to_v(center, color[3]));
460 out.push(to_v(p1, color[3]));
461 out.push(to_v(p2, color[3]));
462 }
463 // The feather: strips between consecutive rings, each vertex carrying its
464 // ring's alpha.
465 for w in rings.windows(2) {
466 let (inner, ia) = (&w[0].0, w[0].1);
467 let (outer, oa) = (&w[1].0, w[1].1);
468 for i in 0..n {
469 let a1 = inner[i];
470 let a2 = inner[(i + 1) % n];
471 let b1 = outer[i];
472 let b2 = outer[(i + 1) % n];
473 out.push(to_v(a1, ia));
474 out.push(to_v(b1, oa));
475 out.push(to_v(a2, ia));
476 out.push(to_v(a2, ia));
477 out.push(to_v(b1, oa));
478 out.push(to_v(b2, oa));
479 }
480 }
481 }
482
483 pub fn push_rounded_rect_vertices_corners(
484 x: f32, y: f32, ww: f32, h: f32,
485 radii: crate::widget::CornerRadii,
486 sw: f32, sh: f32,
487 color: [f32; 4],
488 clip_circle: [f32; 3],
489 clip_rect: Option<(f32, f32, f32, f32)>,
490 out: &mut Vec<Vertex>,
491 ) {
492 let corner_e = 2.0 / crate::layout::corner_shape();
493 let mut r_tl = radii.top_left.max(0.0);
494 let mut r_tr = radii.top_right.max(0.0);
495 let mut r_br = radii.bottom_right.max(0.0);
496 let mut r_bl = radii.bottom_left.max(0.0);
497
498 // Simple scale clamping
499 let sum_top = r_tl + r_tr;
500 if sum_top > ww {
501 let f = ww / sum_top;
502 r_tl *= f;
503 r_tr *= f;
504 }
505 let sum_bottom = r_bl + r_br;
506 if sum_bottom > ww {
507 let f = ww / sum_bottom;
508 r_bl *= f;
509 r_br *= f;
510 }
511 let sum_left = r_tl + r_bl;
512 if sum_left > h {
513 let f = h / sum_left;
514 r_tl *= f;
515 r_bl *= f;
516 }
517 let sum_right = r_tr + r_br;
518 if sum_right > h {
519 let f = h / sum_right;
520 r_tr *= f;
521 r_br *= f;
522 }
523
524 let clamp_x = |val: f32| -> f32 {
525 if let Some((cx0, _, cx1, _)) = clip_rect {
526 val.max(cx0).min(cx1)
527 } else {
528 val
529 }
530 };
531 let clamp_y = |val: f32| -> f32 {
532 if let Some((_, cy0, _, cy1)) = clip_rect {
533 val.max(cy0).min(cy1)
534 } else {
535 val
536 }
537 };
538
539 let push_quad = |verts: &mut Vec<Vertex>, qx: f32, qy: f32, qw: f32, qh: f32| {
540 let x0 = clamp_x(qx);
541 let y0 = clamp_y(qy);
542 let x1 = clamp_x(qx + qw);
543 let y1 = clamp_y(qy + qh);
544
545 if x1 <= x0 || y1 <= y0 {
546 return;
547 }
548
549 let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
550 let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
551 let ndc_x1 = (x1 / sw) * 2.0 - 1.0;
552 let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
553
554 verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
555 verts.push(Vertex { position: [ndc_x1, ndc_y0], color, clip_circle });
556 verts.push(Vertex { position: [ndc_x0, ndc_y1], color, clip_circle });
557 verts.push(Vertex { position: [ndc_x1, ndc_y0], color, clip_circle });
558 verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
559 verts.push(Vertex { position: [ndc_x0, ndc_y1], color, clip_circle });
560 };
561
562 let has_corners = r_tl > 0.1 || r_tr > 0.1 || r_br > 0.1 || r_bl > 0.1;
563 if !has_corners {
564 push_quad(out, x, y, ww, h);
565 return;
566 }
567
568 // Body rectangles
569 let mid_x0 = r_tl.max(r_bl);
570 let mid_x1 = ww - r_tr.max(r_br);
571 if mid_x1 > mid_x0 {
572 push_quad(out, x + mid_x0, y, mid_x1 - mid_x0, h);
573 }
574 if h > r_tl + r_bl {
575 push_quad(out, x, y + r_tl, mid_x0, h - r_tl - r_bl);
576 }
577 if h > r_tr + r_br {
578 push_quad(out, x + mid_x1, y + r_tr, ww - mid_x1, h - r_tr - r_br);
579 }
580
581 // Corner rendering. The fans are FEATHERED: the fan body stops half a
582 // pixel short of the silhouette and a strip fades from opaque at
583 // silhouette-0.5 to transparent at silhouette+0.5, so the arc
584 // anti-aliases instead of rasterizing a hard staircase — invisible on
585 // HiDPI widget buffers, glaring on the desktop grid's world-scale
586 // cells. Perceived size is unchanged (the 50%-coverage line stays on
587 // the exact silhouette). Radii too small to feather keep the hard fan.
588 let segments = 16;
589 let fade = [color[0], color[1], color[2], 0.0];
590 let to_ndc = |px: f32, py: f32| -> [f32; 2] {
591 [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0]
592 };
593 let push_corner = |out: &mut Vec<Vertex>, cx: f32, cy: f32, r: f32, start: f32, end: f32| {
594 let feather = r > 1.5;
595 let r_fan = if feather { r - 0.5 } else { r };
596 let r_out = r + 0.5;
597 for i in 0..segments {
598 let theta1 = start + (i as f32) * (end - start) / (segments as f32);
599 let theta2 = start + ((i + 1) as f32) * (end - start) / (segments as f32);
600
601 let (c1, s1) = superellipse_pt(theta1, corner_e);
602 let (c2, s2) = superellipse_pt(theta2, corner_e);
603 let p0 = to_ndc(clamp_x(cx), clamp_y(cy));
604 let p1 = to_ndc(clamp_x(cx + r_fan * c1), clamp_y(cy + r_fan * s1));
605 let p2 = to_ndc(clamp_x(cx + r_fan * c2), clamp_y(cy + r_fan * s2));
606
607 out.push(Vertex { position: p0, color, clip_circle });
608 out.push(Vertex { position: p1, color, clip_circle });
609 out.push(Vertex { position: p2, color, clip_circle });
610
611 if feather {
612 let q1 = to_ndc(clamp_x(cx + r_out * c1), clamp_y(cy + r_out * s1));
613 let q2 = to_ndc(clamp_x(cx + r_out * c2), clamp_y(cy + r_out * s2));
614 out.push(Vertex { position: p1, color, clip_circle });
615 out.push(Vertex { position: q1, color: fade, clip_circle });
616 out.push(Vertex { position: q2, color: fade, clip_circle });
617 out.push(Vertex { position: p1, color, clip_circle });
618 out.push(Vertex { position: q2, color: fade, clip_circle });
619 out.push(Vertex { position: p2, color, clip_circle });
620 }
621 }
622 };
623
624 // Top-Left
625 if r_tl > 0.1 {
626 push_corner(out, x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI);
627 if mid_x0 > r_tl {
628 push_quad(out, x + r_tl, y, mid_x0 - r_tl, r_tl);
629 }
630 }
631
632 // Top-Right
633 if r_tr > 0.1 {
634 push_corner(out, x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI);
635 if ww - mid_x1 > r_tr {
636 push_quad(out, x + mid_x1, y, ww - mid_x1 - r_tr, r_tr);
637 }
638 }
639
640 // Bottom-Right
641 if r_br > 0.1 {
642 push_corner(out, x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI);
643 if ww - mid_x1 > r_br {
644 push_quad(out, x + mid_x1, y + h - r_br, ww - mid_x1 - r_br, r_br);
645 }
646 }
647
648 // Bottom-Left
649 if r_bl > 0.1 {
650 push_corner(out, x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI);
651 if mid_x0 > r_bl {
652 push_quad(out, x + r_bl, y + h - r_bl, mid_x0 - r_bl, r_bl);
653 }
654 }
655 }
656
657 pub fn rounded_rect_vertices(
658 x: f32, y: f32, ww: f32, h: f32,
659 r: f32,
660 sw: f32, sh: f32,
661 color: [f32; 4],
662 clip_circle: [f32; 3],
663 ) -> Vec<Vertex> {
664 let mut verts = Vec::new();
665 push_rounded_rect_vertices_corners(x, y, ww, h, crate::widget::CornerRadii::uniform(r), sw, sh, color, clip_circle, None, &mut verts);
666 verts
667 }
668
669 pub fn push_rounded_rect_vertices(
670 x: f32, y: f32, ww: f32, h: f32,
671 r: f32,
672 sw: f32, sh: f32,
673 color: [f32; 4],
674 clip_circle: [f32; 3],
675 out: &mut Vec<Vertex>,
676 ) {
677 push_rounded_rect_vertices_corners(x, y, ww, h, crate::widget::CornerRadii::uniform(r), sw, sh, color, clip_circle, None, out);
678 }
679
680 pub fn plate_bevel_vertices(
681 x: f32, y: f32, ww: f32, h: f32,
682 r: f32,
683 t: f32,
684 sw: f32, sh: f32,
685 base_color: [f32; 4],
686 clip_circle: [f32; 3],
687 ) -> Vec<Vertex> {
688 let mut verts = Vec::new();
689 push_plate_bevel_vertices(x, y, ww, h, r, t, sw, sh, base_color, clip_circle, &mut verts);
690 verts
691 }
692
693 pub fn push_plate_bevel_vertices(
694 x: f32, y: f32, ww: f32, h: f32,
695 r: f32,
696 t: f32,
697 sw: f32, sh: f32,
698 base_color: [f32; 4],
699 clip_circle: [f32; 3],
700 out: &mut Vec<Vertex>,
701 ) {
702 push_bevel_edge_vertices(x, y, ww, h, r, t, sw, sh, base_color, clip_circle, 1.0, out);
703 }
704
705 /// The bevel edge shading, with the light direction selectable: `light_sign` is `1.0`
706 /// for a raised plate (edges facing `light_source_position` are lit) and `-1.0` for a
707 /// recess (those same edges fall into shadow instead, and the far edges catch the
708 /// light). Negating the whole light vector flips every edge and every corner segment
709 /// consistently, because both the flat-edge factors and the arc-normal dot product
710 /// below are linear in it.
711 pub fn push_bevel_edge_vertices(
712 x: f32, y: f32, ww: f32, h: f32,
713 r: f32,
714 t: f32,
715 sw: f32, sh: f32,
716 base_color: [f32; 4],
717 clip_circle: [f32; 3],
718 light_sign: f32,
719 out: &mut Vec<Vertex>,
720 ) {
721 push_bevel_edge_vertices_radii(
722 x, y, ww, h, (r, r, r, r), t, sw, sh, base_color, clip_circle, light_sign, out,
723 );
724 }
725
726 /// As [`push_bevel_edge_vertices`], but with a per-corner radius (TL, TR, BR, BL) so the
727 /// lip can follow a shape whose corners differ — a recess carved along the top of a
728 /// rounded plate needs the plate's radius on its top corners and square ones where it
729 /// meets the content below. A uniform radius there would either square off the plate's
730 /// arc (painting a notch outside it) or wrongly round the inner corners.
731 pub fn push_bevel_edge_vertices_radii(
732 x: f32, y: f32, ww: f32, h: f32,
733 radii: (f32, f32, f32, f32),
734 t: f32,
735 sw: f32, sh: f32,
736 base_color: [f32; 4],
737 clip_circle: [f32; 3],
738 light_sign: f32,
739 out: &mut Vec<Vertex>,
740 ) {
741 push_bevel_edge_vertices_banded(
742 x, y, ww, h, radii, t, sw, sh, base_color, clip_circle, light_sign,
743 default_bevel_bands(t), (true, true, true, true), EdgeKind::Rim, out,
744 );
745 }
746
747 /// What kind of height change an edge represents. The two shade differently because they
748 /// are different shapes, and using one where the other belongs is what makes a bevel read
749 /// as a drawn line instead of a surface.
750 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
751 pub enum EdgeKind {
752 /// The surface *ends* here: a quarter-round rolling from face-on at the inner edge of
753 /// the lip to fully in-plane at the outer boundary, where it drops away. The shading
754 /// therefore peaks exactly at the boundary and dies inward. This is a plate's outer
755 /// perimeter.
756 Rim,
757 /// The surface *continues* at a different height: one plateau steps down to another.
758 /// A height field that falls monotonically across the transition has its normal tilted
759 /// toward the low side the whole way, steepest in the middle and flat at both ends —
760 /// so the shading is a bump straddling the boundary, not a band butted against it.
761 /// Hanging the band on one side instead leaves the seam the eye reads as a drawn line.
762 Step,
763 }
764
765 /// Shading across an edge at signed distance `d` from the boundary (positive = toward the
766 /// shape's interior), for a transition of width `t`. Returns the light term as a fraction
767 /// of full tilt.
768 #[inline]
769 fn bevel_profile(kind: EdgeKind, d: f32, t: f32) -> f32 {
770 if t <= 0.0 {
771 return 0.0;
772 }
773 match kind {
774 // Normal rotates from in-plane (d = 0) to face-on (d = t): sine of what tilt is
775 // left. A linear ramp here reads as a flat 45° chamfer instead of a roll.
776 EdgeKind::Rim => ((1.0 - (d / t).clamp(0.0, 1.0)) * std::f32::consts::FRAC_PI_2).sin(),
777 // Symmetric bump over [-t/2, +t/2], zero at both ends so the transition blends into
778 // both plateaus with no seam.
779 EdgeKind::Step => {
780 let s = (d / t + 0.5).clamp(0.0, 1.0);
781 (s * std::f32::consts::PI).sin()
782 }
783 }
784 }
785
786 /// The light-independent curvature term at signed distance `d` — the second depth cue,
787 /// on top of the directional one. Curvature shading is what ambient light does: convex
788 /// surface catches it from everywhere (bright), concave is self-occluded (dark). Because
789 /// it does not rotate with the light, it survives exactly where the directional term
790 /// dies — walls parallel to the light vector — so no edge ever vanishes entirely.
791 ///
792 /// `high_sign` is +1 when the rect interior is the HIGH side of the transition and -1
793 /// when it is the low side (a recess). Geometry, not lighting: it does not flip with
794 /// `light_sign`... except that for these 2.5D shapes the two are the same number, since
795 /// a raised shape is lit like a plateau and shaded like one.
796 #[inline]
797 fn bevel_curvature(kind: EdgeKind, d: f32, t: f32, high_sign: f32) -> f32 {
798 if t <= 0.0 {
799 return 0.0;
800 }
801 match kind {
802 // A rim is convex everywhere, tightest right at the silhouette: a bright crest
803 // line hugging the boundary and dying fast inward. This is the line that makes
804 // glass read as glass — the edge catches ambient light all the way around, even
805 // (dimmer, via the gain asymmetry below) on the side facing away from the light.
806 EdgeKind::Rim => {
807 let u = (d / t).clamp(0.0, 1.0);
808 let f = 1.0 - u;
809 CREST_RATIO * f * f * f
810 }
811 // An S-curve step is convex on its high half (the shoulder) and concave on its
812 // low half (the fillet, where the wall meets the floor): antisymmetric, zero at
813 // the ends (no seam against either plateau) and at the midpoint.
814 EdgeKind::Step => {
815 let s = (d / t + 0.5).clamp(0.0, 1.0);
816 let outer_is_high = -high_sign; // d < 0 is outside the rect
817 // sin(2πs) is positive on the outer half — the shoulder when the outside is
818 // the high side — and negative on the inner (fillet) half.
819 AO_RATIO * outer_is_high * (s * std::f32::consts::TAU).sin()
820 }
821 }
822 }
823
824 /// Crest amplitude as a fraction of `bevel_depth` — how much brighter a rim's silhouette
825 /// line is than flat surface under even light. Must stay clearly below ~0.7 (the
826 /// projection of a 135° light onto an axis edge), or it cancels the directional shadow
827 /// on the dark side and the rim goes flat there instead of showing a faint bright line
828 /// over a shadowed roll.
829 const CREST_RATIO: f32 = 0.4;
830 /// Shoulder/fillet amplitude as a fraction of `bevel_depth`.
831 const AO_RATIO: f32 = 0.6;
832 /// Per-sign overlay gains. These are asymmetric the opposite way from intuition: on the
833 /// dark bases this DE runs, white-over blending (`b + a(1-b)`) moves the pixel far more
834 /// per unit alpha than black-over (`b(1-a)`) — a dark surface has little brightness for
835 /// black to take away. The old subtractive shading effectively crushed shadow sides to
836 /// black in linear space; the black overlay needs a high gain to keep shadows reading
837 /// at all, while white needs damping to keep highlights from blowing out.
838 const LIGHT_GAIN: f32 = 0.7;
839 const DARK_GAIN: f32 = 3.0;
840
841 /// A shading value (already scaled by `bevel_depth`) as the two overlay passes: the lit
842 /// pass is translucent white, the shadow pass translucent black. Painting the
843 /// *modulation* instead of a resolved surface color is what lets relief primitives compose — a step
844 /// crossing a rim shades the rim's gradient instead of stamping a flat band over it, a
845 /// lip on a translucent plate no longer doubles its opacity, and a recess needs no
846 /// knowledge of the surface color it carves.
847 ///
848 /// Why two passes with fixed RGB rather than one signed color: a primitive whose value
849 /// crosses zero inside a band would interpolate white→black through mid-gray at
850 /// non-negligible alpha — on a dark base a *brightening* artifact right where the
851 /// shading should vanish. With per-pass alphas clamped at the crossing, each pass fades
852 /// to zero there and the hue can never be wrong. Alphas also stay non-negative on every
853 /// vertex, which the renderer requires (negative alpha is the blur sentinel).
854 #[inline]
855 fn overlay_light(v: f32) -> [f32; 4] {
856 [1.0, 1.0, 1.0, (v.max(0.0) * LIGHT_GAIN).min(1.0)]
857 }
858 #[inline]
859 fn overlay_dark(v: f32) -> [f32; 4] {
860 [0.0, 0.0, 0.0, ((-v).max(0.0) * DARK_GAIN).min(1.0)]
861 }
862
863 /// The signed distance range an edge's shading occupies, relative to the boundary.
864 #[inline]
865 fn bevel_span(kind: EdgeKind, t: f32) -> (f32, f32) {
866 match kind {
867 EdgeKind::Rim => (0.0, t),
868 EdgeKind::Step => (-0.5 * t, 0.5 * t),
869 }
870 }
871
872 /// How many gradient bands to slice a lip of thickness `t` into. Vertex colors interpolate
873 /// linearly, so each band is a chord of the shading curve; one band per ~1.25px keeps the
874 /// error under a shade step without emitting geometry finer than the display resolves.
875 /// The cap rose with the curvature term: a step now has two features across its width
876 /// (shoulder and fillet), so it needs double the samples a single bump did.
877 fn default_bevel_bands(t: f32) -> usize {
878 ((t / 1.25).ceil() as usize).clamp(1, 12)
879 }
880
881 /// As [`push_bevel_edge_vertices_radii`], with the band count forced and the walls
882 /// selectable — for callers that want a coarser or finer roll-off than thickness alone
883 /// implies, or that are shading a step rather than a closed shape.
884 ///
885 /// `edges` is (top, right, bottom, left). Suppressing a wall matters for a region that
886 /// runs flush to the surface's own edge: a full-width menubar sunk into the top of a plate
887 /// is a *plateau one step down*, not a trough, so its only real wall is the one facing the
888 /// content. Drawing the other three would carve a lip along the plate's outer edge, where
889 /// the plate's own roll already lives, and the two would fight.
890 pub fn push_bevel_edge_vertices_banded(
891 x: f32, y: f32, ww: f32, h: f32,
892 radii: (f32, f32, f32, f32),
893 t: f32,
894 sw: f32, sh: f32,
895 base_color: [f32; 4],
896 clip_circle: [f32; 3],
897 light_sign: f32,
898 bands: usize,
899 edges: (bool, bool, bool, bool),
900 kind: EdgeKind,
901 out: &mut Vec<Vertex>,
902 ) {
903 // Floored for the same reason as `plate_push_raised`'s cap: a negative
904 // extent must degrade to no ring, not panic in `clamp`.
905 let cap = (ww.min(h) * 0.5).max(0.0);
906 let (tl, tr, br, bl) = (
907 radii.0.clamp(0.0, cap),
908 radii.1.clamp(0.0, cap),
909 radii.2.clamp(0.0, cap),
910 radii.3.clamp(0.0, cap),
911 );
912 let t = t.clamp(0.0, cap);
913 if t <= 0.0 {
914 return;
915 }
916 let bands = bands.max(1);
917
918 let rad = crate::layout::light_source_position();
919 let lx = rad.cos() * light_sign;
920 let ly = -rad.sin() * light_sign;
921 let depth = crate::layout::bevel_depth();
922
923 // `base_color` is no longer painted: shading is an overlay (see `overlay_color`), so
924 // the surface below shows through with its own gradients and translucency intact.
925 let _ = base_color;
926 // Shading (directional + curvature, scaled by bevel_depth) at signed distance `d`,
927 // for an edge whose outward flat normal is `dir`. A `Step` band runs negative — it
928 // straddles the boundary into the plateau outside the rect, which is exactly what
929 // removes the seam.
930 let value = |dot: f32, d: f32| {
931 depth * (bevel_profile(kind, d, t) * dot + bevel_curvature(kind, d, t, light_sign))
932 };
933 // The (up to two) overlay color pairs for a band running from value `v0` to `v1`:
934 // one white pair and/or one black pair, each pass fading to zero alpha wherever the
935 // value has the other sign. Both fire only when the band straddles the terminator.
936 let passes = |v0: f32, v1: f32| -> [Option<([f32; 4], [f32; 4])>; 2] {
937 [
938 (v0 > 0.0 || v1 > 0.0).then(|| (overlay_light(v0), overlay_light(v1))),
939 (v0 < 0.0 || v1 < 0.0).then(|| (overlay_dark(v0), overlay_dark(v1))),
940 ]
941 };
942 let (span_lo, span_hi) = bevel_span(kind, t);
943
944 // Each flat edge spans between its two adjoining corner radii, not a single uniform
945 // inset — that is what lets the corners differ. At a square corner there is no arc to
946 // cover the t×t patch where two edges meet, so the horizontal edges claim it (they run
947 // the full span) and the vertical ones inset by `t`; overlapping them instead would
948 // double-blend that patch, which shows as a dark notch on a translucent surface.
949 let (left_top, left_bot) = (if tl > 0.0 { tl } else { t }, if bl > 0.0 { bl } else { t });
950 let (right_top, right_bot) = (if tr > 0.0 { tr } else { t }, if br > 0.0 { br } else { t });
951 let top_w = ww - tl - tr;
952 let bottom_w = ww - bl - br;
953 let left_h = h - left_top - left_bot;
954 let right_h = h - right_top - right_bot;
955
956 for k in 0..bands {
957 let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
958 let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
959 let bw = d1 - d0;
960
961 // Top: outward normal (0,-1); the gradient runs downward, into the surface.
962 if top_w > 0.0 && edges.0 {
963 let (v0, v1) = (value(-ly, d0), value(-ly, d1));
964 for (c0, c1) in passes(v0, v1).into_iter().flatten() {
965 out.extend_from_slice(&quad_vertices_shaded(
966 x + tl, y + d0, top_w, bw, sw, sh, c0, c0, c1, c1, clip_circle,
967 ));
968 }
969 }
970 // Bottom: outward normal (0,1); gradient runs upward.
971 if bottom_w > 0.0 && edges.2 {
972 let (v0, v1) = (value(ly, d0), value(ly, d1));
973 for (c0, c1) in passes(v0, v1).into_iter().flatten() {
974 out.extend_from_slice(&quad_vertices_shaded(
975 x + bl, y + h - d1, bottom_w, bw, sw, sh, c1, c1, c0, c0, clip_circle,
976 ));
977 }
978 }
979 // Left: outward normal (-1,0); gradient runs rightward.
980 if left_h > 0.0 && edges.3 {
981 let (v0, v1) = (value(-lx, d0), value(-lx, d1));
982 for (c0, c1) in passes(v0, v1).into_iter().flatten() {
983 out.extend_from_slice(&quad_vertices_shaded(
984 x + d0, y + left_top, bw, left_h, sw, sh, c0, c1, c1, c0, clip_circle,
985 ));
986 }
987 }
988 // Right: outward normal (1,0); gradient runs leftward.
989 if right_h > 0.0 && edges.1 {
990 let (v0, v1) = (value(lx, d0), value(lx, d1));
991 for (c0, c1) in passes(v0, v1).into_iter().flatten() {
992 out.extend_from_slice(&quad_vertices_shaded(
993 x + ww - d1, y + right_top, bw, right_h, sw, sh, c1, c0, c0, c1, clip_circle,
994 ));
995 }
996 }
997 }
998
999 // A corner arc belongs to both of its adjoining walls, so it is drawn only when both
1000 // are — otherwise a suppressed wall would still get a quarter of a lip.
1001 let corners = [
1002 (x + tl, y + tl, tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, edges.0 && edges.3), // Top-Left
1003 (x + ww - tr, y + tr, tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, edges.0 && edges.1), // Top-Right
1004 (x + ww - br, y + h - br, br, 0.0, 0.5 * std::f32::consts::PI, edges.2 && edges.1), // Bottom-Right
1005 (x + bl, y + h - bl, bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, edges.2 && edges.3), // Bottom-Left
1006 ];
1007
1008 for &(cx, cy, r, start_angle, end_angle, enabled) in &corners {
1009 // A square corner has no arc to sweep — the flat edges already met there.
1010 if r <= 0.0 || !enabled {
1011 continue;
1012 }
1013 // The corner is a quarter of a torus: shading varies along the sweep (the normal
1014 // swings through 90° of the light) *and* across the lip (the roll-off). Both come
1015 // out of the vertex colors, so one quad per (segment × band) cell is enough — no
1016 // faceting, unlike the 16 flat wedges this replaced.
1017 let segments = ((r * 0.75) as usize).clamp(8, 48);
1018 let ct = t.min(r);
1019 for j in 0..segments {
1020 let theta0 = start_angle + (j as f32) * (end_angle - start_angle) / (segments as f32);
1021 let theta1 = start_angle + ((j + 1) as f32) * (end_angle - start_angle) / (segments as f32);
1022 let (cos0, sin0) = (theta0.cos(), theta0.sin());
1023 let (cos1, sin1) = (theta1.cos(), theta1.sin());
1024 for k in 0..bands {
1025 let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
1026 let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
1027 // Inward along the corner's radius is the same signed distance as inward
1028 // from a flat edge, so the arc scales the span the same way.
1029 let (r0, r1) = (r - ct * (d0 / t), r - ct * (d1 / t));
1030 let p = |rho: f32, c: f32, s: f32| -> [f32; 2] {
1031 [
1032 ((cx + rho * c) / sw) * 2.0 - 1.0,
1033 1.0 - ((cy + rho * s) / sh) * 2.0,
1034 ]
1035 };
1036 // Outer/inner × the two sweep ends; each vertex gets its own value, and
1037 // the cell is drawn once per overlay pass that has any coverage.
1038 let vals = [
1039 value(cos0 * lx + sin0 * ly, d0),
1040 value(cos1 * lx + sin1 * ly, d0),
1041 value(cos1 * lx + sin1 * ly, d1),
1042 value(cos0 * lx + sin0 * ly, d1),
1043 ];
1044 let geo = [
1045 p(r0, cos0, sin0),
1046 p(r0, cos1, sin1),
1047 p(r1, cos1, sin1),
1048 p(r1, cos0, sin0),
1049 ];
1050 let mut cells: [Option<fn(f32) -> [f32; 4]>; 2] = [None, None];
1051 if vals.iter().any(|&v| v > 0.0) {
1052 cells[0] = Some(overlay_light);
1053 }
1054 if vals.iter().any(|&v| v < 0.0) {
1055 cells[1] = Some(overlay_dark);
1056 }
1057 for f in cells.into_iter().flatten() {
1058 let c: Vec<Vertex> = (0..4)
1059 .map(|i| Vertex { position: geo[i], color: f(vals[i]), clip_circle })
1060 .collect();
1061 out.extend_from_slice(&[c[0], c[1], c[2], c[0], c[2], c[3]]);
1062 }
1063 }
1064 }
1065 }
1066 }
1067
1068 /// How strong the face gradient is, as a fraction of `bevel_depth` at the corner nearest
1069 /// the light. Deliberately well below the edge amplitude: the face is a plane, not a
1070 /// roll — it only *leans* toward the light.
1071 const FACE_RATIO: f32 = 0.35;
1072
1073 /// The face lighting of a plate: a single diagonal luminance gradient across the whole
1074 /// surface, brightest at the corner facing `light_source_position` and darkest at the
1075 /// opposite one. This is the difference between an object and a sticker: a real surface
1076 /// under directional light is never uniform, and a perfectly flat fill makes the eye
1077 /// read the (much smaller) edge shading as frame decoration rather than shape.
1078 ///
1079 /// Emitted as the same two-pass white/black overlays as the relief primitives (see
1080 /// [`overlay_light`]/[`overlay_dark`]): fixed RGB per pass, per-corner alphas clamped at
1081 /// the terminator, bilinear across the quad. The quad is square — its corners poke past
1082 /// a rounded plate's arcs — but the compositor clips the window surface to the same
1083 /// radius, so the overhang never reaches the screen.
1084 pub fn push_plate_face_vertices(
1085 x: f32, y: f32, ww: f32, h: f32,
1086 sw: f32, sh: f32,
1087 clip_circle: [f32; 3],
1088 out: &mut Vec<Vertex>,
1089 ) {
1090 let rad = crate::layout::light_source_position();
1091 let (lx, ly) = (rad.cos(), -rad.sin());
1092 let amp = crate::layout::bevel_depth() * FACE_RATIO;
1093 // Corner value = how much its outward diagonal faces the light.
1094 let inv = std::f32::consts::FRAC_1_SQRT_2;
1095 let v_tl = amp * inv * (-lx - ly);
1096 let v_tr = amp * inv * (lx - ly);
1097 let v_br = amp * inv * (lx + ly);
1098 let v_bl = amp * inv * (-lx + ly);
1099 let vs = [v_tl, v_tr, v_br, v_bl];
1100 if vs.iter().any(|&v| v > 0.0) {
1101 out.extend_from_slice(&quad_vertices_shaded(
1102 x, y, ww, h, sw, sh,
1103 overlay_light(v_tl), overlay_light(v_tr), overlay_light(v_br), overlay_light(v_bl),
1104 clip_circle,
1105 ));
1106 }
1107 if vs.iter().any(|&v| v < 0.0) {
1108 out.extend_from_slice(&quad_vertices_shaded(
1109 x, y, ww, h, sw, sh,
1110 overlay_dark(v_tl), overlay_dark(v_tr), overlay_dark(v_br), overlay_dark(v_bl),
1111 clip_circle,
1112 ));
1113 }
1114 }
1115
1116 pub fn push_plate_solid_border_vertices(
1117 x: f32, y: f32, ww: f32, h: f32,
1118 radii: crate::widget::CornerRadii,
1119 t: f32,
1120 sw: f32, sh: f32,
1121 color: [f32; 4],
1122 clip_circle: [f32; 3],
1123 out: &mut Vec<Vertex>,
1124 ) {
1125 let mut r_tl = radii.top_left.max(0.0);
1126 let mut r_tr = radii.top_right.max(0.0);
1127 let mut r_br = radii.bottom_right.max(0.0);
1128 let mut r_bl = radii.bottom_left.max(0.0);
1129
1130 // Simple scale clamping
1131 let sum_top = r_tl + r_tr;
1132 if sum_top > ww {
1133 let f = ww / sum_top;
1134 r_tl *= f;
1135 r_tr *= f;
1136 }
1137 let sum_bottom = r_bl + r_br;
1138 if sum_bottom > ww {
1139 let f = ww / sum_bottom;
1140 r_bl *= f;
1141 r_br *= f;
1142 }
1143 let sum_left = r_tl + r_bl;
1144 if sum_left > h {
1145 let f = h / sum_left;
1146 r_tl *= f;
1147 r_bl *= f;
1148 }
1149 let sum_right = r_tr + r_br;
1150 if sum_right > h {
1151 let f = h / sum_right;
1152 r_tr *= f;
1153 r_br *= f;
1154 }
1155
1156 out.extend_from_slice(&quad_vertices_with_clip(x + r_tl, y, ww - r_tl - r_tr, t, sw, sh, color, clip_circle));
1157 out.extend_from_slice(&quad_vertices_with_clip(x, y + r_tl, t, h - r_tl - r_bl, sw, sh, color, clip_circle));
1158 out.extend_from_slice(&quad_vertices_with_clip(x + r_bl, y + h - t, ww - r_bl - r_br, t, sw, sh, color, clip_circle));
1159 out.extend_from_slice(&quad_vertices_with_clip(x + ww - t, y + r_tr, t, h - r_tr - r_br, sw, sh, color, clip_circle));
1160
1161 let segments = 16;
1162 let corner_e = 2.0 / crate::layout::corner_shape();
1163
1164 // Corner strokes as annulus strips between the outer superellipse (radius
1165 // r) and its inner scaled copy (r - t): at 1px thickness the scaled inner
1166 // curve is indistinguishable from the true parallel curve, and at
1167 // corner_shape 2 this is exactly the circular arc annulus. NOT
1168 // push_arc_background_vertices — that stays circular for genuine arcs.
1169 //
1170 // Both edges of the annulus are FEATHERED, like the fill fan's corners
1171 // (push_rounded_rect_vertices_corners) and push_feathered_line_vertices:
1172 // each fades over `f` either side of its true curve, so the 50%-coverage
1173 // lines stay on the exact silhouette and the stroke reads at the same
1174 // weight, but the arc anti-aliases instead of rasterizing a staircase
1175 // beside the SDF-smooth face it outlines. The straight edges stay crisp
1176 // quads (a pixel-snapped hairline would only blur). A corner too tight to
1177 // fit the inner fade keeps the hard annulus.
1178 let f = 0.5f32.min(t * 0.25);
1179 let fade = [color[0], color[1], color[2], 0.0];
1180 let corner = |cx: f32, cy: f32, r: f32, start: f32, end: f32, out: &mut Vec<Vertex>| {
1181 let r_in = (r - t).max(0.0);
1182 // (inner radius, outer radius, inner alpha colour, outer alpha colour)
1183 let bands: &[(f32, f32, [f32; 4], [f32; 4])] = if r_in - f > 0.0 {
1184 &[
1185 (r_in - f, r_in + f, fade, color),
1186 (r_in + f, r - f, color, color),
1187 (r - f, r + f, color, fade),
1188 ]
1189 } else {
1190 &[(r_in, r, color, color)]
1191 };
1192 let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
1193 for i in 0..segments {
1194 let t1 = start + (i as f32) * (end - start) / segments as f32;
1195 let t2 = start + ((i + 1) as f32) * (end - start) / segments as f32;
1196 let (c1, s1) = superellipse_pt(t1, corner_e);
1197 let (c2, s2) = superellipse_pt(t2, corner_e);
1198 for &(ra, rb, ca, cb) in bands {
1199 if rb - ra <= 0.0 {
1200 continue;
1201 }
1202 let o1 = ndc(cx + rb * c1, cy + rb * s1);
1203 let o2 = ndc(cx + rb * c2, cy + rb * s2);
1204 let i1 = ndc(cx + ra * c1, cy + ra * s1);
1205 let i2 = ndc(cx + ra * c2, cy + ra * s2);
1206 out.push(Vertex { position: o1, color: cb, clip_circle });
1207 out.push(Vertex { position: o2, color: cb, clip_circle });
1208 out.push(Vertex { position: i1, color: ca, clip_circle });
1209 out.push(Vertex { position: o2, color: cb, clip_circle });
1210 out.push(Vertex { position: i2, color: ca, clip_circle });
1211 out.push(Vertex { position: i1, color: ca, clip_circle });
1212 }
1213 }
1214 };
1215
1216 if r_tl > 0.1 {
1217 corner(x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, out);
1218 }
1219 if r_tr > 0.1 {
1220 corner(x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, out);
1221 }
1222 if r_br > 0.1 {
1223 corner(x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI, out);
1224 }
1225 if r_bl > 0.1 {
1226 corner(x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, out);
1227 }
1228 }
1229
1230 pub fn push_plate_solid_border_vertices_legacy(
1231 x: f32, y: f32, ww: f32, h: f32,
1232 r: f32,
1233 t: f32,
1234 sw: f32, sh: f32,
1235 color: [f32; 4],
1236 clip_circle: [f32; 3],
1237 out: &mut Vec<Vertex>,
1238 ) {
1239 let radii = crate::widget::CornerRadii::uniform(r);
1240 push_plate_solid_border_vertices(x, y, ww, h, radii, t, sw, sh, color, clip_circle, out);
1241 }
1242
1243 /// A contiguous run of vertices sharing one scissor rect (Phase 3 single paint path) and one
1244 /// rounded-rect clip. `scissor` is a logical-pixel clip (`None` = unclipped); `clip_rrect` is
1245 /// the paint walk's `[cx, cy, bx, by, r]` rounded clip in logical px (`None` = unclipped),
1246 /// applied as per-draw push-constant state; `start..end` indexes the flat vertex buffer.
1247 pub struct DlBatch {
1248 pub scissor: Option<crate::scene::layout::Rect>,
1249 pub clip_rrect: Option<[f32; 5]>,
1250 pub start: u32,
1251 pub end: u32,
1252 /// When set, this batch is one SDF-lit plate cover quad (see
1253 /// [`crate::draw::PlatePush`]; already in physical px). Never merged.
1254 pub plate: Option<crate::draw::PlatePush>,
1255 /// A blur-behind plate (negative-alpha color): before drawing this batch
1256 /// the renderer snapshots the swapchain-so-far into its snapshot image, so
1257 /// the blur samples everything painted beneath the plate — not just the 3D
1258 /// scene backdrop. Never merged.
1259 pub blur_behind: bool,
1260 }
1261
1262 /// An image draw from the display list: `at` is the vertex index it sorts
1263 /// before (its position in the tessellated stream); `clip` is the item's
1264 /// paint-walk clip. Logical coordinates throughout.
1265 pub struct DlImage {
1266 pub image: u32,
1267 pub rect: crate::scene::layout::Rect,
1268 pub alpha: f32,
1269 pub at: u32,
1270 pub clip: Option<crate::scene::layout::Rect>,
1271 }
1272
1273 /// Tessellate a `scene::paint::DisplayList`'s geometry into a flat vertex buffer plus per-clip draw
1274 /// batches, reusing the same tessellators as the legacy path so vertices are identical. `Text`
1275 /// prims are skipped here — text is still rendered via the app's `text_areas()` path. `sw`/`sh` are
1276 /// logical surface dimensions (as everywhere else); `scale` is the HiDPI factor, needed because an
1277 /// item's circular clip rides the vertices in PHYSICAL pixels. Consecutive prims sharing a clip are
1278 /// merged into one batch (the circle clip is per-vertex, so it never splits batches).
1279 /// `CCE_PLATE_DEBUG=1` — trace which carves group into their host plate as exact
1280 /// CSG features and which fall back to the standalone overlay shading.
1281 ///
1282 /// The two paths do NOT look the same: a grouped carve is part of the plate's
1283 /// single height field, so its wall meets the plate's rolled perimeter as a real
1284 /// junction, while the fallback approximates that with the host-box fade. Six
1285 /// conditions decide it, three of them dynamic (draw order, neighbouring plates,
1286 /// whether another carve already claimed the host's feature run), so the SAME
1287 /// widget can render either way depending on what is around it — and it does so
1288 /// silently. That has already shipped as a bug once: a hovered button's opaque
1289 /// fill used to sever every later button from the root plate they carve into,
1290 /// which is why `plate_stack` is a stack (see its comment below).
1291 ///
1292 /// Off by default and read once; the classification below runs only when set.
1293 /// Prim discriminant name, for `CCE_PLATE_DEBUG` reporting only.
1294 fn prim_kind(p: &crate::scene::paint::Prim) -> &'static str {
1295 use crate::scene::paint::Prim as P;
1296 match p {
1297 P::Quad { .. } => "Quad", P::RoundedRect { .. } => "RoundedRect",
1298 P::Border { .. } => "Border", P::Bevel { .. } => "Bevel",
1299 P::Recess { .. } => "Recess", P::Boss { .. } => "Boss",
1300 P::Ridge { .. } => "Ridge", P::Trough { .. } => "Trough", P::Field { .. } => "Field", P::Plate { .. } => "Plate",
1301 P::Arc { .. } => "Arc", P::ArcShaded { .. } => "ArcShaded",
1302 P::Vector { .. } => "Vector", P::Circle { .. } => "Circle",
1303 P::Sphere { .. } => "Sphere", P::Droplet { .. } => "Droplet",
1304 P::DropletScrim { .. } => "DropletScrim", P::Frame { .. } => "Frame",
1305 P::ConcaveFillet { .. } => "ConcaveFillet",
1306 P::Groove { .. } => "Groove", P::Lattice { .. } => "Lattice", P::Grout { .. } => "Grout", P::Fill { .. } => "Fill",
1307 P::CarveUnion { .. } => "CarveUnion", P::Glow { .. } => "Glow",
1308 P::Text { .. } => "Text", P::Image { .. } => "Image",
1309 }
1310 }
1311
1312 fn plate_debug() -> bool {
1313 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1314 *ON.get_or_init(|| std::env::var("CCE_PLATE_DEBUG").is_ok_and(|v| v != "0"))
1315 }
1316
1317 /// Debug builds make one kind of fallback LOUD without `CCE_PLATE_DEBUG`: a
1318 /// carve that could group (full ring, untinted) failing to while a still-open
1319 /// plate encloses it and the carve's shaded region reaches that plate's
1320 /// perimeter roll. There the grouped and overlay paths shade the junction
1321 /// differently, and the rejection is one of the dynamic rules — so the SAME
1322 /// widget can flip looks frame to frame with nothing on stderr. Not an
1323 /// assert/panic: every rejection is conservative-CORRECT (the audit that
1324 /// shipped CCE_PLATE_DEBUG found no misgrouping; a later plate overlapping the
1325 /// carve genuinely must be shaded over, not under) — it is the frame-to-frame
1326 /// LOOK that flips, so the right loudness is an unmissable warning, not a
1327 /// crash. The ubiquitous quiet case stays quiet by construction: ordinary
1328 /// geometry closing every grouping window empties `plate_stack`, so no
1329 /// enclosing OPEN plate exists and this never runs — that is draw-order
1330 /// design, not a flip.
1331 ///
1332 /// Returns the dynamic rule to report, or `None` when the fallback is not the
1333 /// loud case. Pure so the classification is unit-testable; `later_plates` are
1334 /// the open plates emitted after the enclosing host.
1335 #[cfg(debug_assertions)]
1336 fn near_roll_fallback_reason(
1337 carve: &crate::scene::layout::Rect,
1338 depth: f32,
1339 host: &crate::scene::layout::Rect,
1340 roll: f32,
1341 later_plates: &[crate::scene::layout::Rect],
1342 budget_full: bool,
1343 ) -> Option<&'static str> {
1344 // The carve's shaded region — the overlay path's cover-quad inflation.
1345 let infl = depth * 0.5 + 2.0;
1346 let (sx0, sy0) = (carve.x - infl, carve.y - infl);
1347 let (sx1, sy1) = (carve.x + carve.width + infl, carve.y + carve.height + infl);
1348 // "Near the roll" = the shaded region leaves the host rect deflated by the
1349 // host's own roll width on any side.
1350 let near = sx0 < host.x + roll
1351 || sy0 < host.y + roll
1352 || sx1 > host.x + host.width - roll
1353 || sy1 > host.y + host.height - roll;
1354 if !near {
1355 return None;
1356 }
1357 // The dynamic rules, in the order the grouping guard tests them.
1358 if budget_full {
1359 return Some("the feature budget is full");
1360 }
1361 if later_plates
1362 .iter()
1363 .any(|o| sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y)
1364 {
1365 return Some("a later plate overlaps the carve's shaded region");
1366 }
1367 Some("the host's feature run is closed (another plate appended features since)")
1368 }
1369
1370 /// Print a near-roll fallback warning once per distinct message — a carve in a
1371 /// steady layout would otherwise repeat it every frame.
1372 #[cfg(debug_assertions)]
1373 fn plate_carve_warn_once(msg: String) {
1374 use std::sync::{Mutex, OnceLock};
1375 static SEEN: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
1376 let seen = SEEN.get_or_init(|| Mutex::new(std::collections::HashSet::new()));
1377 if seen.lock().unwrap().insert(msg.clone()) {
1378 eprintln!("{msg}");
1379 }
1380 }
1381
1382 pub fn tessellate_display_list(
1383 dl: &crate::scene::paint::DisplayList,
1384 sw: f32,
1385 sh: f32,
1386 scale: f32,
1387 ) -> (Vec<Vertex>, Vec<DlBatch>, Vec<DlImage>, Vec<[f32; 12]>) {
1388 use crate::scene::material::PlateRole;
1389 use crate::scene::paint::{Cap, Prim};
1390 let mut verts: Vec<Vertex> = Vec::new();
1391 let mut batches: Vec<DlBatch> = Vec::new();
1392 let mut images: Vec<DlImage> = Vec::new();
1393 // Carves CSG'd into plates (see Frame2D::plate_features), plus the plate
1394 // they group into: the most recent Plate/Bevel batch, provided only Text
1395 // and Image prims (which draw through separate paths anyway) intervene.
1396 let mut features: Vec<[f32; 12]> = Vec::new();
1397 // Open carve-host plates, in emission order (innermost candidates last).
1398 // A STACK, not a single slot: a sibling plate emitted between a root plate
1399 // and its later carves (a hovered button's opaque fill among transparent
1400 // ones) must not sever those carves from the root plate they are carved
1401 // into — that severing rendered every button after the hovered one
1402 // through the visually-different overlay fallback. Ordinary geometry
1403 // still closes every open plate (the draw-order rule below).
1404 let mut plate_stack: Vec<(usize, crate::scene::layout::Rect)> = Vec::new();
1405 // Which plate last appended a carve feature: a plate's features are
1406 // addressed as one contiguous [offset, count] run (PlatePush::host), so a
1407 // plate may only receive MORE features while no other plate has appended
1408 // any since.
1409 let mut last_feature_plate: Option<usize> = None;
1410 // `CCE_PLATE_DEBUG` bookkeeping — see `plate_debug`.
1411 let dbg_plates = plate_debug();
1412 let mut dbg_grouped = 0usize;
1413 let mut dbg_fell_back: Vec<String> = Vec::new();
1414 let mut dbg_opened = 0usize;
1415 // Which prim kind closed a still-open grouping window, and how many plates
1416 // it closed — the answer to "why was there no enclosing plate?".
1417 let mut dbg_closed_by: std::collections::BTreeMap<&'static str, usize> =
1418 std::collections::BTreeMap::new();
1419
1420 // SDF-lit plate path (shader2d's plate branch) vs the legacy banded vertex
1421 // shading, plus the frame-constant lighting inputs it pushes per plate.
1422 let shader_plates = crate::layout::bevel_shader();
1423 // Light and material come from `scene::relief_shade`, which is also what
1424 // cce-relief predicts pixels with — one definition, so the editor cannot
1425 // draw a different material than the renderer applies.
1426 let plate_light = crate::scene::relief_shade::light_vector();
1427 // [shading strength (1.0 at the default bevel_depth), specular strength,
1428 // shininess, curvature/AO strength] — the DE's finish, for the CARVES,
1429 // which shade whatever is beneath them and so take the host's. A prim
1430 // that carries a Material (Plate, Bevel, Sphere, Droplet) pushes its own
1431 // `material.finish` instead. Curvature is kept near the raised path's
1432 // crest amplitude: the recess shoulder's brightening lands on the same
1433 // pixels as its specular line, and the two stack — at 0.5 the step read
1434 // several times hotter than a plate roll.
1435 let plate_mat = crate::scene::material::Finish::from_style().to_array();
1436
1437 for item in &dl.items {
1438 let mut start = verts.len() as u32;
1439 let mut plate: Option<crate::draw::PlatePush> = None;
1440 // A frosted flat fill promoted to a zero-depth plate batch (below):
1441 // it carries a recipe like any plate, but it is ordinary geometry to
1442 // the carve grouping — it opens no host and closes the open ones.
1443 let mut promoted = false;
1444 let mut made_plate: Option<crate::scene::layout::Rect> = None;
1445 // Blur-behind marker: a prim whose FILL alpha is negative asks the
1446 // renderer to snapshot the frame-so-far before it draws. Every
1447 // fill-bearing prim counts — the shader's a<0 branch runs for all of
1448 // them, and a variant missing here still frosts, but against the
1449 // stale scene backdrop instead of the frame: a flat tint with no
1450 // content and no blur, which is how the Dropdown popover (Border)
1451 // and the menubar panels (Quad) shipped visibly unfrosted while the
1452 // context menu (Plate) worked.
1453 let mut blur_behind = matches!(
1454 &item.prim,
1455 crate::scene::paint::Prim::Quad { color, .. }
1456 | crate::scene::paint::Prim::RoundedRect { color, .. } if color[3] < 0.0
1457 ) || matches!(
1458 &item.prim,
1459 crate::scene::paint::Prim::Bevel { material, .. }
1460 | crate::scene::paint::Prim::Frame { material, .. }
1461 | crate::scene::paint::Prim::Plate { material, .. }
1462 | crate::scene::paint::Prim::Droplet { material, .. }
1463 if material.fill(PlateRole::Nested)[3] < 0.0
1464 ) || matches!(
1465 &item.prim,
1466 crate::scene::paint::Prim::Border { fill, .. } if fill[3] < 0.0
1467 ) || matches!(
1468 &item.prim,
1469 crate::scene::paint::Prim::Fill { material, .. } if material.fill(PlateRole::Nested)[3] < 0.0
1470 );
1471 // Logical [cx, cy, r] → the physical-pixel triple the vertex attribute carries.
1472 let no = item
1473 .clip_circle
1474 .map(|c| [c[0] * scale, c[1] * scale, c[2] * scale])
1475 .unwrap_or([0.0f32, 0.0, 0.0]);
1476 // Fixed 16-segment fans read as polygons once a circle/arc is pane-sized; scale
1477 // the fan with the PHYSICAL radius (capped — beyond 128 the chord error is
1478 // subpixel even on HiDPI).
1479 let segs = |radius: f32| -> usize { ((radius * scale) as usize).clamp(16, 128) };
1480 match &item.prim {
1481 Prim::Text { .. } => continue, // text goes through the glyph/text-span path
1482 Prim::Image { image, rect, alpha } => {
1483 images.push(DlImage {
1484 image: *image,
1485 rect: *rect,
1486 alpha: *alpha,
1487 at: verts.len() as u32,
1488 clip: item.clip,
1489 });
1490 continue;
1491 }
1492 // A frosted FLAT fill — a `Flat` control face, a menu panel, a
1493 // popover, an inset plate's face — is a zero-depth plate batch
1494 // (RFC material § 6.2): the same shader path as every plate, so
1495 // it carries its own frost recipe instead of a window-wide one,
1496 // with the configured `corner_shape` and no roll, which is what the
1497 // tessellated fill drew. The display list is untouched, so the
1498 // legacy bridges that extract RoundedRects still see one.
1499 Prim::Quad { rect, color } if shader_plates && color[3] < 0.0 => {
1500 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
1501 plate = Some(flat_frost_push(rect, (0.0, 0.0, 0.0, 0.0), *color, scale, plate_light, plate_mat));
1502 promoted = true;
1503 }
1504 Prim::RoundedRect { rect, radius, corners, color } if shader_plates && color[3] < 0.0 => {
1505 let radii = (
1506 if corners.0 { *radius } else { 0.0 },
1507 if corners.1 { *radius } else { 0.0 },
1508 if corners.2 { *radius } else { 0.0 },
1509 if corners.3 { *radius } else { 0.0 },
1510 );
1511 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
1512 plate = Some(flat_frost_push(rect, radii, *color, scale, plate_light, plate_mat));
1513 promoted = true;
1514 }
1515 Prim::Fill { rect, radii, material } if shader_plates && material.frost.is_frosted() => {
1516 // A material's flat fill: the frosted promotion above with
1517 // the MATERIAL's recipe (compression, refraction, radius)
1518 // instead of the DE default's. Zero depth, the configured
1519 // corner shape, no host — exactly a promoted RoundedRect.
1520 let color = material.fill(PlateRole::Nested);
1521 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
1522 let mut p = plate_push_raised(rect, *radii, 0.0, scale, plate_light, plate_mat, false, None);
1523 let [fz, fw] = material.frost.pack(scale);
1524 p.host[2] = fz;
1525 p.host[3] = fw;
1526 plate = Some(p);
1527 promoted = true;
1528 }
1529 Prim::Fill { rect, radii, material } => {
1530 // Opaque (or the legacy path): a plain rounded fill.
1531 let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
1532 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, material.fill(PlateRole::Nested), no, None, &mut verts);
1533 }
1534 Prim::Border { rect, radii, fill, border, thickness } if shader_plates && fill[3] < 0.0 => {
1535 // The fill as its own plate batch, closed here; the stroke
1536 // follows as ordinary geometry in the batch the tail makes.
1537 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *fill));
1538 let p = flat_frost_push(rect, *radii, *fill, scale, plate_light, plate_mat);
1539 let end = verts.len() as u32;
1540 plate_stack.clear();
1541 batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate: Some(p), blur_behind: true });
1542 start = end;
1543 blur_behind = false;
1544 let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
1545 push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
1546 }
1547 Prim::Quad { rect, color } => {
1548 // Quads honor an active circle clip like circles/arcs do (the
1549 // Ramp's foam-cell fills draw as clipped strips).
1550 verts.extend(quad_vertices_with_clip(rect.x, rect.y, rect.width, rect.height, sw, sh, *color, no));
1551 }
1552 Prim::RoundedRect { rect, radius, corners, color } => {
1553 let radii = crate::widget::CornerRadii::new(
1554 if corners.0 { *radius } else { 0.0 },
1555 if corners.1 { *radius } else { 0.0 },
1556 if corners.2 { *radius } else { 0.0 },
1557 if corners.3 { *radius } else { 0.0 },
1558 );
1559 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, *color, no, None, &mut verts);
1560 }
1561 Prim::Border { rect, radii, fill, border, thickness } => {
1562 let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
1563 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, *fill, no, None, &mut verts);
1564 push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
1565 }
1566 Prim::Glow { rect, radius, reach, color } => {
1567 push_glow_vertices(rect.x, rect.y, rect.width, rect.height, *radius, *reach, sw, sh, *color, no, &mut verts);
1568 }
1569 Prim::Bevel { rect, radii, material, depth, tint } if shader_plates => {
1570 let color = material.fill(PlateRole::Nested);
1571 let mat = material.finish.to_array();
1572 // SDF-lit raised plate: one cover quad; the shader owns fill,
1573 // roll shading, corners, and silhouette AA. Nominal corner
1574 // radii (scale_corners false): a Bevel is a WIDGET-scale plate
1575 // whose silhouette must match the nominal-radius squircles of
1576 // the controls around it — only window-scale `Plate`s get the
1577 // curvature-matched span.
1578 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
1579 let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, false, None);
1580 // The plate's own frost recipe rides host.zw (see PlatePush).
1581 let [fz, fw] = material.frost.pack(scale);
1582 p.host[2] = fz;
1583 p.host[3] = fw;
1584 // w = 1 marks an accent-tinted plate (the focused-pane
1585 // treatment): the shader keeps the roll's light and shadow
1586 // and recolours them — light toward the tint, shadow toward
1587 // a dark tint — matching the free-carve path's tinted-well
1588 // convention. Neutral white keeps w = 0 (a no-op multiply).
1589 let full = if *tint == [1.0, 1.0, 1.0] { 0.0 } else { 1.0 };
1590 p.specular_tint = [tint[0], tint[1], tint[2], full];
1591 plate = Some(p);
1592 made_plate = Some(*rect);
1593 }
1594 Prim::Frame { rect, hole, hole_radii, material, depth } if shader_plates => {
1595 // The Bevel branch turned inside out (shader MODE_FRAME): the
1596 // cover quad is the face's bound, the SDF box is the HOLE,
1597 // and the shader reads the distance outside it as the
1598 // plate's depth. Nominal corner radii, as a Bevel's. A carve
1599 // host over `rect`, like any filled plate.
1600 let color = material.fill(PlateRole::Nested);
1601 let mat = material.finish.to_array();
1602 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
1603 let mut p = plate_push_raised(hole, *hole_radii, *depth, scale, plate_light, mat, false, None);
1604 let [fz, fw] = material.frost.pack(scale);
1605 p.host[2] = fz;
1606 p.host[3] = fw;
1607 p.mode = 17.0; // MODE_FRAME
1608 plate = Some(p);
1609 made_plate = Some(*rect);
1610 }
1611 Prim::Plate { rect, radii, material, depth, shape } if shader_plates => {
1612 let color = material.fill(PlateRole::Nested);
1613 let mat = material.finish.to_array();
1614 if *depth < 0.0 {
1615 // Negative depth = fill-less roll overlay (MODE_ROLL): the
1616 // window-edge roll shading alone, screened over whatever is
1617 // beneath — for a root plate whose face is not a fill (the
1618 // designer's 3D canvas). The cover quad carries no color,
1619 // and the batch is NOT opened as a carve host: an overlay
1620 // owns no surface for a CSG feature to cut into.
1621 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
1622 let mut p = plate_push_raised(rect, *radii, -*depth, scale, plate_light, mat, true, *shape);
1623 p.mode = 11.0; // MODE_ROLL
1624 plate = Some(p);
1625 } else {
1626 // Same lit-plate branch; the cover quad is the exact rect so the
1627 // silhouette and the compositor's rounded window corners agree.
1628 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
1629 let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, true, *shape);
1630 let [fz, fw] = material.frost.pack(scale);
1631 p.host[2] = fz;
1632 p.host[3] = fw;
1633 plate = Some(p);
1634 made_plate = Some(*rect);
1635 }
1636 }
1637 // A sunken well ending in a flush run (see `Prim::Field`): one
1638 // outline, one overlay — never grouped, its profile is not a
1639 // monotonic step. The cover quad inflates by half the wall, as a
1640 // free carve's does; the host-box slot carries the run's two
1641 // ends (physical px), since nothing fades against a host here.
1642 Prim::Field { rect, radii, depth, split, end, tint } if shader_plates => {
1643 let infl = *depth * 0.5 + 2.0;
1644 verts.extend(quad_vertices(
1645 rect.x - infl, rect.y - infl,
1646 rect.width + 2.0 * infl, rect.height + 2.0 * infl,
1647 sw, sh, [0.0; 4],
1648 ));
1649 let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
1650 p.mode = 16.0; // MODE_FIELD
1651 if let Some(t) = tint {
1652 p.specular_tint = [t[0], t[1], t[2], 1.0];
1653 }
1654 p.host = [*split * scale, *end * scale, 0.0, 0.0];
1655 plate = Some(p);
1656 }
1657 Prim::Recess { rect, radii, depth, edges, .. }
1658 | Prim::Boss { rect, radii, depth, edges, .. }
1659 | Prim::Ridge { rect, radii, depth, edges }
1660 | Prim::Trough { rect, radii, depth, edges, .. }
1661 if shader_plates =>
1662 {
1663 let tint = match &item.prim {
1664 Prim::Recess { tint, .. } => *tint,
1665 Prim::Boss { tint, .. } => *tint,
1666 Prim::Trough { tint, .. } => *tint,
1667 _ => None,
1668 };
1669 // Recess carves down into the surface; Boss raises a plateau out
1670 // of it (same machinery, depth sign flipped); Ridge is a raised
1671 // rim straddling the boundary and Trough the sunken valley twin
1672 // (their own overlay profiles — never grouped, the CSG features
1673 // only model monotonic steps).
1674 let mode = match &item.prim {
1675 Prim::Boss { .. } => 3.0f32,
1676 Prim::Ridge { .. } => 4.0,
1677 Prim::Trough { .. } => 9.0,
1678 _ => 2.0,
1679 };
1680 let raised = mode > 2.5;
1681 // Grouped into the enclosing plate whenever one is live: the
1682 // carve becomes a CSG feature of that plate's single draw —
1683 // exact composite shading, real junctions at the plate's rolled
1684 // perimeter — instead of a shading overlay (the fallback below).
1685 //
1686 // Edge-suppressed carves NEVER group: a suppressed wall's rect
1687 // extends past the carve (below), relying on the overlay cover
1688 // quad to keep that shading out of the drawn pixels — a clip
1689 // the plate's whole-surface draw does not have, so grouped it
1690 // smears the extended walls across the plate. Union pieces
1691 // (section wells, a spinbox's field and button run) are
1692 // exactly these.
1693 // A tinted carve also never groups: a CSG feature is geometry only,
1694 // so the tint could only land on the whole plate's specular.
1695 let full_ring = *edges == (true, true, true, true);
1696 let host_plate = if mode < 3.5 && full_ring && tint.is_none() && features.len() < crate::draw::MAX_PLATE_FEATURES {
1697 // The carve's shaded region, for the occlusion test below
1698 // (the overlay path's cover-quad inflation).
1699 let infl = *depth * 0.5 + 2.0;
1700 let (sx0, sy0) = (rect.x - infl, rect.y - infl);
1701 let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
1702 plate_stack
1703 .iter()
1704 .enumerate()
1705 .rev()
1706 .find(|(si, (bi, prect))| {
1707 let inside = rect.x >= prect.x - 0.5
1708 && rect.y >= prect.y - 0.5
1709 && rect.x + rect.width <= prect.x + prect.width + 0.5
1710 && rect.y + rect.height <= prect.y + prect.height + 0.5;
1711 if !inside {
1712 return false;
1713 }
1714 // Pixels drawn since this plate (a LATER plate in the
1715 // stack) must not overlap the carve — its shading would
1716 // land beneath them in this plate's earlier draw.
1717 if plate_stack[si + 1..].iter().any(|(_, orect)| {
1718 sx0 < orect.x + orect.width
1719 && sx1 > orect.x
1720 && sy0 < orect.y + orect.height
1721 && sy1 > orect.y
1722 }) {
1723 return false;
1724 }
1725 // Contiguity: only the last feature-receiving plate (or
1726 // one with no features yet) may take another.
1727 batches[*bi].plate.as_ref().is_some_and(|p| p.host[1] == 0.0)
1728 || last_feature_plate == Some(*bi)
1729 })
1730 .map(|(_, &(bi, _))| bi)
1731 } else {
1732 None
1733 };
1734 // Debug-build loudness for the silent grouped→overlay flip —
1735 // see `near_roll_fallback_reason` on what qualifies and why
1736 // this warns instead of panicking.
1737 #[cfg(debug_assertions)]
1738 if host_plate.is_none() && mode < 3.5 && full_ring && tint.is_none() {
1739 let enclosing = plate_stack.iter().enumerate().rev().find(|(_, (_, p))| {
1740 rect.x >= p.x - 0.5
1741 && rect.y >= p.y - 0.5
1742 && rect.x + rect.width <= p.x + p.width + 0.5
1743 && rect.y + rect.height <= p.y + p.height + 0.5
1744 });
1745 if let Some((si, &(bi, prect))) = enclosing {
1746 // Host roll width rides the push's light.w (physical px).
1747 let roll = batches[bi].plate.as_ref().map_or(0.0, |p| p.light[3]) / scale;
1748 let later: Vec<crate::scene::layout::Rect> =
1749 plate_stack[si + 1..].iter().map(|&(_, r)| r).collect();
1750 let budget_full = features.len() >= crate::draw::MAX_PLATE_FEATURES;
1751 if let Some(why) =
1752 near_roll_fallback_reason(rect, *depth, &prect, roll, &later, budget_full)
1753 {
1754 let kind = if mode > 2.5 { "boss" } else { "recess" };
1755 plate_carve_warn_once(format!(
1756 "plate-carve: near-roll {kind} ({:.0},{:.0} {:.0}x{:.0}) lost grouping — {why}; \
1757 its junction with the host plate's roll shades through the overlay fallback, \
1758 visually different from grouped frames (CCE_PLATE_DEBUG=1 traces verdicts) \
1759 [debug-build warning, printed once]",
1760 rect.x, rect.y, rect.width, rect.height
1761 ));
1762 }
1763 }
1764 }
1765 if dbg_plates {
1766 match host_plate {
1767 Some(_) => dbg_grouped += 1,
1768 None => {
1769 // Re-derive WHY, in the same order the guard tests
1770 // them. Debug-only: the hot path above is untouched.
1771 let kind = match &item.prim {
1772 Prim::Boss { .. } => "boss",
1773 Prim::Ridge { .. } => "ridge",
1774 Prim::Trough { .. } => "trough",
1775 _ => "recess",
1776 };
1777 let infl = *depth * 0.5 + 2.0;
1778 let (sx0, sy0) = (rect.x - infl, rect.y - infl);
1779 let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
1780 let enclosing: Vec<usize> = plate_stack
1781 .iter()
1782 .enumerate()
1783 .filter(|(_, (_, p))| {
1784 rect.x >= p.x - 0.5
1785 && rect.y >= p.y - 0.5
1786 && rect.x + rect.width <= p.x + p.width + 0.5
1787 && rect.y + rect.height <= p.y + p.height + 0.5
1788 })
1789 .map(|(si, _)| si)
1790 .collect();
1791 let occluded = |si: usize| {
1792 plate_stack[si + 1..].iter().any(|(_, o)| {
1793 sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y
1794 })
1795 };
1796 let why = if mode >= 3.5 {
1797 "ridge — never groups (its bump profile is not a monotonic step)".into()
1798 } else if !full_ring {
1799 format!("edge-suppressed {edges:?} — the extended wall would smear across the host")
1800 } else if tint.is_some() {
1801 "tinted — a CSG feature is geometry only, it carries no color".into()
1802 } else if features.len() >= crate::draw::MAX_PLATE_FEATURES {
1803 format!("feature budget full ({} used)", features.len())
1804 } else if enclosing.is_empty() {
1805 format!("no enclosing plate ({} open)", plate_stack.len())
1806 } else if enclosing.iter().all(|&si| occluded(si)) {
1807 "a later plate overlaps this carve's shaded region".into()
1808 } else {
1809 "host plate's feature run is closed (another carve appended since)".into()
1810 };
1811 dbg_fell_back.push(format!(
1812 " overlay: {kind} ({:.0},{:.0} {:.0}x{:.0}) — {why}",
1813 rect.x, rect.y, rect.width, rect.height
1814 ));
1815 }
1816 }
1817 }
1818 if let Some(bi) = host_plate {
1819 {
1820 // A wall the carve shares with the plate's edge extends
1821 // past the plate, so the carve has no wall there.
1822 let ext = *depth + 4.0;
1823 let (mut x0, mut y0) = (rect.x, rect.y);
1824 let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
1825 if !edges.0 { y0 -= ext; }
1826 if !edges.1 { x1 += ext; }
1827 if !edges.2 { y1 += ext; }
1828 if !edges.3 { x0 -= ext; }
1829 let t_px = *depth * scale;
1830 // The carve's drop: the material's pinned height, else
1831 // the analytic ratio of the wall saturating at the DE's
1832 // roll width (`layout::carve_depth_px` states the rule
1833 // once for this path and the shader's free carves).
1834 let k_mag = crate::layout::carve_depth_px(*depth) * scale;
1835 // Negative depth = raised (Boss); the shader's summed
1836 // slope vectors and curvature sign follow it.
1837 let k_px = if raised { -k_mag } else { k_mag };
1838 if let Some(p) = batches[bi].plate.as_mut() {
1839 if p.host[1] == 0.0 {
1840 p.host[0] = features.len() as f32;
1841 }
1842 p.host[1] += 1.0;
1843 }
1844 last_feature_plate = Some(bi);
1845 features.push([
1846 (x0 + x1) * 0.5 * scale,
1847 (y0 + y1) * 0.5 * scale,
1848 (x1 - x0) * 0.5 * scale,
1849 (y1 - y0) * 0.5 * scale,
1850 radii.0 * scale,
1851 radii.1 * scale,
1852 radii.2 * scale,
1853 radii.3 * scale,
1854 t_px,
1855 k_px,
1856 0.0,
1857 0.0,
1858 ]);
1859 continue;
1860 }
1861 }
1862 // Overlay-only carve: the cover quad inflates by half the roll
1863 // width (the step straddles the boundary) and carries no color —
1864 // the shader emits translucent white/black over what's beneath.
1865 let infl = *depth * 0.5 + 2.0;
1866 verts.extend(quad_vertices(
1867 rect.x - infl, rect.y - infl,
1868 rect.width + 2.0 * infl, rect.height + 2.0 * infl,
1869 sw, sh, [0.0; 4],
1870 ));
1871 // A suppressed wall is pushed past the cover quad, so its
1872 // shading falls outside the drawn pixels (see Prim::Recess on
1873 // why a flush region is a step, not a trough).
1874 let ext = *depth + 4.0;
1875 let (mut x0, mut y0) = (rect.x, rect.y);
1876 let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
1877 if !edges.0 { y0 -= ext; }
1878 if !edges.1 { x1 += ext; }
1879 if !edges.2 { y1 += ext; }
1880 if !edges.3 { x0 -= ext; }
1881 let sdf_rect = crate::scene::layout::Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
1882 let mut p = plate_push_raised(&sdf_rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
1883 p.mode = mode;
1884 // w = 1.0 flags the free-carve shader path to composite its
1885 // light in the tint and its shadow in a dark tint instead of
1886 // white and black (plates leave w at 0.0).
1887 if let Some(t) = tint {
1888 p.specular_tint = [t[0], t[1], t[2], 1.0];
1889 }
1890 // Host-plate box for the roll fade: a suppressed wall means the
1891 // recess runs flush to the host's edge there, so that side of
1892 // the box sits at the original rect edge; enabled walls face
1893 // host interior, pushed to ±1e5 so no fade applies.
1894 const FAR: f32 = 1e5;
1895 let (hx0, hy0) = (
1896 if edges.3 { rect.x - FAR } else { rect.x },
1897 if edges.0 { rect.y - FAR } else { rect.y },
1898 );
1899 let (hx1, hy1) = (
1900 if edges.1 { rect.x + rect.width + FAR } else { rect.x + rect.width },
1901 if edges.2 { rect.y + rect.height + FAR } else { rect.y + rect.height },
1902 );
1903 p.host = [
1904 (hx0 + hx1) * 0.5 * scale,
1905 (hy0 + hy1) * 0.5 * scale,
1906 (hx1 - hx0) * 0.5 * scale,
1907 (hy1 - hy0) * 0.5 * scale,
1908 ];
1909 plate = Some(p);
1910 }
1911 Prim::Bevel { rect, radii, material, depth, tint: _ } => {
1912 let color = material.fill(PlateRole::Nested);
1913 // Full-size fill: the lip is now a shading overlay, not a paint of the
1914 // outer ring, so the fill must cover the whole rect (the old inset fill
1915 // would leave the ring showing whatever lay beneath).
1916 let corners = crate::widget::CornerRadii {
1917 top_left: radii.0, top_right: radii.1,
1918 bottom_right: radii.2, bottom_left: radii.3,
1919 };
1920 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts);
1921 push_plate_bevel_vertices(rect.x, rect.y, rect.width, rect.height, radii.0, *depth, sw, sh, color, no, &mut verts);
1922 }
1923 // The legacy banded path has no inside-out SDF: the face square,
1924 // and only below the hole, so the coves' rows are left to what
1925 // is beneath (A/B comparison path only).
1926 Prim::Frame { rect, hole, material, .. } => {
1927 let color = material.fill(PlateRole::Nested);
1928 let y0 = rect.y.max(hole.y + hole.height);
1929 let y1 = rect.y + rect.height;
1930 if y1 > y0 {
1931 verts.extend(quad_vertices(rect.x, y0, rect.width, y1 - y0, sw, sh, color));
1932 }
1933 }
1934 Prim::Plate { rect, radii, material, depth, .. } => {
1935 let color = material.fill(PlateRole::Nested);
1936 if *depth < 0.0 {
1937 // Fill-less roll overlay (negative-depth sentinel): the banded
1938 // legacy tessellation has no overlay compositing, so the roll
1939 // is simply absent here — the A/B path draws nothing rather
1940 // than a wrong fill.
1941 continue;
1942 }
1943 // Fill at full size (no inset — see Prim::Plate), then light the face,
1944 // then roll the perimeter. The lip rides on top of the fill's outer band
1945 // rather than replacing it, so the plate's silhouette and the
1946 // compositor's rounded window corners still agree exactly.
1947 let corners = crate::widget::CornerRadii {
1948 top_left: radii.0, top_right: radii.1,
1949 bottom_right: radii.2, bottom_left: radii.3,
1950 };
1951 push_rounded_rect_vertices_corners(
1952 rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts,
1953 );
1954 push_plate_face_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, no, &mut verts);
1955 push_bevel_edge_vertices_radii(
1956 rect.x, rect.y, rect.width, rect.height, *radii, *depth,
1957 sw, sh, color, no, 1.0, &mut verts,
1958 );
1959 }
1960 Prim::Recess { rect, radii, depth, edges, .. } => {
1961 // Edges only — no fill: the shading is an overlay, so whatever is painted
1962 // below (fill, rim gradient, blur) shows through the carve modulated
1963 // rather than repainted. `light_sign = -1.0` shadows the lit-facing edges,
1964 // which is the raised->recessed inversion.
1965 push_bevel_edge_vertices_banded(
1966 rect.x, rect.y, rect.width, rect.height, *radii, *depth,
1967 sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), *edges,
1968 EdgeKind::Step, &mut verts,
1969 );
1970 }
1971 Prim::Boss { rect, radii, depth, edges, .. } => {
1972 // Legacy raised step: the recess overlay with the light sign upright.
1973 push_bevel_edge_vertices_banded(
1974 rect.x, rect.y, rect.width, rect.height, *radii, *depth,
1975 sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(*depth), *edges,
1976 EdgeKind::Step, &mut verts,
1977 );
1978 }
1979 Prim::Ridge { rect, radii, depth, edges } => {
1980 // Legacy approximation: a raised step up at the boundary plus a
1981 // recessed step down half a width in (the banded machinery has no
1982 // bump profile; the double-pass hot crest is accepted here — the
1983 // legacy path exists only for A/B comparison).
1984 let half = *depth * 0.5;
1985 push_bevel_edge_vertices_banded(
1986 rect.x, rect.y, rect.width, rect.height, *radii, half,
1987 sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
1988 EdgeKind::Step, &mut verts,
1989 );
1990 let ir = (radii.0 - half).max(0.0);
1991 push_bevel_edge_vertices_banded(
1992 rect.x + half, rect.y + half,
1993 rect.width - *depth, rect.height - *depth,
1994 (ir, ir, ir, ir), half,
1995 sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
1996 EdgeKind::Step, &mut verts,
1997 );
1998 }
1999 Prim::Trough { rect, radii, depth, edges, .. } => {
2000 // Legacy approximation, the Ridge arm's two steps with the light
2001 // signs swapped: down at the boundary, back up half a width in.
2002 // The banded machinery has no valley profile, so this is the old
2003 // stacked look — accepted here, as the legacy path exists only
2004 // for A/B comparison against the SDF one.
2005 let half = *depth * 0.5;
2006 push_bevel_edge_vertices_banded(
2007 rect.x, rect.y, rect.width, rect.height, *radii, half,
2008 sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
2009 EdgeKind::Step, &mut verts,
2010 );
2011 let ir = (radii.0 - half).max(0.0);
2012 push_bevel_edge_vertices_banded(
2013 rect.x + half, rect.y + half,
2014 rect.width - *depth, rect.height - *depth,
2015 (ir, ir, ir, ir), half,
2016 sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
2017 EdgeKind::Step, &mut verts,
2018 );
2019 }
2020 Prim::Field { rect, radii, depth, split, end, .. } => {
2021 // Legacy approximation: the two-box form `Prim::Field`
2022 // replaced — a well either side of the run a step down, the
2023 // run the Trough arm's down-then-up stack. The banded
2024 // machinery has no blended outline, and the legacy path
2025 // exists only for A/B comparison.
2026 let all = (true, true, true, true);
2027 let (fl, fr) = (rect.x, rect.x + rect.width);
2028 let (well_l, well_r) = (*split > fl, *end < fr);
2029 let rx = split.max(fl);
2030 let rw = (end.min(fr) - rx).max(0.0);
2031 if well_l {
2032 push_bevel_edge_vertices_banded(
2033 fl, rect.y, rx - fl, rect.height, (radii.0, 0.0, 0.0, radii.3), *depth,
2034 sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
2035 EdgeKind::Step, &mut verts,
2036 );
2037 }
2038 if well_r {
2039 push_bevel_edge_vertices_banded(
2040 rx + rw, rect.y, fr - rx - rw, rect.height, (0.0, radii.1, radii.2, 0.0), *depth,
2041 sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
2042 EdgeKind::Step, &mut verts,
2043 );
2044 }
2045 // A run's own corners where it reaches the outline; square at a seam.
2046 let (l0, l3) = if well_l { (0.0, 0.0) } else { (radii.0, radii.3) };
2047 let (r1, r2) = if well_r { (0.0, 0.0) } else { (radii.1, radii.2) };
2048 let half = *depth * 0.5;
2049 push_bevel_edge_vertices_banded(
2050 rx, rect.y, rw, rect.height, (l0, r1, r2, l3), half,
2051 sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), all,
2052 EdgeKind::Step, &mut verts,
2053 );
2054 let ir = if well_r { 0.0 } else { (radii.1 - half).max(0.0) };
2055 let il = if well_l { 0.0 } else { (radii.0 - half).max(0.0) };
2056 push_bevel_edge_vertices_banded(
2057 rx + half, rect.y + half, rw - *depth, rect.height - *depth, (il, ir, ir, il), half,
2058 sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), all,
2059 EdgeKind::Step, &mut verts,
2060 );
2061 }
2062 Prim::Arc { cx, cy, radius, thickness, start: sa, end: ea, color } => {
2063 push_arc_background_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *color, segs(*radius), no, &mut verts);
2064 }
2065 Prim::ArcShaded { cx, cy, radius, thickness, start: sa, end: ea, inner, crest, outer } => {
2066 push_arc_shaded_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *inner, *crest, *outer, segs(*radius), no, &mut verts);
2067 }
2068 Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => {
2069 let lc = match cap {
2070 Cap::Flat => LineCap::Flat,
2071 Cap::Round => LineCap::Round,
2072 Cap::Arrow => LineCap::Arrow,
2073 };
2074 verts.extend(vector_vertices(*x1, *y1, *x2, *y2, *thickness, sw, sh, *color, lc));
2075 }
2076 Prim::Circle { cx, cy, radius, color } => {
2077 if item.clip_circle.is_none() && *radius > 1.5 {
2078 // Cover quad with the disc itself as the (feathered) circle
2079 // clip: a per-pixel smooth silhouette instead of a hard-edged
2080 // fan. The quad overhangs by 1px for the feather. Only when
2081 // no ancestor clip holds the slot — then it's the fan path.
2082 let own = [cx * scale, cy * scale, radius * scale];
2083 let d = *radius + 1.0;
2084 verts.extend(quad_vertices_with_clip(
2085 cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, *color, own,
2086 ));
2087 } else {
2088 verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
2089 }
2090 }
2091 Prim::Sphere { cx, cy, radius, material } if shader_plates => {
2092 let color = material.fill(PlateRole::Nested);
2093 let mat = material.finish.to_array();
2094 // A hemisphere lit per pixel by the plate branch (mode 5): one
2095 // cover quad, its own never-merged batch. The quad overhangs
2096 // the disc by 1px for the shader's silhouette anti-aliasing.
2097 let d = *radius + 1.0;
2098 verts.extend(quad_vertices(cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, color));
2099 plate = Some(crate::draw::PlatePush {
2100 // Center + radius in physical px; the SDF box machinery is
2101 // unused in this mode, so .w is free.
2102 rect: [cx * scale, cy * scale, radius * scale, 0.0],
2103 radii: [0.0; 4],
2104 light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
2105 material: mat,
2106 host: [0.0; 4],
2107 specular_tint: [1.0, 1.0, 1.0, 0.0],
2108 mode: 5.0,
2109 shape: 2.0,
2110 });
2111 }
2112 Prim::Sphere { cx, cy, radius, material } => {
2113 let color = material.fill(PlateRole::Nested);
2114 // Legacy path: the flat disc, exactly a Circle.
2115 verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, color, segs(*radius), no));
2116 }
2117 Prim::DropletScrim { rect, material, spec, feather } if shader_plates => {
2118 let color = material.fill(PlateRole::Nested);
2119 let mat = material.finish.to_array();
2120 // Shader mode 12: the droplet's own SDF, filled flat and
2121 // feathered inward. No contact shadow, so unlike the lit drop
2122 // the cover quad is exactly the box — a scrim never draws
2123 // outside the silhouette.
2124 let g = droplet_geom(rect, spec);
2125 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
2126 plate = Some(crate::draw::PlatePush {
2127 rect: [
2128 (rect.x + rect.width * 0.5) * scale,
2129 (rect.y + rect.height * 0.5) * scale,
2130 g.hx * scale,
2131 g.hy * scale,
2132 ],
2133 radii: [g.sag * scale, g.br * scale, g.bw * scale, g.k * scale],
2134 // p_light.w carries the FEATHER here; mode 12 returns
2135 // before the shading band it otherwise holds is read.
2136 light: [plate_light[0], plate_light[1], plate_light[2], feather.max(0.001) * scale],
2137 material: [mat[0], 0.0, 0.0, 0.0],
2138 host: [g.sr * scale, 0.0, 0.0, g.ar * scale],
2139 specular_tint: [0.0, 0.0, 0.0, g.bow * scale],
2140 mode: 12.0,
2141 shape: spec.curve.clamp(2.0, 6.0),
2142 });
2143 }
2144 Prim::Droplet { rect, material, spec } if shader_plates => {
2145 let color = material.fill(PlateRole::Nested);
2146 let mat = material.finish.to_array();
2147 // A water droplet lit by shader mode 10: one cover quad; the
2148 // shader owns silhouette (sheet ∪smin belly), dome shading,
2149 // fresnel rim and thin-edge clarity. The spec's height
2150 // fractions resolve against the concrete rect here, clamped so
2151 // small or narrow boxes stay well-formed (a belly wider than
2152 // the box would turn the SDF interior inside out).
2153 // The cover quad grows sideways and BELOW the box by the
2154 // contact shadow's reach — shadow fragments live outside the
2155 // silhouette, so they need covered pixels to shade.
2156 let g = droplet_geom(rect, spec);
2157 let (hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach) =
2158 (g.hx, g.hy, g.sag, g.br, g.bw, g.k, g.sr, g.ar, g.band, g.bow, g.sh_reach);
2159 verts.extend(quad_vertices(
2160 rect.x - sh_reach,
2161 rect.y,
2162 rect.width + 2.0 * sh_reach,
2163 rect.height + sh_reach,
2164 sw, sh, color,
2165 ));
2166 plate = Some(crate::draw::PlatePush {
2167 rect: [
2168 (rect.x + rect.width * 0.5) * scale,
2169 (rect.y + rect.height * 0.5) * scale,
2170 hx * scale,
2171 hy * scale,
2172 ],
2173 radii: [sag * scale, br * scale, bw * scale, k * scale],
2174 light: [plate_light[0], plate_light[1], plate_light[2], band * scale],
2175 // Slots y/z/w feed roll_spec and the rim term directly:
2176 // a droplet's material carries its own gleam/shine/rim
2177 // there (`DropletSpec::finish`; a drop is wetter than the
2178 // DE's plates), so this is the material's finish like any
2179 // plate's.
2180 material: mat,
2181 host: [sr * scale, spec.clarity.clamp(0.0, 1.0), spec.dome, ar * scale],
2182 // Droplet glints are always white, so the tint RGB slots
2183 // carry droplet params instead: x = core density,
2184 // y = contact-shadow reach px, z = shadow strength.
2185 specular_tint: [
2186 spec.core.clamp(0.0, 2.0),
2187 sh_reach * scale,
2188 spec.shadow.clamp(0.0, 1.0),
2189 bow * scale,
2190 ],
2191 mode: 10.0,
2192 shape: spec.curve.clamp(2.0, 6.0),
2193 });
2194 }
2195 Prim::DropletScrim { rect, material, spec, .. } => {
2196 let color = material.fill(PlateRole::Nested);
2197 // Legacy banded path: no SDF to feather against, so the scrim
2198 // degrades to the same flat outline the drop itself does —
2199 // hard-edged, but present. A prim with no arm here VANISHES.
2200 let cap = (rect.height * 0.5).min(rect.width * 0.5);
2201 let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
2202 let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
2203 let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
2204 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
2205 }
2206 Prim::Droplet { rect, material, spec } => {
2207 let color = material.fill(PlateRole::Nested);
2208 // Legacy banded path: the flat drop outline — attach-tapered
2209 // top, round bottom. Degrades the material but keeps the
2210 // silhouette (a prim with no arm here VANISHES, it doesn't
2211 // degrade — see Ridge/Groove above).
2212 let cap = (rect.height * 0.5).min(rect.width * 0.5);
2213 let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
2214 let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
2215 let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
2216 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
2217 }
2218 Prim::ConcaveFillet { cx, cy, radius, depth, start: a0, raised } if shader_plates => {
2219 // A quarter-arc carve wall (shader mode 6/7): one cover quad
2220 // over the wedge's reach; the wall straddles the arc by ±t/2
2221 // like every carve boundary. p_rect carries centre + radius,
2222 // p_radii.x the wedge start angle. Host box pushed far out —
2223 // an inside-corner fillet never fades.
2224 let m = *depth * 0.5 + 2.0;
2225 let r = *radius + m;
2226 verts.extend(quad_vertices(cx - r, cy - r, 2.0 * r, 2.0 * r, sw, sh, [0.0; 4]));
2227 plate = Some(crate::draw::PlatePush {
2228 rect: [cx * scale, cy * scale, *radius * scale, 0.0],
2229 radii: [*a0, 0.0, 0.0, 0.0],
2230 light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
2231 material: plate_mat,
2232 host: [0.0, 0.0, 1e6, 1e6],
2233 specular_tint: [1.0, 1.0, 1.0, 0.0],
2234 mode: if *raised { 7.0 } else { 6.0 },
2235 shape: crate::layout::corner_shape(),
2236 });
2237 }
2238 // Legacy banded path has no radial wall — the composed corner
2239 // stays square there (A/B comparison path only).
2240 Prim::ConcaveFillet { .. } => {}
2241 Prim::Groove { a, b, width, depth, host, strength } if shader_plates => {
2242 // A slab carve about the line a–b (shader mode 8): the cover
2243 // quad is the segment's bounding box grown by the groove's own
2244 // half-width plus the wall's reach. Off-band corners of that
2245 // box sit at u = 1 (plateau), so the box overhang shades
2246 // nothing — the slab is what bounds the mark, not the quad.
2247 let m = *width * 0.5 + *depth * 0.5 + 2.0;
2248 let (x0, x1) = (a.0.min(b.0) - m, a.0.max(b.0) + m);
2249 let (y0, y1) = (a.1.min(b.1) - m, a.1.max(b.1) + m);
2250 verts.extend(quad_vertices(x0, y0, x1 - x0, y1 - y0, sw, sh, [0.0; 4]));
2251 // Unit normal of the line — the direction the slab's distance is
2252 // measured along. A degenerate segment falls back to vertical so
2253 // a zero-length groove is a no-op wall rather than a NaN.
2254 let (dx, dy) = (b.0 - a.0, b.1 - a.1);
2255 let len = (dx * dx + dy * dy).sqrt();
2256 let n = if len > 1e-4 { (-dy / len, dx / len) } else { (1.0, 0.0) };
2257 plate = Some(crate::draw::PlatePush {
2258 // Centre + slab half-width in physical px; .w unused.
2259 rect: [
2260 (a.0 + b.0) * 0.5 * scale,
2261 (a.1 + b.1) * 0.5 * scale,
2262 *width * 0.5 * scale,
2263 0.0,
2264 ],
2265 radii: [n.0, n.1, 0.0, 0.0],
2266 light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
2267 // The finish's shading, specular and AO, at the groove's
2268 // strength; shininess is a shape, not an amount.
2269 material: {
2270 let s = strength.clamp(0.0, 1.0);
2271 [plate_mat[0] * s, plate_mat[1] * s, plate_mat[2], plate_mat[3] * s]
2272 },
2273 host: [
2274 (host.x + host.width * 0.5) * scale,
2275 (host.y + host.height * 0.5) * scale,
2276 host.width * 0.5 * scale,
2277 host.height * 0.5 * scale,
2278 ],
2279 specular_tint: [1.0, 1.0, 1.0, 0.0],
2280 mode: 8.0,
2281 shape: crate::layout::corner_shape(),
2282 });
2283 }
2284 Prim::Groove { a, b, width, depth, host: _, strength } => {
2285 // Legacy approximation. The banded tessellators walk BOX edges —
2286 // exactly the axis-aligned assumption a groove exists to escape —
2287 // so the walls are drawn directly as two feathered lines meeting
2288 // at the centerline: the engraved-line fake, one half in shadow
2289 // and one lit. Coarser than the SDF (no profile curve, no host
2290 // fade), but this path exists for A/B comparison, and drawing
2291 // NOTHING would silently delete the mark rather than degrade it
2292 // — see `Prim::Ridge` above, which accepts a hot crest for the
2293 // same reason.
2294 let (dx, dy) = (b.0 - a.0, b.1 - a.1);
2295 let len = (dx * dx + dy * dy).sqrt();
2296 if len < 0.001 {
2297 continue;
2298 }
2299 let n = (-dy / len, dx / len);
2300 // Same convention as `push_bevel_edge_vertices_banded`: the
2301 // light folded through `light_sign` (-1.0 — a groove is a
2302 // carve), dotted with each wall's OUTWARD normal, amplitude on
2303 // `bevel_depth`. So a groove re-lights with the DE's light
2304 // instead of hardcoding which side is dark.
2305 let rad = crate::layout::light_source_position();
2306 let (lx, ly) = (-rad.cos(), rad.sin());
2307 let v = crate::layout::bevel_depth() * (n.0 * lx + n.1 * ly);
2308 // Each wall covers its own half, centreline to outer edge —
2309 // abutting rather than overlapping. The SDF gets away with
2310 // walls that overlap across a sub-pixel floor because it is one
2311 // evaluation of |distance|; two opposite-signed overlays would
2312 // just blend to mud.
2313 let half = (*width * 0.5 + *depth * 0.5).max(0.5);
2314 for side in [1.0f32, -1.0] {
2315 let sv = v * side;
2316 let mut c = if sv >= 0.0 { overlay_light(sv) } else { overlay_dark(sv) };
2317 c[3] *= strength.clamp(0.0, 1.0);
2318 if c[3] <= 0.0 {
2319 continue;
2320 }
2321 let off = side * half * 0.5;
2322 push_feathered_line_vertices(
2323 a.0 + n.0 * off, a.1 + n.1 * off,
2324 b.0 + n.0 * off, b.1 + n.1 * off,
2325 half, sw, sh, c, &mut verts,
2326 );
2327 }
2328 }
2329 Prim::Lattice { rect, period, origin, cell, radius, depth } if shader_plates => {
2330 // A periodic well field (shader mode 13): one cover quad over
2331 // `rect`; the shader folds each pixel into the period and
2332 // measures the nearest cell, so the whole lattice is a single
2333 // evaluation. p_rect = one cell's centre + half-extents,
2334 // p_radii = the corner radius, p_host.xy = the period; the
2335 // host-box fade sides are pushed far out (a lattice never
2336 // fades against a host — its own rect bounds it).
2337 let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
2338 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
2339 plate = Some(crate::draw::PlatePush {
2340 rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
2341 radii: [*radius * scale; 4],
2342 light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
2343 material: plate_mat,
2344 host: [pw * scale, ph * scale, 1e6, 1e6],
2345 specular_tint: [1.0, 1.0, 1.0, 0.0],
2346 mode: 13.0,
2347 shape: crate::layout::corner_shape(),
2348 });
2349 }
2350 Prim::Grout { rect, period, origin, cell, radius, color } if shader_plates => {
2351 // The lattice's fold, painted flat (shader mode 15): one cover
2352 // quad in the grout colour; the shader keeps it outside the
2353 // cells. Same push layout as the lattice; light/material are
2354 // carried but unread.
2355 let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
2356 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
2357 plate = Some(crate::draw::PlatePush {
2358 rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
2359 radii: [*radius * scale; 4],
2360 light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
2361 material: plate_mat,
2362 host: [pw * scale, ph * scale, 1e6, 1e6],
2363 specular_tint: [1.0, 1.0, 1.0, 0.0],
2364 mode: 15.0,
2365 shape: crate::layout::corner_shape(),
2366 });
2367 }
2368 // Legacy banded path: no periodic wall — the lattice and the grout
2369 // draw nothing there, like the fillet (A/B comparison path only).
2370 Prim::Lattice { .. } | Prim::Grout { .. } => {}
2371 Prim::CarveUnion { boxes, depth, raised } if shader_plates => {
2372 // The union of several boxes as ONE wall (shader mode 14): the
2373 // boxes go into the frame's feature buffer as a contiguous run
2374 // and the shader takes the nearest one per pixel. The cover
2375 // quad is the union's bounding box grown by the wall's reach;
2376 // off-shape corners of it sit at the plateau and shade nothing.
2377 let budget = crate::draw::MAX_PLATE_FEATURES.saturating_sub(features.len());
2378 let take = boxes.len().min(budget);
2379 if take < boxes.len() && plate_debug() {
2380 eprintln!(
2381 "plate-carve: union of {} boxes gets {} — feature budget full ({} used)",
2382 boxes.len(), take, features.len()
2383 );
2384 }
2385 if take == 0 {
2386 continue;
2387 }
2388 let kept = &boxes[..take];
2389 let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
2390 for (r, _) in kept {
2391 x0 = x0.min(r.x);
2392 y0 = y0.min(r.y);
2393 x1 = x1.max(r.x + r.width);
2394 y1 = y1.max(r.y + r.height);
2395 }
2396 let infl = *depth * 0.5 + 2.0;
2397 verts.extend(quad_vertices(
2398 x0 - infl, y0 - infl,
2399 (x1 - x0) + 2.0 * infl, (y1 - y0) + 2.0 * infl,
2400 sw, sh, [0.0; 4],
2401 ));
2402 let off = features.len() as f32;
2403 for (r, radii) in kept {
2404 features.push([
2405 (r.x + r.width * 0.5) * scale,
2406 (r.y + r.height * 0.5) * scale,
2407 r.width * 0.5 * scale,
2408 r.height * 0.5 * scale,
2409 radii.0 * scale,
2410 radii.1 * scale,
2411 radii.2 * scale,
2412 radii.3 * scale,
2413 *depth * scale,
2414 0.0,
2415 0.0,
2416 0.0,
2417 ]);
2418 }
2419 // The run is complete: a plate with an open feature run must
2420 // not append past it (its features would no longer be
2421 // contiguous), so it is closed here like any other appender.
2422 last_feature_plate = None;
2423 plate = Some(crate::draw::PlatePush {
2424 rect: [
2425 (x0 + x1) * 0.5 * scale,
2426 (y0 + y1) * 0.5 * scale,
2427 (x1 - x0) * 0.5 * scale,
2428 (y1 - y0) * 0.5 * scale,
2429 ],
2430 // x: the raised flag; the shader reads nothing else here.
2431 radii: [if *raised { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0],
2432 light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
2433 material: plate_mat,
2434 // Feature run [offset, count] (the renderer rebases the
2435 // offset onto the frame slot, as for mode 1); zw far out
2436 // so the host-box fade never applies.
2437 host: [off, take as f32, 1e6, 1e6],
2438 specular_tint: [1.0, 1.0, 1.0, 0.0],
2439 mode: 14.0,
2440 shape: crate::layout::corner_shape(),
2441 });
2442 }
2443 // Legacy banded path: no union — nothing is drawn there, like the
2444 // fillet and the lattice (A/B comparison path only).
2445 Prim::CarveUnion { .. } => {}
2446 }
2447 let end = verts.len() as u32;
2448 if end == start {
2449 continue;
2450 }
2451 // Some tessellators (quad_vertices, vector_vertices) don't thread the circle clip —
2452 // stamp the whole emitted range so every prim kind honors it uniformly.
2453 if item.clip_circle.is_some() {
2454 for v in verts[start as usize..].iter_mut() {
2455 v.clip_circle = no;
2456 }
2457 }
2458 // Merge into the previous batch if it shares this clip pair and is contiguous.
2459 // Plate batches carry per-draw push constants, and blur-behind batches
2460 // trigger the renderer's snapshot copy, so neither ever merges.
2461 if plate.is_none() && !blur_behind {
2462 // Ordinary geometry painted after a plate ends its carve-grouping
2463 // window: a recess emitted later must overlay this geometry (the
2464 // fallback path), not shade beneath it inside the plate's draw.
2465 if dbg_plates && !plate_stack.is_empty() {
2466 *dbg_closed_by.entry(prim_kind(&item.prim)).or_insert(0) += plate_stack.len();
2467 }
2468 plate_stack.clear();
2469 if let Some(last) = batches.last_mut() {
2470 if last.plate.is_none()
2471 && last.scissor == item.clip
2472 && last.clip_rrect == item.clip_rrect
2473 && last.end == start
2474 {
2475 last.end = end;
2476 continue;
2477 }
2478 }
2479 }
2480 if promoted {
2481 plate_stack.clear();
2482 }
2483 batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate, blur_behind });
2484 if let Some(prect) = made_plate {
2485 plate_stack.push((batches.len() - 1, prect));
2486 if dbg_plates {
2487 dbg_opened += 1;
2488 }
2489 }
2490 }
2491
2492 if dbg_plates && (dbg_grouped > 0 || !dbg_fell_back.is_empty()) {
2493 eprintln!(
2494 "plate-dbg: {} carves — {dbg_grouped} grouped (exact CSG), {} overlay fallback",
2495 dbg_grouped + dbg_fell_back.len(),
2496 dbg_fell_back.len(),
2497 );
2498 eprintln!(
2499 "plate-dbg: {dbg_opened} grouping window(s) opened by a filled plate; closed early by {}",
2500 if dbg_closed_by.is_empty() {
2501 "nothing".to_string()
2502 } else {
2503 dbg_closed_by
2504 .iter()
2505 .map(|(k, n)| format!("{k}x{n}"))
2506 .collect::<Vec<_>>()
2507 .join(", ")
2508 }
2509 );
2510 for line in &dbg_fell_back {
2511 eprintln!("plate-dbg: {line}");
2512 }
2513 }
2514
2515 (verts, batches, images, features)
2516 }
2517
2518 /// The push-constant block for a raised SDF-lit plate over `rect` (logical px in,
2519 /// physical px out). Corner radii clamp to the half-extent cap the SDF needs.
2520 ///
2521 /// `shape` is a per-plate corner exponent (`Prim::Plate`'s override); `None`
2522 /// follows the DE-wide `layout::corner_shape`. The span factor follows the
2523 /// exponent actually used, so a circular override (2.0) spans nothing and a
2524 /// half-extent radius lands on a true circle.
2525 #[allow(clippy::too_many_arguments)]
2526 /// The push block of a frosted flat fill promoted to a zero-depth plate: a
2527 /// mode-1 plate with no roll (`t` = 0.001, so the face is exactly the fill),
2528 /// corners at the nominal radii in the configured `corner_shape`, and the
2529 /// fill's own frost recipe in `host.zw` (`Material::from_fill` decodes the
2530 /// sentinel).
2531 ///
2532 /// The shape must be `corner_shape`, not a fixed circle: a frosted `Border`
2533 /// draws its stroke as `push_plate_solid_border_vertices` geometry in that
2534 /// shape, and the unfrosted fill fan uses it too. A circular face under a
2535 /// squircle stroke left the stroke cutting inside the face's corners.
2536 fn flat_frost_push(
2537 rect: &crate::scene::layout::Rect,
2538 radii: (f32, f32, f32, f32),
2539 fill: [f32; 4],
2540 scale: f32,
2541 light: [f32; 3],
2542 material: [f32; 4],
2543 ) -> crate::draw::PlatePush {
2544 let mut p = plate_push_raised(rect, radii, 0.0, scale, light, material, false, None);
2545 let [fz, fw] = crate::scene::material::Material::from_fill(fill).frost.pack(scale);
2546 p.host[2] = fz;
2547 p.host[3] = fw;
2548 p
2549 }
2550
2551 fn plate_push_raised(
2552 rect: &crate::scene::layout::Rect,
2553 radii: (f32, f32, f32, f32),
2554 width: f32,
2555 scale: f32,
2556 light: [f32; 3],
2557 material: [f32; 4],
2558 scale_corners: bool,
2559 shape: Option<f32>,
2560 ) -> crate::draw::PlatePush {
2561 // Floored: a rect already shrunk past its padding (a window dragged
2562 // below what its layout can hold) has a NEGATIVE extent here, and
2563 // `clamp(0.0, cap)` with a negative cap is a panic, not a zero radius.
2564 let cap = (rect.width.min(rect.height) * 0.5).max(0.0);
2565 let shape = shape.map_or_else(crate::layout::corner_shape, |n| n.clamp(2.0, 16.0));
2566 // For PLATES (`scale_corners`), widen the corner span by the
2567 // curvature-match factor (see `layout::corner_span_factor`): the diagonal
2568 // curvature radius equals the configured radius, the corner reads as the
2569 // same size as a circular one, and every roll inset ≤ r stays crease-free
2570 // (past the diagonal curvature radius the offset curve the specular band
2571 // follows creases into a visible square corner). Widget-scale overlay
2572 // reliefs (recess/boss/ridge fallbacks) pass false: their radii must MATCH
2573 // the nominal-radius squircles of the widget silhouettes around them, and
2574 // at their few-px roll widths the offset crease is subpixel.
2575 let rscale = if scale_corners { crate::layout::corner_span_factor_for(shape) } else { 1.0 };
2576 crate::draw::PlatePush {
2577 rect: [
2578 (rect.x + rect.width * 0.5) * scale,
2579 (rect.y + rect.height * 0.5) * scale,
2580 rect.width * 0.5 * scale,
2581 rect.height * 0.5 * scale,
2582 ],
2583 radii: [
2584 (radii.0 * rscale).clamp(0.0, cap) * scale,
2585 (radii.1 * rscale).clamp(0.0, cap) * scale,
2586 (radii.2 * rscale).clamp(0.0, cap) * scale,
2587 (radii.3 * rscale).clamp(0.0, cap) * scale,
2588 ],
2589 light: [light[0], light[1], light[2], width * scale],
2590 material,
2591 // Mode-1 semantics: [feature offset, feature count] — no carves yet;
2592 // the tessellator fills these in as recesses group into this plate.
2593 host: [0.0, 0.0, 0.0, 0.0],
2594 specular_tint: [1.0, 1.0, 1.0, 0.0],
2595 mode: 1.0,
2596 shape,
2597 }
2598 }
2599
2600 pub fn extra_quad_vertices(
2601 w: &dyn crate::widget::WidgetHost,
2602 qx: f32, qy: f32, qw: f32, qh: f32,
2603 sw: f32, sh: f32,
2604 qc: [f32; 4],
2605 clip_circle: [f32; 3],
2606 ) -> Vec<Vertex> {
2607 let mut verts = Vec::new();
2608 push_extra_quad_vertices(w, qx, qy, qw, qh, sw, sh, qc, clip_circle, &mut verts);
2609 verts
2610 }
2611
2612 fn get_child_widget_for_quad(
2613 w: &dyn crate::widget::WidgetHost,
2614 qx: f32, qy: f32, qw: f32, qh: f32,
2615 ) -> &dyn crate::widget::WidgetHost {
2616 if let Some(pbg) = w.as_any().downcast_ref::<crate::widget::ParametersBg>() {
2617 for s in pbg.sliders.iter().flatten() {
2618 let (sx, sy, sww, shh) = s.rect();
2619 if qx >= sx - 0.1 && qx + qw <= sx + sww + 0.1 && qy >= sy - 0.1 && qy + qh <= sy + shh + 0.1 {
2620 return s;
2621 }
2622 }
2623 for f in pbg.float3s.iter().flatten() {
2624 let (fx, fy, fww, fhh) = f.rect();
2625 if qx >= fx - 0.1 && qx + qw <= fx + fww + 0.1 && qy >= fy - 0.1 && qy + qh <= fy + fhh + 0.1 {
2626 return f;
2627 }
2628 }
2629 for sb in pbg.spinboxes.iter().flatten() {
2630 let (sx, sy, sww, shh) = sb.rect();
2631 if qx >= sx - 0.1 && qx + qw <= sx + sww + 0.1 && qy >= sy - 0.1 && qy + qh <= sy + shh + 0.1 {
2632 return sb;
2633 }
2634 }
2635 for btn in pbg.buttons.iter().flatten() {
2636 let (bx, by, bww, bhh) = btn.rect();
2637 if qx >= bx - 0.1 && qx + qw <= bx + bww + 0.1 && qy >= by - 0.1 && qy + qh <= by + bhh + 0.1 {
2638 return btn;
2639 }
2640 }
2641 for ch in pbg.choices.iter().flatten() {
2642 let (cx, cy, cww, chh) = ch.rect();
2643 if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
2644 return ch;
2645 }
2646 }
2647 for t in pbg.texts.iter().flatten() {
2648 let (tx, ty, tww, thh) = t.rect();
2649 if qx >= tx - 0.1 && qx + qw <= tx + tww + 0.1 && qy >= ty - 0.1 && qy + qh <= ty + thh + 0.1 {
2650 return t;
2651 }
2652 }
2653 for cb in pbg.toggles.iter().flatten() {
2654 let (cx, cy, cww, chh) = cb.rect();
2655 if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
2656 return cb;
2657 }
2658 }
2659 for c in pbg.colors.iter().flatten() {
2660 let (cx, cy, cww, chh) = c.rect();
2661 if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
2662 return c;
2663 }
2664 }
2665 }
2666 w
2667 }
2668
2669 pub fn push_extra_quad_vertices(
2670 w: &dyn crate::widget::WidgetHost,
2671 qx: f32, qy: f32, qw: f32, qh: f32,
2672 sw: f32, sh: f32,
2673 qc: [f32; 4],
2674 clip_circle: [f32; 3],
2675 out: &mut Vec<Vertex>,
2676 ) {
2677 if let Some(graph) = w.as_any().downcast_ref::<crate::widget::display::Graph>() {
2678 if graph.is_node_rect(qx, qy, qw, qh) {
2679 let r = crate::layout::graph_node_corner_radius();
2680 let extra_radii = crate::widget::CornerRadii::new(r, r, r, r);
2681 push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, None, out);
2682 return;
2683 }
2684 }
2685
2686 let target_w = get_child_widget_for_quad(w, qx, qy, qw, qh);
2687 let radii = target_w.corner_radii();
2688 if radii.top_left <= 0.1 && radii.top_right <= 0.1 && radii.bottom_right <= 0.1 && radii.bottom_left <= 0.1 {
2689 out.extend_from_slice(&quad_vertices_with_clip(qx, qy, qw, qh, sw, sh, qc, clip_circle));
2690 if let Some((color, thickness)) = target_w.solid_border() {
2691 let (wx, wy, ww, wh) = target_w.rect();
2692 if (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - ww).abs() < 0.1 && (qh - wh).abs() < 0.1 {
2693 push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
2694 }
2695 }
2696 return;
2697 }
2698
2699 let (wx, mut wy, ww, mut wh) = target_w.rect();
2700 let top_room = target_w.label_strip();
2701 wy += top_room;
2702 wh -= top_room;
2703 let extra_radii = crate::widget::CornerRadii::new(
2704 if qx <= wx + 1.5 && qy <= wy + 1.5 { radii.top_left } else { 0.0 },
2705 if qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5 { radii.top_right } else { 0.0 },
2706 if qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_right } else { 0.0 },
2707 if qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_left } else { 0.0 },
2708 );
2709
2710 push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, None, out);
2711
2712 if let Some((color, thickness)) = target_w.solid_border() {
2713 let (rx, mut ry, rw, mut rh) = target_w.rect();
2714 let top = target_w.label_strip();
2715 ry += top;
2716 rh -= top;
2717 if (qx - rx).abs() < 0.1 && (qy - ry).abs() < 0.1 && (qw - rw).abs() < 0.1 && (qh - rh).abs() < 0.1 {
2718 push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
2719 }
2720 }
2721 }
2722
2723 pub fn extra_quad_vertices_clipped(
2724 w: &dyn crate::widget::WidgetHost,
2725 qx: f32, qy: f32, qw: f32, qh: f32,
2726 sw: f32, sh: f32,
2727 qc: [f32; 4],
2728 clip: (f32, f32, f32, f32),
2729 clip_circle: [f32; 3],
2730 ) -> Vec<Vertex> {
2731 let mut verts = Vec::new();
2732 push_extra_quad_vertices_clipped(w, qx, qy, qw, qh, sw, sh, qc, clip, clip_circle, &mut verts);
2733 verts
2734 }
2735
2736 pub fn push_extra_quad_vertices_clipped(
2737 w: &dyn crate::widget::WidgetHost,
2738 qx: f32, qy: f32, qw: f32, qh: f32,
2739 sw: f32, sh: f32,
2740 qc: [f32; 4],
2741 clip: (f32, f32, f32, f32),
2742 clip_circle: [f32; 3],
2743 out: &mut Vec<Vertex>,
2744 ) {
2745 if let Some(graph) = w.as_any().downcast_ref::<crate::widget::display::Graph>() {
2746 if graph.is_node_rect(qx, qy, qw, qh) {
2747 let r = crate::layout::graph_node_corner_radius();
2748 let extra_radii = crate::widget::CornerRadii::new(r, r, r, r);
2749 push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, Some(clip), out);
2750 return;
2751 }
2752 }
2753
2754 let target_w = get_child_widget_for_quad(w, qx, qy, qw, qh);
2755 let radii = target_w.corner_radii();
2756 if radii.top_left <= 0.1 && radii.top_right <= 0.1 && radii.bottom_right <= 0.1 && radii.bottom_left <= 0.1 {
2757 let (cx0, cy0, cx1, cy1) = clip;
2758 let ix0 = qx.max(cx0);
2759 let iy0 = qy.max(cy0);
2760 let ix1 = (qx + qw).min(cx1);
2761 let iy1 = (qy + qh).min(cy1);
2762 if ix1 <= ix0 || iy1 <= iy0 {
2763 return;
2764 }
2765 out.extend_from_slice(&quad_vertices_with_clip(ix0, iy0, ix1 - ix0, iy1 - iy0, sw, sh, qc, clip_circle));
2766 if let Some((color, thickness)) = target_w.solid_border() {
2767 let (wx, wy, ww, wh) = target_w.rect();
2768 if (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - ww).abs() < 0.1 && (qh - wh).abs() < 0.1 {
2769 push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
2770 }
2771 }
2772 return;
2773 }
2774
2775 let (wx, mut wy, ww, mut wh) = target_w.rect();
2776 let top_room = target_w.label_strip();
2777 wy += top_room;
2778 wh -= top_room;
2779 let extra_radii = crate::widget::CornerRadii::new(
2780 if qx <= wx + 1.5 && qy <= wy + 1.5 { radii.top_left } else { 0.0 },
2781 if qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5 { radii.top_right } else { 0.0 },
2782 if qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_right } else { 0.0 },
2783 if qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_left } else { 0.0 },
2784 );
2785
2786 push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, Some(clip), out);
2787
2788 if let Some((color, thickness)) = target_w.solid_border() {
2789 let (rx, mut ry, rw, mut rh) = target_w.rect();
2790 let top = target_w.label_strip();
2791 ry += top;
2792 rh -= top;
2793 if (qx - rx).abs() < 0.1 && (qy - ry).abs() < 0.1 && (qw - rw).abs() < 0.1 && (qh - rh).abs() < 0.1 {
2794 push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
2795 }
2796 }
2797 }
2798
2799 pub fn circle_vertices(
2800 cx: f32, cy: f32, r: f32,
2801 sw: f32, sh: f32,
2802 color: [f32; 4],
2803 segments: usize,
2804 clip_circle: [f32; 3],
2805 ) -> Vec<Vertex> {
2806 let mut verts = Vec::new();
2807 for i in 0..segments {
2808 let theta1 = (i as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
2809 let theta2 = ((i + 1) as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
2810 let x0 = cx;
2811 let y0 = cy;
2812 let x1 = cx + r * theta1.cos();
2813 let y1 = cy + r * theta1.sin();
2814 let x2 = cx + r * theta2.cos();
2815 let y2 = cy + r * theta2.sin();
2816
2817 let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
2818 let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
2819 let ndc_x1 = (x1 / sw) * 2.0 - 1.0;
2820 let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
2821 let ndc_x2 = (x2 / sw) * 2.0 - 1.0;
2822 let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
2823
2824 verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
2825 verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
2826 verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
2827 }
2828 verts
2829 }
2830
2831 pub fn circle_border_vertices(
2832 cx: f32, cy: f32, r: f32,
2833 thickness: f32,
2834 sw: f32, sh: f32,
2835 color: [f32; 4],
2836 segments: usize,
2837 clip_circle: [f32; 3],
2838 ) -> Vec<Vertex> {
2839 let mut verts = Vec::new();
2840 for i in 0..segments {
2841 let theta1 = (i as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
2842 let theta2 = ((i + 1) as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
2843
2844 let x0 = cx + (r - thickness) * theta1.cos();
2845 let y0 = cy + (r - thickness) * theta1.sin();
2846 let x1 = cx + r * theta1.cos();
2847 let y1 = cy + r * theta1.sin();
2848
2849 let x2 = cx + r * theta2.cos();
2850 let y2 = cy + r * theta2.sin();
2851 let x3 = cx + (r - thickness) * theta2.cos();
2852 let y3 = cy + (r - thickness) * theta2.sin();
2853
2854 let ndc_x0 = (x0 / sw) * 2.0 - 1.0; let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
2855 let ndc_x1 = (x1 / sw) * 2.0 - 1.0; let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
2856 let ndc_x2 = (x2 / sw) * 2.0 - 1.0; let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
2857 let ndc_x3 = (x3 / sw) * 2.0 - 1.0; let ndc_y3 = 1.0 - (y3 / sh) * 2.0;
2858
2859 verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
2860 verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
2861 verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
2862
2863 verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
2864 verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
2865 verts.push(Vertex { position: [ndc_x3, ndc_y3], color, clip_circle });
2866 }
2867 verts
2868 }
2869
2870 pub fn arc_background_vertices(
2871 cx: f32, cy: f32, r: f32,
2872 thickness: f32,
2873 start_angle: f32, end_angle: f32,
2874 sw: f32, sh: f32,
2875 color: [f32; 4],
2876 segments: usize,
2877 clip_circle: [f32; 3],
2878 ) -> Vec<Vertex> {
2879 let mut verts = Vec::new();
2880 push_arc_background_vertices(cx, cy, r, thickness, start_angle, end_angle, sw, sh, color, segments, clip_circle, &mut verts);
2881 verts
2882 }
2883
2884 /// A ring band with radial Gouraud shading: two sub-bands (inner rim → crest
2885 /// centerline, crest → outer rim) whose vertex colors interpolate across the
2886 /// stroke — the rounded-bevel profile — plus the half-px alpha feathers at
2887 /// both true rims (colors matched to the adjacent band, so no seams).
2888 #[allow(clippy::too_many_arguments)]
2889 pub fn push_arc_shaded_vertices(
2890 cx: f32, cy: f32, r: f32,
2891 thickness: f32,
2892 start_angle: f32, end_angle: f32,
2893 sw: f32, sh: f32,
2894 inner: [f32; 4], crest: [f32; 4], outer: [f32; 4],
2895 segments: usize,
2896 clip_circle: [f32; 3],
2897 out: &mut Vec<Vertex>,
2898 ) {
2899 let f = 0.5f32.min(thickness * 0.25);
2900 let r_out = r;
2901 let r_in = (r - thickness).max(0.0);
2902 let r_mid = (r_in + r_out) / 2.0;
2903 let fade_in = [inner[0], inner[1], inner[2], 0.0];
2904 let fade_out = [outer[0], outer[1], outer[2], 0.0];
2905 // (inner radius, outer radius, color at inner edge, color at outer edge)
2906 let bands = [
2907 ((r_in - f).max(0.0), r_in + f, fade_in, inner),
2908 (r_in + f, r_mid, inner, crest),
2909 (r_mid, r_out - f, crest, outer),
2910 (r_out - f, r_out + f, outer, fade_out),
2911 ];
2912 for i in 0..segments {
2913 let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
2914 let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
2915 let (c1, s1) = (theta1.cos(), theta1.sin());
2916 let (c2, s2) = (theta2.cos(), theta2.sin());
2917 for &(ra, rb, ca, cb) in &bands {
2918 if rb <= ra {
2919 continue;
2920 }
2921 let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
2922 [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
2923 };
2924 let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
2925 let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
2926 out.push(Vertex { position: i1, color: ca, clip_circle });
2927 out.push(Vertex { position: o1, color: cb, clip_circle });
2928 out.push(Vertex { position: o2, color: cb, clip_circle });
2929 out.push(Vertex { position: i1, color: ca, clip_circle });
2930 out.push(Vertex { position: o2, color: cb, clip_circle });
2931 out.push(Vertex { position: i2, color: ca, clip_circle });
2932 }
2933 }
2934 }
2935
2936 pub fn push_arc_background_vertices(
2937 cx: f32, cy: f32, r: f32,
2938 thickness: f32,
2939 start_angle: f32, end_angle: f32,
2940 sw: f32, sh: f32,
2941 color: [f32; 4],
2942 segments: usize,
2943 clip_circle: [f32; 3],
2944 out: &mut Vec<Vertex>,
2945 ) {
2946 // The stroke band [r - thickness, r], with a half-px alpha ramp on each rim
2947 // (Gouraud across thin edge bands) so curved edges resolve smoothly instead
2948 // of hard-stepping — the poor-man's AA the flat pipeline doesn't provide.
2949 let f = 0.5f32.min(thickness * 0.25);
2950 let r_in = (r - thickness).max(0.0);
2951 // (inner radius, outer radius, alpha at inner rim, alpha at outer rim)
2952 let bands = [
2953 ((r_in - f).max(0.0), r_in + f, 0.0, color[3]),
2954 (r_in + f, r - f, color[3], color[3]),
2955 (r - f, r + f, color[3], 0.0),
2956 ];
2957 for i in 0..segments {
2958 let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
2959 let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
2960 let (c1, s1) = (theta1.cos(), theta1.sin());
2961 let (c2, s2) = (theta2.cos(), theta2.sin());
2962 for &(ra, rb, aa, ab) in &bands {
2963 if rb <= ra {
2964 continue;
2965 }
2966 let ca = [color[0], color[1], color[2], aa];
2967 let cb = [color[0], color[1], color[2], ab];
2968 let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
2969 [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
2970 };
2971 let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
2972 let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
2973 out.push(Vertex { position: i1, color: ca, clip_circle });
2974 out.push(Vertex { position: o1, color: cb, clip_circle });
2975 out.push(Vertex { position: o2, color: cb, clip_circle });
2976 out.push(Vertex { position: i1, color: ca, clip_circle });
2977 out.push(Vertex { position: o2, color: cb, clip_circle });
2978 out.push(Vertex { position: i2, color: ca, clip_circle });
2979 }
2980 }
2981 }
2982
2983 // `all(test, debug_assertions)`: the function under test only exists in
2984 // debug builds, so a `cargo test --release` must compile the module out too.
2985 #[cfg(all(test, debug_assertions))]
2986 mod near_roll_fallback_tests {
2987 use super::near_roll_fallback_reason;
2988 use crate::scene::layout::Rect;
2989
2990 fn r(x: f32, y: f32, w: f32, h: f32) -> Rect {
2991 Rect { x, y, width: w, height: h }
2992 }
2993
2994 const HOST: Rect = Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
2995 const ROLL: f32 = 8.0;
2996
2997 #[test]
2998 fn interior_carve_is_quiet() {
2999 // Well inside the deflated host: the overlay fallback is exact there.
3000 let carve = r(100.0, 100.0, 200.0, 100.0);
3001 assert_eq!(near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[], false), None);
3002 }
3003
3004 #[test]
3005 fn shaded_region_reaching_the_roll_is_loud() {
3006 // Carve rect stops 3px short of the roll band, but its shaded region
3007 // (depth*0.5 + 2 = 5px) crosses in — the inflation must count.
3008 let carve = r(ROLL + 3.0, 100.0, 200.0, 100.0);
3009 assert_eq!(
3010 near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[], false),
3011 Some("the host's feature run is closed (another plate appended features since)")
3012 );
3013 }
3014
3015 #[test]
3016 fn occlusion_is_named_before_run_contiguity() {
3017 let carve = r(2.0, 100.0, 200.0, 100.0);
3018 let occluder = r(150.0, 150.0, 100.0, 100.0);
3019 assert_eq!(
3020 near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[occluder], false),
3021 Some("a later plate overlaps the carve's shaded region")
3022 );
3023 }
3024
3025 #[test]
3026 fn non_overlapping_later_plate_is_not_occlusion() {
3027 let carve = r(2.0, 100.0, 200.0, 100.0);
3028 let elsewhere = r(500.0, 400.0, 100.0, 100.0);
3029 assert_eq!(
3030 near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[elsewhere], false),
3031 Some("the host's feature run is closed (another plate appended features since)")
3032 );
3033 }
3034
3035 #[test]
3036 fn budget_wins_over_every_other_reason() {
3037 let carve = r(2.0, 100.0, 200.0, 100.0);
3038 let occluder = r(150.0, 150.0, 100.0, 100.0);
3039 assert_eq!(
3040 near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[occluder], true),
3041 Some("the feature budget is full")
3042 );
3043 }
3044 }
3045
3046 #[cfg(test)]
3047 mod frame_tests {
3048 use crate::scene::layout::Rect;
3049 use crate::scene::paint::PaintCtx;
3050
3051 /// A frame is a plate turned inside out: its batch carries the HOLE as
3052 /// the SDF box, in mode 17, its cover quad is the face's bound, and it
3053 /// hosts the carves inside that bound as a Bevel does.
3054 #[test]
3055 fn a_frame_is_an_inside_out_plate_that_hosts_its_carves() {
3056 if !crate::layout::bevel_shader() {
3057 eprintln!("skipping: the shader plates are off in this configuration");
3058 return;
3059 }
3060 let (w, h, scale) = (400.0f32, 200.0f32, 1.0f32);
3061 let face = Rect { x: -10.0, y: 100.0, width: 420.0, height: 110.0 };
3062 let hole = Rect { x: 0.0, y: -100.0, width: 400.0, height: 224.0 };
3063 let mut pc = PaintCtx::new();
3064 let material = crate::scene::Material::opaque([0.3, 0.3, 0.35, 1.0]);
3065 pc.frame(face, hole, (0.0, 0.0, 20.0, 20.0), &material, 8.0);
3066 pc.recess(Rect { x: 40.0, y: 150.0, width: 30.0, height: 20.0 }, (4.0, 4.0, 4.0, 4.0), 3.0);
3067 let dl = pc.finish();
3068 let (_, batches, _, features) = super::tessellate_display_list(&dl, w, h, scale);
3069 let plate = batches.iter().find_map(|b| b.plate.filter(|p| p.mode == 17.0)).expect("a mode-17 batch");
3070 assert_eq!(plate.rect, [200.0, 12.0, 200.0, 112.0], "the SDF box is the hole");
3071 assert_eq!(plate.radii[2], 20.0, "the hole's bottom corners are the coves");
3072 assert_eq!(features.len(), 1, "the carve inside the face groups into the frame");
3073 }
3074 }