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..e696d5d20 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 => 1, + }; +} +/// ":". 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,39 @@ pub fn mergeAt(gap: f32) f32 { return 36 * ct * ct; } +/// 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 }; +/// 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: Box, + b: [2]Box, + region: dvui.Rect, + hole: dvui.Rect, +}; +/// 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 }; -/// 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 +89,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 +113,37 @@ 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 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); + self.hole = rs.rectToPhysical(pictures.hole); + }, + } core.LiquidField.publishEnabled(true); // Native programs compile on first use. Do not measure their fallback frame. if (!core.LiquidField.ready()) { @@ -93,7 +155,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 +199,21 @@ 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); + 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() }); } var png: std.Io.Writer.Allocating = .init(self.capture.gpa); @@ -152,6 +223,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..a519024bc 100644 --- a/src/editor/glass_testbed_tests.zig +++ b/src/editor/glass_testbed_tests.zig @@ -1,17 +1,22 @@ const std = @import("std"); +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 +33,35 @@ 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: 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); +}