From 76bc41f30b23c4a994806a3315d3835a667d219c Mon Sep 17 00:00:00 2001 From: fizzy pop-os-claude-1 Date: Fri, 6 Nov 2026 09:11:28 -0600 Subject: [PATCH 1/3] glass: the test bed has a shadow scene that proves one shadow on GPU pixels Signed-off-by: fizzy pop-os-claude-1 --- scripts/glass-testbed.sh | 7 +- src/editor/GlassTestbed.zig | 159 +++++++++++++++++++++++------ src/editor/glass_testbed_tests.zig | 46 ++++++++- 3 files changed, 176 insertions(+), 36 deletions(-) diff --git a/scripts/glass-testbed.sh b/scripts/glass-testbed.sh index 68c6fb661..88d6a9410 100755 --- a/scripts/glass-testbed.sh +++ b/scripts/glass-testbed.sh @@ -12,7 +12,7 @@ while (($#)); do *) echo "unknown option: $1" >&2; exit 1 ;; esac done -[[ $scene == pair ]] || { echo "unknown scene: $scene" >&2; exit 1; } +[[ $scene == pair || $scene == shadow ]] || { echo "unknown scene: $scene" >&2; exit 1; } if [[ -z $out ]]; then out=$(mktemp -d /tmp/fizzy-glass-testbed.XXXXXX); fi mkdir -p "$out" out=$(cd "$out" && pwd) @@ -29,9 +29,10 @@ fi bins=(zig-out/*/fizzy) [[ ${#bins[@]} == 1 && -x ${bins[0]} ]] || { echo "expected one built fizzy executable" >&2; exit 1; } SDL_VIDEO_DRIVER=x11 xvfb-run -a -s "-screen 0 1280x1024x24" "${bins[0]}" -python3 - "$out/frames.jsonl" <<'PY' +python3 - "$out/frames.jsonl" "$scene" <<'PY' import json, sys +key = {'pair': 'ghost_max', 'shadow': 'dark_max'}[sys.argv[2]] with open(sys.argv[1]) as f: - print(json.dumps(max((json.loads(line) for line in f), key=lambda row: row['ghost_max']))) + print(json.dumps(max((json.loads(line) for line in f), key=lambda row: row[key]))) PY echo "Pictures and measurements: $out" diff --git a/src/editor/GlassTestbed.zig b/src/editor/GlassTestbed.zig index d25bdd973..0b567f148 100644 --- a/src/editor/GlassTestbed.zig +++ b/src/editor/GlassTestbed.zig @@ -15,17 +15,30 @@ serial: u32 = 0, warmup_frames: usize = 0, paired: std.ArrayList(u8) = .empty, rows: std.Io.Writer.Allocating, -bar: dvui.Rect.Physical = .{}, +/// Where B is measured against A this step, and the hole in it B's own glass fills, physical. +region: dvui.Rect.Physical = .{}, +hole: ?dvui.Rect.Physical = null, field: core.LiquidField = .{}, -pub const Config = struct { dir: []const u8 }; -pub const steps = 26; +/// `pair`: a drop meeting the bar, glass against glass. `shadow`: the shadow joined glass casts, +/// against the same outline drawn as one piece. +pub const Scene = enum { pair, shadow }; +pub const Config = struct { scene: Scene, dir: []const u8 }; +pub fn steps(scene: Scene) usize { + return switch (scene) { + .pair => 26, + .shadow => std.enums.values(ShadowCase).len, + }; +} +/// ":". pub fn parse(raw: []const u8) ?Config { - if (!std.mem.startsWith(u8, raw, "pair:") or raw.len == 5) return null; - return .{ .dir = raw[5..] }; + const colon = std.mem.indexOfScalar(u8, raw, ':') orelse return null; + const scene = std.meta.stringToEnum(Scene, raw[0..colon]) orelse return null; + if (colon + 1 == raw.len) return null; + return .{ .scene = scene, .dir = raw[colon + 1 ..] }; } pub fn gapAt(step: usize) f32 { - std.debug.assert(step < steps); + std.debug.assert(step < steps(.pair)); return if (step == 25) -40 else 12 - @as(f32, @floatFromInt(step)); } pub fn dropRadii(gap: f32) [4]f32 { @@ -36,9 +49,65 @@ pub fn mergeAt(gap: f32) f32 { return 36 * ct * ct; } +/// The shadow scene's steps, in order. +pub const ShadowCase = enum { inside, overlap }; +/// Points: how near the shadow scene's pieces come before their glass joins. +pub const shadow_merge: f32 = 12; +/// A rounded box, points. +pub const Box = struct { rect: dvui.Rect, radius: f32 }; +/// A shadow step: picture A (one piece of glass), picture B (the same outline with a second +/// piece joined to it) and the region where B's shadow must be A's, less a hole B's own glass +/// fills. Points. +pub const ShadowPictures = struct { + a: [2]Box = undefined, + a_len: usize, + b: [2]Box, + region: dvui.Rect, + hole: ?dvui.Rect = null, + + pub fn pictureA(self: *const ShadowPictures) []const Box { + return self.a[0..self.a_len]; + } +}; +/// `case`'s pictures centred in `window`, for a shadow that fades over `fade` and drops by +/// `offset` (points, `dialogs.surfaceShadow`'s). +pub fn shadowPictures(case: ShadowCase, window: dvui.Rect, fade: f32, offset: f32) ShadowPictures { + switch (case) { + // A drop wholly inside the bar casts nothing: the joined outline is the bar's. + .inside => { + const bar: Box = .{ .rect = .{ .x = (window.w - 340) / 2, .y = (window.h - 64) / 2, .w = 340, .h = 64 }, .radius = 24 }; + const drop: Box = .{ .rect = .{ .x = bar.rect.x + 150, .y = bar.rect.y + 12, .w = 40, .h = 40 }, .radius = 20 }; + return .{ + .a = .{ bar, undefined }, + .a_len = 1, + .b = .{ bar, drop }, + .region = drop.rect.outsetAll(fade).intersect(bar.rect), + .hole = drop.rect, + }; + }, + // Two boxes overlapping by 40 pt cast one shadow, not two stacked under the overlap. + .overlap => { + const x = (window.w - 160) / 2; + const y = (window.h - 60) / 2; + return .{ + .a = .{ .{ .rect = .{ .x = x, .y = y, .w = 160, .h = 60 }, .radius = 20 }, undefined }, + .a_len = 1, + .b = .{ + .{ .rect = .{ .x = x, .y = y, .w = 100, .h = 60 }, .radius = 20 }, + .{ .rect = .{ .x = x + 60, .y = y, .w = 100, .h = 60 }, .radius = 20 }, + }, + .region = .{ .x = x + 60, .y = y + 60, .w = 40, .h = fade + offset }, + }; + }, + } +} + pub const Ghost = struct { max: f32 = 0, x: usize = 0, y: usize = 0 }; -/// Rec. 709 luma difference on encoded RGBA, inside rect inset by two physical pixels. -pub fn ghostMax(a: []const u8, b: []const u8, width: usize, rect: dvui.Rect.Physical) Ghost { +/// Physical pixels `ghostMax` keeps clear of its rect's edges and its hole's, where edges blend. +pub const ghost_inset: f32 = 2; +/// Rec. 709 luma difference b − a on encoded RGBA, inside `rect` inset by `ghost_inset` and +/// outside `hole` outset by it. +pub fn ghostMax(a: []const u8, b: []const u8, width: usize, rect: dvui.Rect.Physical, hole: ?dvui.Rect.Physical) Ghost { std.debug.assert(a.len == b.len and a.len % (width * 4) == 0); var result: Ghost = .{}; for (0..a.len / 4) |i| { @@ -46,7 +115,8 @@ pub fn ghostMax(a: []const u8, b: []const u8, width: usize, rect: dvui.Rect.Phys const y = i / width; const px = @as(f32, @floatFromInt(x)) + 0.5; const py = @as(f32, @floatFromInt(y)) + 0.5; - if (px < rect.x + 2 or px >= rect.x + rect.w - 2 or py < rect.y + 2 or py >= rect.y + rect.h - 2) continue; + if (px < rect.x + ghost_inset or px >= rect.x + rect.w - ghost_inset or py < rect.y + ghost_inset or py >= rect.y + rect.h - ghost_inset) continue; + if (hole) |h| if (px >= h.x - ghost_inset and px < h.x + h.w + ghost_inset and py >= h.y - ghost_inset and py < h.y + h.h + ghost_inset) continue; var diff: f32 = 0; for ([_]f32{ 0.2126, 0.7152, 0.0722 }, 0..) |weight, channel| { diff += weight * (@as(f32, @floatFromInt(b[i * 4 + channel])) - @as(f32, @floatFromInt(a[i * 4 + channel]))) / 255; @@ -69,19 +139,38 @@ pub fn draw(self: *GlassTestbed) void { const rs = dvui.parentGet().screenRectScale(.{}); const window = rs.rectFromPhysical(dvui.windowRectPixels()); backdrop(rs, window); - const bar: dvui.Rect = .{ .x = (window.w - 340) / 2, .y = (window.h - 64) / 2, .w = 340, .h = 64 }; - self.bar = rs.rectToPhysical(bar); - const gap = gapAt(self.step); - const drop: dvui.Rect = .{ .x = bar.x + 150, .y = bar.y - gap - 40, .w = 40, .h = 40 }; - var radii = dropRadii(gap); - for (&radii) |*r| r.* *= rs.s; - const bubbles = [_]core.dialogs.Bubble{ - .{ .rect = self.bar, .radius = 24 * rs.s }, - .{ .rect = rs.rectToPhysical(drop), .radii = radii }, - }; - self.field = .{ .len = 2, .scale = rs.s, .merge_px = mergeAt(gap) * rs.s, .refraction = core.dialogs.refraction() }; - self.field.shapes[0] = .{ .rect = self.bar, .radii = @splat(24 * rs.s), .round = true }; - self.field.shapes[1] = .{ .rect = bubbles[1].rect, .radii = radii, .round = gap > 0 }; + var bubbles: [2]core.dialogs.Bubble = undefined; + var len: usize = 0; + var opts: core.dialogs.BubblesOptions = undefined; + switch (self.config.scene) { + .pair => { + const bar: dvui.Rect = .{ .x = (window.w - 340) / 2, .y = (window.h - 64) / 2, .w = 340, .h = 64 }; + self.region = rs.rectToPhysical(bar); + const gap = gapAt(self.step); + const drop: dvui.Rect = .{ .x = bar.x + 150, .y = bar.y - gap - 40, .w = 40, .h = 40 }; + var radii = dropRadii(gap); + for (&radii) |*r| r.* *= rs.s; + bubbles = .{ + .{ .rect = self.region, .radius = 24 * rs.s }, + .{ .rect = rs.rectToPhysical(drop), .radii = radii }, + }; + len = if (self.host) 1 else 2; + opts = .{ .merge = mergeAt(gap), .shadow = 0 }; + self.field = .{ .len = 2, .scale = rs.s, .merge_px = mergeAt(gap) * rs.s, .refraction = core.dialogs.refraction() }; + self.field.shapes[0] = .{ .rect = self.region, .radii = @splat(24 * rs.s), .round = true }; + self.field.shapes[1] = .{ .rect = bubbles[1].rect, .radii = radii, .round = gap > 0 }; + }, + .shadow => { + const bs = core.dialogs.surfaceShadow(); + const pictures = shadowPictures(@fromBackingInt(@intCast(self.step)), window, bs.fade, bs.offset.y); + const boxes = if (self.host) &pictures.b else pictures.pictureA(); + for (boxes, 0..) |box, i| bubbles[i] = .{ .rect = rs.rectToPhysical(box.rect), .radius = box.radius * rs.s }; + len = boxes.len; + opts = .{ .merge = shadow_merge, .shadow = 1 }; + self.region = rs.rectToPhysical(pictures.region); + self.hole = if (pictures.hole) |h| rs.rectToPhysical(h) else null; + }, + } core.LiquidField.publishEnabled(true); // Native programs compile on first use. Do not measure their fallback frame. if (!core.LiquidField.ready()) { @@ -93,7 +182,7 @@ pub fn draw(self: *GlassTestbed) void { dvui.refresh(null, @src(), null); return; } - core.dialogs.glassBubbles(dvui.Id.zero.update("glass-testbed"), bubbles[0..if (self.host) 1 else 2], rs.s, .{ .merge = mergeAt(gap), .shadow = 0 }); + core.dialogs.glassBubbles(dvui.Id.zero.update("glass-testbed"), bubbles[0..len], rs.s, opts); self.serial = self.capture.request(""); } @@ -137,12 +226,22 @@ fn saveFrame(self: *GlassTestbed, picture: ?Capture.Source) !void { @memcpy(self.paired.items, image.pixels); } else { if (self.paired.items.len != image.pixels.len) return error.FrameSizeChanged; - const ghost = ghostMax(self.paired.items, image.pixels, image.width, self.bar); - var area: usize = 0; - for (0..image.height) |y| for (0..image.width) |x| { - if (self.field.sampleWith(.{ .x = @as(f32, @floatFromInt(x)) + 0.5, .y = @as(f32, @floatFromInt(y)) + 0.5 }, core.glass_look.inApp(1)).coverage > 0.5) area += 1; - }; - try self.rows.writer.print("{{\"step\":{d},\"gap_pt\":{d},\"k_px\":{d},\"area_px\":{d},\"ghost_max\":{d},\"ghost_x\":{d},\"ghost_y\":{d}}}\n", .{ self.step, gapAt(self.step), self.field.merge_px, area, ghost.max, ghost.x, ghost.y }); + switch (self.config.scene) { + .pair => { + const ghost = ghostMax(self.paired.items, image.pixels, image.width, self.region, null); + var area: usize = 0; + for (0..image.height) |y| for (0..image.width) |x| { + if (self.field.sampleWith(.{ .x = @as(f32, @floatFromInt(x)) + 0.5, .y = @as(f32, @floatFromInt(y)) + 0.5 }, core.glass_look.inApp(1)).coverage > 0.5) area += 1; + }; + try self.rows.writer.print("{{\"step\":{d},\"gap_pt\":{d},\"k_px\":{d},\"area_px\":{d},\"ghost_max\":{d},\"ghost_x\":{d},\"ghost_y\":{d}}}\n", .{ self.step, gapAt(self.step), self.field.merge_px, area, ghost.max, ghost.x, ghost.y }); + }, + .shadow => { + // B against A: how much darker B is. + const dark = ghostMax(image.pixels, self.paired.items, image.width, self.region, self.hole); + const case: ShadowCase = @fromBackingInt(@intCast(self.step)); + try self.rows.writer.print("{{\"scene\":\"shadow\",\"case\":\"{t}\",\"dark_max\":{d},\"x\":{d},\"y\":{d}}}\n", .{ case, dark.max, dark.x, dark.y }); + }, + } try cwd.writeFile(dvui.io, .{ .sub_path = try std.fs.path.join(arena, &.{ self.config.dir, "frames.jsonl" }), .data = self.rows.written() }); } var png: std.Io.Writer.Allocating = .init(self.capture.gpa); @@ -152,6 +251,6 @@ fn saveFrame(self: *GlassTestbed, picture: ?Capture.Source) !void { try cwd.writeFile(dvui.io, .{ .sub_path = try std.fs.path.join(arena, &.{ self.config.dir, name }), .data = png.written() }); if (self.host) self.step += 1; self.host = !self.host; - if (self.step == steps) std.process.exit(0); + if (self.step == steps(self.config.scene)) std.process.exit(0); dvui.refresh(null, @src(), null); } diff --git a/src/editor/glass_testbed_tests.zig b/src/editor/glass_testbed_tests.zig index 8ae643d75..e30cb316c 100644 --- a/src/editor/glass_testbed_tests.zig +++ b/src/editor/glass_testbed_tests.zig @@ -1,17 +1,23 @@ const std = @import("std"); +const core = @import("core"); +const dvui = @import("dvui"); const bed = @import("GlassTestbed.zig"); const expect = std.testing.expect; const equal = std.testing.expectEqual; -test "pair parsing requires a scene and output directory" { +test "parsing requires a known scene and an output directory" { try std.testing.expectEqualStrings("/tmp/x", bed.parse("pair:/tmp/x").?.dir); + try equal(.pair, bed.parse("pair:/tmp/x").?.scene); + try std.testing.expectEqualStrings("/tmp/y", bed.parse("shadow:/tmp/y").?.dir); + try equal(.shadow, bed.parse("shadow:/tmp/y").?.scene); try expect(bed.parse("pair") == null); try expect(bed.parse("nope:/tmp/x") == null); try expect(bed.parse("pair:") == null); + try expect(bed.parse("shadow:") == null); } test "pair steps and drop geometry follow the console join" { - try equal(26, bed.steps); + try equal(26, bed.steps(.pair)); for (0..25) |i| try equal(12 - @as(f32, @floatFromInt(i)), bed.gapAt(i)); try equal(-40, bed.gapAt(25)); try equal([4]f32{ 20, 20, 20, 20 }, bed.dropRadii(6)); @@ -28,8 +34,42 @@ test "ghost maximum ignores darkening outside the inset bar" { a[(3 * 8 + 3) * 4 + c] = 235; a[c] = 0; } - const ghost = bed.ghostMax(&a, &b, 8, .{ .w = 8, .h = 8 }); + const ghost = bed.ghostMax(&a, &b, 8, .{ .w = 8, .h = 8 }, null); try std.testing.expectApproxEqAbs(@as(f32, 20.0 / 255.0), ghost.max, 0.000001); try equal(3, ghost.x); try equal(3, ghost.y); } + +test "ghost maximum ignores its hole" { + var a: [16 * 16 * 4]u8 = @splat(255); + const b = a; + for (0..3) |c| { + a[(8 * 16 + 8) * 4 + c] = 200; + a[(3 * 16 + 3) * 4 + c] = 235; + } + const ghost = bed.ghostMax(&a, &b, 16, .{ .w = 16, .h = 16 }, .{ .x = 8, .y = 8, .w = 1, .h = 1 }); + try equal(3, ghost.x); + try equal(3, ghost.y); +} + +test "shadow scene: two steps, each measuring a region outside B's glass" { + try equal(2, bed.steps(.shadow)); + const window: dvui.Rect = .{ .w = 1280, .h = 1024 }; + for (std.enums.values(bed.ShadowCase)) |case| { + const pictures = bed.shadowPictures(case, window, 8, 0); + const measured = pictures.region.insetAll(bed.ghost_inset); + try expect(measured.w > 0 and measured.h > 0); + try equal(1, pictures.pictureA().len); + if (case == .inside) { + // The ring round the drop, inside the bar, is measured. + try expect(pictures.hole.?.w < measured.w and pictures.hole.?.h < measured.h); + try expect(pictures.b[0].rect.contains(pictures.region.topLeft())); + continue; + } + var field: core.LiquidField = .{ .scale = 1, .merge_px = bed.shadow_merge }; + for (pictures.b) |box| field.add(.{ .rect = box.rect.scale(1, dvui.Rect.Physical), .radii = @splat(box.radius), .round = true }); + for ([_]dvui.Point{ measured.topLeft(), measured.topRight(), measured.bottomLeft(), measured.bottomRight() }) |p| { + try equal(0, field.sampleWith(.{ .x = p.x, .y = p.y }, core.glass_look.inApp(1)).coverage); + } + } +} From e3cb4cc83f459f940d917532cf0f0f2c96a01279 Mon Sep 17 00:00:00 2001 From: fizzy pop-os-claude-1 Date: Fri, 6 Nov 2026 11:23:37 -0600 Subject: [PATCH 2/3] glass: the test bed checks that joined glass casts one shadow Signed-off-by: fizzy pop-os-claude-1 --- build/app.zig | 9 ++++++-- src/editor/GlassTestbed.zig | 33 +++++++++++++++++++++++++++++- src/editor/glass_testbed_tests.zig | 27 ++++++++++++++++++++---- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/build/app.zig b/build/app.zig index f811e4029..d93ef60cf 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1420,11 +1420,12 @@ fn addTests( }); glass_testbed_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); glass_testbed_module.addImport("core", core_module_test); - try unit_test_artifacts.append(b.allocator, b.addTest(.{ + const glass_testbed_tests = b.addTest(.{ .name = "fizzy-glass-testbed-tests", .root_module = glass_testbed_module, .filters = test_filters, - })); + }); + try unit_test_artifacts.append(b.allocator, glass_testbed_tests); // The liquid field's packing and edge-light arithmetic needs dvui's geometry types, // but no Window or native backend. Keep it in `test`, apart from the glass window tests. @@ -1484,6 +1485,10 @@ fn addTests( const test_all_step = b.step("test-all", "Run unit + integration tests"); test_all_step.dependOn(test_step); test_all_step.dependOn(test_integration_step); + // The test bed's scene geometry backs `scripts/glass-testbed.sh`, whose acceptance is + // `test-integration`. + test_integration_step.dependOn(&b.addRunArtifact(glass_testbed_tests).step); + check_integration_step.dependOn(&glass_testbed_tests.step); const test_sdk_version_step = b.step( "test-sdk-version", diff --git a/src/editor/GlassTestbed.zig b/src/editor/GlassTestbed.zig index 0b567f148..9f65a5df1 100644 --- a/src/editor/GlassTestbed.zig +++ b/src/editor/GlassTestbed.zig @@ -102,6 +102,31 @@ pub fn shadowPictures(case: ShadowCase, window: dvui.Rect, fade: f32, offset: f3 } } +/// `boxes` as `glassBubbles` joins them, placed by `rs`. +pub fn shadowField(boxes: []const Box, rs: dvui.RectScale) core.LiquidField { + var field: core.LiquidField = .{ .scale = rs.s, .merge_px = shadow_merge * rs.s }; + for (boxes) |box| field.add(.{ .rect = rs.rectToPhysical(box.rect), .radii = @splat(box.radius * rs.s), .round = true }); + return field; +} + +/// `region` moved down, its height kept, until no pixel of it is glass `field` covers at all, its +/// antialiased edge included. The join bulges `merge / 4` below the boxes it joins, so a band +/// under them would otherwise measure B's glass as its shadow. +pub fn belowGlass(region: dvui.Rect.Physical, field: *const core.LiquidField) dvui.Rect.Physical { + var out = region; + out.y = @floor(region.y); + while (rowCovered(out, field)) out.y += 1; + return out; +} + +fn rowCovered(region: dvui.Rect.Physical, field: *const core.LiquidField) bool { + var x = @floor(region.x); + while (x < region.x + region.w) : (x += 1) { + if (field.sampleWith(.{ .x = x + 0.5, .y = region.y + 0.5 }, core.glass_look.inApp(1)).coverage > 0) return true; + } + return false; +} + pub const Ghost = struct { max: f32 = 0, x: usize = 0, y: usize = 0 }; /// Physical pixels `ghostMax` keeps clear of its rect's edges and its hole's, where edges blend. pub const ghost_inset: f32 = 2; @@ -162,12 +187,18 @@ pub fn draw(self: *GlassTestbed) void { }, .shadow => { const bs = core.dialogs.surfaceShadow(); - const pictures = shadowPictures(@fromBackingInt(@intCast(self.step)), window, bs.fade, bs.offset.y); + const case: ShadowCase = @fromBackingInt(@intCast(self.step)); + const pictures = shadowPictures(case, window, bs.fade, bs.offset.y); const boxes = if (self.host) &pictures.b else pictures.pictureA(); for (boxes, 0..) |box, i| bubbles[i] = .{ .rect = rs.rectToPhysical(box.rect), .radius = box.radius * rs.s }; len = boxes.len; opts = .{ .merge = shadow_merge, .shadow = 1 }; self.region = rs.rectToPhysical(pictures.region); + // "inside" measures within B's glass on purpose; "overlap" measures only shadow. + if (case == .overlap) { + self.field = shadowField(&pictures.b, rs); + self.region = belowGlass(self.region, &self.field); + } self.hole = if (pictures.hole) |h| rs.rectToPhysical(h) else null; }, } diff --git a/src/editor/glass_testbed_tests.zig b/src/editor/glass_testbed_tests.zig index e30cb316c..bda9dc606 100644 --- a/src/editor/glass_testbed_tests.zig +++ b/src/editor/glass_testbed_tests.zig @@ -66,10 +66,29 @@ test "shadow scene: two steps, each measuring a region outside B's glass" { try expect(pictures.b[0].rect.contains(pictures.region.topLeft())); continue; } - var field: core.LiquidField = .{ .scale = 1, .merge_px = bed.shadow_merge }; - for (pictures.b) |box| field.add(.{ .rect = box.rect.scale(1, dvui.Rect.Physical), .radii = @splat(box.radius), .round = true }); - for ([_]dvui.Point{ measured.topLeft(), measured.topRight(), measured.bottomLeft(), measured.bottomRight() }) |p| { - try equal(0, field.sampleWith(.{ .x = p.x, .y = p.y }, core.glass_look.inApp(1)).coverage); + // The join bulges below the boxes, most in the overlap's middle: no corner or the middle + // of what is measured is glass, at either window scale. + for ([_]f32{ 1, 2 }) |s| { + const rs: dvui.RectScale = .{ .s = s }; + const field = bed.shadowField(&pictures.b, rs); + const region = bed.belowGlass(rs.rectToPhysical(pictures.region), &field); + try equal(rs.rectToPhysical(pictures.region).h, region.h); + const inner = region.insetAll(bed.ghost_inset); + try expect(inner.w > 0 and inner.h > 0); + const mid = inner.center(); + for ([_]dvui.Point.Physical{ inner.topLeft(), inner.topRight(), inner.bottomLeft(), inner.bottomRight(), mid, .{ .x = mid.x, .y = inner.y } }) |p| { + try equal(0, field.sampleWith(p, core.glass_look.inApp(1)).coverage); + } } } } + +test "the overlap's band starts below the join's bulge" { + const pictures = bed.shadowPictures(.overlap, .{ .w = 1280, .h = 1024 }, 8, 0); + const rs: dvui.RectScale = .{ .s = 1 }; + const field = bed.shadowField(&pictures.b, rs); + const region = bed.belowGlass(rs.rectToPhysical(pictures.region), &field); + // Without the move its top row would sample B's glass. + try expect(field.sampleWith(.{ .x = pictures.region.center().x, .y = pictures.region.y + 0.5 }, core.glass_look.inApp(1)).coverage > 0); + try expect(region.y >= pictures.region.y + bed.shadow_merge / 4); +} From 8c4b3b9f7bfc0928038d2dd37853b3b687d03566 Mon Sep 17 00:00:00 2001 From: fizzy foxnne-desktop-claude-2 Date: Fri, 6 Nov 2026 12:25:44 -0600 Subject: [PATCH 3/3] glass: the test bed checks that joined glass casts one shadow Signed-off-by: fizzy foxnne-desktop-claude-2 --- build/app.zig | 9 +-- src/editor/GlassTestbed.zig | 103 ++++++----------------------- src/editor/glass_testbed_tests.zig | 53 ++++----------- 3 files changed, 37 insertions(+), 128 deletions(-) diff --git a/build/app.zig b/build/app.zig index d93ef60cf..f811e4029 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1420,12 +1420,11 @@ fn addTests( }); glass_testbed_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); glass_testbed_module.addImport("core", core_module_test); - const glass_testbed_tests = b.addTest(.{ + try unit_test_artifacts.append(b.allocator, b.addTest(.{ .name = "fizzy-glass-testbed-tests", .root_module = glass_testbed_module, .filters = test_filters, - }); - try unit_test_artifacts.append(b.allocator, glass_testbed_tests); + })); // The liquid field's packing and edge-light arithmetic needs dvui's geometry types, // but no Window or native backend. Keep it in `test`, apart from the glass window tests. @@ -1485,10 +1484,6 @@ fn addTests( const test_all_step = b.step("test-all", "Run unit + integration tests"); test_all_step.dependOn(test_step); test_all_step.dependOn(test_integration_step); - // The test bed's scene geometry backs `scripts/glass-testbed.sh`, whose acceptance is - // `test-integration`. - test_integration_step.dependOn(&b.addRunArtifact(glass_testbed_tests).step); - check_integration_step.dependOn(&glass_testbed_tests.step); const test_sdk_version_step = b.step( "test-sdk-version", diff --git a/src/editor/GlassTestbed.zig b/src/editor/GlassTestbed.zig index 9f65a5df1..e696d5d20 100644 --- a/src/editor/GlassTestbed.zig +++ b/src/editor/GlassTestbed.zig @@ -27,7 +27,7 @@ pub const Config = struct { scene: Scene, dir: []const u8 }; pub fn steps(scene: Scene) usize { return switch (scene) { .pair => 26, - .shadow => std.enums.values(ShadowCase).len, + .shadow => 1, }; } /// ":". @@ -49,82 +49,31 @@ pub fn mergeAt(gap: f32) f32 { return 36 * ct * ct; } -/// The shadow scene's steps, in order. -pub const ShadowCase = enum { inside, overlap }; /// Points: how near the shadow scene's pieces come before their glass joins. pub const shadow_merge: f32 = 12; /// A rounded box, points. pub const Box = struct { rect: dvui.Rect, radius: f32 }; -/// A shadow step: picture A (one piece of glass), picture B (the same outline with a second -/// piece joined to it) and the region where B's shadow must be A's, less a hole B's own glass -/// fills. Points. +/// The shadow scene's one step, "inside": picture A (the bar), picture B (the bar with a drop +/// joined wholly inside it) and the region where B's shadow must be A's, less the hole the +/// drop's own glass fills. Points. pub const ShadowPictures = struct { - a: [2]Box = undefined, - a_len: usize, + a: Box, b: [2]Box, region: dvui.Rect, - hole: ?dvui.Rect = null, - - pub fn pictureA(self: *const ShadowPictures) []const Box { - return self.a[0..self.a_len]; - } + hole: dvui.Rect, }; -/// `case`'s pictures centred in `window`, for a shadow that fades over `fade` and drops by -/// `offset` (points, `dialogs.surfaceShadow`'s). -pub fn shadowPictures(case: ShadowCase, window: dvui.Rect, fade: f32, offset: f32) ShadowPictures { - switch (case) { - // A drop wholly inside the bar casts nothing: the joined outline is the bar's. - .inside => { - const bar: Box = .{ .rect = .{ .x = (window.w - 340) / 2, .y = (window.h - 64) / 2, .w = 340, .h = 64 }, .radius = 24 }; - const drop: Box = .{ .rect = .{ .x = bar.rect.x + 150, .y = bar.rect.y + 12, .w = 40, .h = 40 }, .radius = 20 }; - return .{ - .a = .{ bar, undefined }, - .a_len = 1, - .b = .{ bar, drop }, - .region = drop.rect.outsetAll(fade).intersect(bar.rect), - .hole = drop.rect, - }; - }, - // Two boxes overlapping by 40 pt cast one shadow, not two stacked under the overlap. - .overlap => { - const x = (window.w - 160) / 2; - const y = (window.h - 60) / 2; - return .{ - .a = .{ .{ .rect = .{ .x = x, .y = y, .w = 160, .h = 60 }, .radius = 20 }, undefined }, - .a_len = 1, - .b = .{ - .{ .rect = .{ .x = x, .y = y, .w = 100, .h = 60 }, .radius = 20 }, - .{ .rect = .{ .x = x + 60, .y = y, .w = 100, .h = 60 }, .radius = 20 }, - }, - .region = .{ .x = x + 60, .y = y + 60, .w = 40, .h = fade + offset }, - }; - }, - } -} - -/// `boxes` as `glassBubbles` joins them, placed by `rs`. -pub fn shadowField(boxes: []const Box, rs: dvui.RectScale) core.LiquidField { - var field: core.LiquidField = .{ .scale = rs.s, .merge_px = shadow_merge * rs.s }; - for (boxes) |box| field.add(.{ .rect = rs.rectToPhysical(box.rect), .radii = @splat(box.radius * rs.s), .round = true }); - return field; -} - -/// `region` moved down, its height kept, until no pixel of it is glass `field` covers at all, its -/// antialiased edge included. The join bulges `merge / 4` below the boxes it joins, so a band -/// under them would otherwise measure B's glass as its shadow. -pub fn belowGlass(region: dvui.Rect.Physical, field: *const core.LiquidField) dvui.Rect.Physical { - var out = region; - out.y = @floor(region.y); - while (rowCovered(out, field)) out.y += 1; - return out; -} - -fn rowCovered(region: dvui.Rect.Physical, field: *const core.LiquidField) bool { - var x = @floor(region.x); - while (x < region.x + region.w) : (x += 1) { - if (field.sampleWith(.{ .x = x + 0.5, .y = region.y + 0.5 }, core.glass_look.inApp(1)).coverage > 0) return true; - } - return false; +/// The pictures centred in `window`, for a shadow that fades over `fade` points +/// (`dialogs.surfaceShadow`'s). A drop wholly inside the bar casts nothing: the joined outline +/// is the bar's. +pub fn shadowPictures(window: dvui.Rect, fade: f32) ShadowPictures { + const bar: Box = .{ .rect = .{ .x = (window.w - 340) / 2, .y = (window.h - 64) / 2, .w = 340, .h = 64 }, .radius = 24 }; + const drop: Box = .{ .rect = .{ .x = bar.rect.x + 150, .y = bar.rect.y + 12, .w = 40, .h = 40 }, .radius = 20 }; + return .{ + .a = bar, + .b = .{ bar, drop }, + .region = drop.rect.outsetAll(fade).intersect(bar.rect), + .hole = drop.rect, + }; } pub const Ghost = struct { max: f32 = 0, x: usize = 0, y: usize = 0 }; @@ -186,20 +135,13 @@ pub fn draw(self: *GlassTestbed) void { self.field.shapes[1] = .{ .rect = bubbles[1].rect, .radii = radii, .round = gap > 0 }; }, .shadow => { - const bs = core.dialogs.surfaceShadow(); - const case: ShadowCase = @fromBackingInt(@intCast(self.step)); - const pictures = shadowPictures(case, window, bs.fade, bs.offset.y); - const boxes = if (self.host) &pictures.b else pictures.pictureA(); + const pictures = shadowPictures(window, core.dialogs.surfaceShadow().fade); + const boxes: []const Box = if (self.host) &pictures.b else (&pictures.a)[0..1]; for (boxes, 0..) |box, i| bubbles[i] = .{ .rect = rs.rectToPhysical(box.rect), .radius = box.radius * rs.s }; len = boxes.len; opts = .{ .merge = shadow_merge, .shadow = 1 }; self.region = rs.rectToPhysical(pictures.region); - // "inside" measures within B's glass on purpose; "overlap" measures only shadow. - if (case == .overlap) { - self.field = shadowField(&pictures.b, rs); - self.region = belowGlass(self.region, &self.field); - } - self.hole = if (pictures.hole) |h| rs.rectToPhysical(h) else null; + self.hole = rs.rectToPhysical(pictures.hole); }, } core.LiquidField.publishEnabled(true); @@ -269,8 +211,7 @@ fn saveFrame(self: *GlassTestbed, picture: ?Capture.Source) !void { .shadow => { // B against A: how much darker B is. const dark = ghostMax(image.pixels, self.paired.items, image.width, self.region, self.hole); - const case: ShadowCase = @fromBackingInt(@intCast(self.step)); - try self.rows.writer.print("{{\"scene\":\"shadow\",\"case\":\"{t}\",\"dark_max\":{d},\"x\":{d},\"y\":{d}}}\n", .{ case, dark.max, dark.x, dark.y }); + try self.rows.writer.print("{{\"scene\":\"shadow\",\"case\":\"inside\",\"dark_max\":{d},\"x\":{d},\"y\":{d}}}\n", .{ dark.max, dark.x, dark.y }); }, } try cwd.writeFile(dvui.io, .{ .sub_path = try std.fs.path.join(arena, &.{ self.config.dir, "frames.jsonl" }), .data = self.rows.written() }); diff --git a/src/editor/glass_testbed_tests.zig b/src/editor/glass_testbed_tests.zig index bda9dc606..a519024bc 100644 --- a/src/editor/glass_testbed_tests.zig +++ b/src/editor/glass_testbed_tests.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const core = @import("core"); const dvui = @import("dvui"); const bed = @import("GlassTestbed.zig"); const expect = std.testing.expect; @@ -52,43 +51,17 @@ test "ghost maximum ignores its hole" { try equal(3, ghost.y); } -test "shadow scene: two steps, each measuring a region outside B's glass" { - try equal(2, bed.steps(.shadow)); - const window: dvui.Rect = .{ .w = 1280, .h = 1024 }; - for (std.enums.values(bed.ShadowCase)) |case| { - const pictures = bed.shadowPictures(case, window, 8, 0); - const measured = pictures.region.insetAll(bed.ghost_inset); - try expect(measured.w > 0 and measured.h > 0); - try equal(1, pictures.pictureA().len); - if (case == .inside) { - // The ring round the drop, inside the bar, is measured. - try expect(pictures.hole.?.w < measured.w and pictures.hole.?.h < measured.h); - try expect(pictures.b[0].rect.contains(pictures.region.topLeft())); - continue; - } - // The join bulges below the boxes, most in the overlap's middle: no corner or the middle - // of what is measured is glass, at either window scale. - for ([_]f32{ 1, 2 }) |s| { - const rs: dvui.RectScale = .{ .s = s }; - const field = bed.shadowField(&pictures.b, rs); - const region = bed.belowGlass(rs.rectToPhysical(pictures.region), &field); - try equal(rs.rectToPhysical(pictures.region).h, region.h); - const inner = region.insetAll(bed.ghost_inset); - try expect(inner.w > 0 and inner.h > 0); - const mid = inner.center(); - for ([_]dvui.Point.Physical{ inner.topLeft(), inner.topRight(), inner.bottomLeft(), inner.bottomRight(), mid, .{ .x = mid.x, .y = inner.y } }) |p| { - try equal(0, field.sampleWith(p, core.glass_look.inApp(1)).coverage); - } - } - } -} - -test "the overlap's band starts below the join's bulge" { - const pictures = bed.shadowPictures(.overlap, .{ .w = 1280, .h = 1024 }, 8, 0); - const rs: dvui.RectScale = .{ .s = 1 }; - const field = bed.shadowField(&pictures.b, rs); - const region = bed.belowGlass(rs.rectToPhysical(pictures.region), &field); - // Without the move its top row would sample B's glass. - try expect(field.sampleWith(.{ .x = pictures.region.center().x, .y = pictures.region.y + 0.5 }, core.glass_look.inApp(1)).coverage > 0); - try expect(region.y >= pictures.region.y + bed.shadow_merge / 4); +test "shadow scene: one step measuring the ring round the drop, inside the bar" { + try equal(1, bed.steps(.shadow)); + const pictures = bed.shadowPictures(.{ .w = 1280, .h = 1024 }, 8); + const bar = pictures.a.rect; + try equal(bar, pictures.b[0].rect); + const region = pictures.region; + try expect(region.x >= bar.x and region.y >= bar.y and region.x + region.w <= bar.x + bar.w and region.y + region.h <= bar.y + bar.h); + // The drop's fade ring is wider than what `ghostMax` keeps clear on both its sides. + const measured = region.insetAll(bed.ghost_inset); + const hole = pictures.hole.outsetAll(bed.ghost_inset); + try equal(pictures.b[1].rect, pictures.hole); + try expect(measured.x < hole.x and measured.y < hole.y); + try expect(measured.x + measured.w > hole.x + hole.w and measured.y + measured.h > hole.y + hole.h); }