diff --git a/examples/gpu-components/src/tests.zig b/examples/gpu-components/src/tests.zig index c04593de5..4eea474a1 100644 --- a/examples/gpu-components/src/tests.zig +++ b/examples/gpu-components/src/tests.zig @@ -616,7 +616,7 @@ test "gpu components display list renders stable reference snapshot" { // on checked/filled states. Update deliberately when component // rendering changes, reviewing the rendered pixels (reference render // dump or docs previews — same emitters) first. - try std.testing.expectEqual(@as(u64, 5753353894120093539), referenceSurfaceSignature(pixels)); + try std.testing.expectEqual(@as(u64, 4649016855701722499), referenceSurfaceSignature(pixels)); try expectVisiblePixel(surface.pixelRgba8(36, 36)); try expectVisiblePixel(surface.pixelRgba8(92, 88)); try expectVisiblePixel(surface.pixelRgba8(330, 160)); @@ -693,7 +693,7 @@ test "gpu components display list renders stable geist reference snapshot" { const scratch = try std.testing.allocator.alloc(u8, pixel_count); defer std.testing.allocator.free(scratch); const surface = try renderComponentsReferenceSurface(componentTokensForPack(.geist, .light), pixels, scratch); - try std.testing.expectEqual(@as(u64, 8766972054033897563), referenceSurfaceSignature(pixels)); + try std.testing.expectEqual(@as(u64, 937194073311940051), referenceSurfaceSignature(pixels)); try expectVisiblePixel(surface.pixelRgba8(36, 36)); try expectVisiblePixel(surface.pixelRgba8(92, 88)); try expectVisiblePixel(surface.pixelRgba8(330, 160)); @@ -785,12 +785,12 @@ test "geist button group renders the detached secondary-tab register in both sch // one step short of the pack's pure-black light primary — probed at // the chip's lower body, clear of the knockout label. try std.testing.expectEqual([4]u8{ 23, 23, 23, 255 }, light.pixelRgba8(30, 48)); - try std.testing.expectEqual(@as(u64, 13158221911267186466), referenceSurfaceSignature(pixels)); + try std.testing.expectEqual(@as(u64, 7474062486595489698), referenceSurfaceSignature(pixels)); const dark = try renderButtonGroupReferenceSurface(componentTokensForPack(.geist, .dark), pixels, scratch); // Dark inverts to porcelain #ededed. try std.testing.expectEqual([4]u8{ 237, 237, 237, 255 }, dark.pixelRgba8(30, 48)); - try std.testing.expectEqual(@as(u64, 9922762454948493824), referenceSurfaceSignature(pixels)); + try std.testing.expectEqual(@as(u64, 10632788448036391376), referenceSurfaceSignature(pixels)); } test "house button group keeps the attached segmented bar through the shared specimen" { @@ -804,7 +804,7 @@ test "house button group keeps the attached segmented bar through the shared spe const scratch = try std.testing.allocator.alloc(u8, button_group_surface_pixels); defer std.testing.allocator.free(scratch); _ = try renderButtonGroupReferenceSurface(componentTokens(), pixels, scratch); - try std.testing.expectEqual(@as(u64, 16337717838073850632), referenceSurfaceSignature(pixels)); + try std.testing.expectEqual(@as(u64, 2150490712731232712), referenceSurfaceSignature(pixels)); } /// Render a two-trigger tab strip (one active) on a small reference @@ -922,7 +922,7 @@ test "house tabs keep the flush pill strip through the shared specimen" { const scratch = try std.testing.allocator.alloc(u8, tabs_surface_pixels); defer std.testing.allocator.free(scratch); _ = try renderTabsReferenceSurface(componentTokens(), pixels, scratch); - try std.testing.expectEqual(@as(u64, 15995515449431694753), referenceSurfaceSignature(pixels)); + try std.testing.expectEqual(@as(u64, 16693469301949121985), referenceSurfaceSignature(pixels)); } test "gpu components house reference snapshot is reproducible through the shared per-theme path" { @@ -935,7 +935,7 @@ test "gpu components house reference snapshot is reproducible through the shared const scratch = try std.testing.allocator.alloc(u8, pixel_count); defer std.testing.allocator.free(scratch); _ = try renderComponentsReferenceSurface(componentTokens(), pixels, scratch); - try std.testing.expectEqual(@as(u64, 5753353894120093539), referenceSurfaceSignature(pixels)); + try std.testing.expectEqual(@as(u64, 4649016855701722499), referenceSurfaceSignature(pixels)); } test "gpu components catalog previews use canonical built-in foundations" { diff --git a/examples/gpu-dashboard/src/main.zig b/examples/gpu-dashboard/src/main.zig index e9ade6922..16316e24e 100644 --- a/examples/gpu-dashboard/src/main.zig +++ b/examples/gpu-dashboard/src/main.zig @@ -70,7 +70,7 @@ const expected_dashboard_interaction_command_count: usize = 70; // deliberately when rendering changes, reviewing the rendered pixels // (reference captures or the docs previews — same emitters) first; the // spot pixels below still guard basic visibility. -const expected_dashboard_reference_signature: u64 = 14864234856790650620; +const expected_dashboard_reference_signature: u64 = 7222753033888946849; const expected_dashboard_widget_node_count: usize = 48; const expected_dashboard_snapshot_widget_count: usize = 48; const refresh_command = "dashboard.refresh"; diff --git a/src/primitives/canvas/chart_tests.zig b/src/primitives/canvas/chart_tests.zig index aaca45ce5..e08fd483c 100644 --- a/src/primitives/canvas/chart_tests.zig +++ b/src/primitives/canvas/chart_tests.zig @@ -722,8 +722,8 @@ test "chart golden: line + bar + band render byte-identically in light and dark" // category labels (q1, q3) under the bars. Both themes clear with their // background token. Update deliberately when chart rendering changes, // reviewing the dumped pixels first. -const golden_light_signature: u64 = 11237334215963301158; -const golden_dark_signature: u64 = 4053146140543071333; +const golden_light_signature: u64 = 4743613392161459263; +const golden_dark_signature: u64 = 13208780493823093371; fn goldenDumpRequested() bool { if (comptime !@import("builtin").link_libc) return false; diff --git a/src/primitives/canvas/markdown_tests.zig b/src/primitives/canvas/markdown_tests.zig index b093ee0e7..b5b3375d4 100644 --- a/src/primitives/canvas/markdown_tests.zig +++ b/src/primitives/canvas/markdown_tests.zig @@ -405,7 +405,7 @@ test "the README-shaped fixture renders through the mapper and the reference ren // separators, fenced-code panels, and near-black underlined links. // Update deliberately when markdown rendering changes, reviewing the // rendered pixels first (see reference_tests.zig conventions). -const markdown_document_reference_signature: u64 = 6015448079037287912; +const markdown_document_reference_signature: u64 = 4124710367581899536; test "bare URLs autolink at word boundaries with trailing punctuation trimmed" { diff --git a/src/primitives/canvas/reference.zig b/src/primitives/canvas/reference.zig index f5e664106..862b9eda4 100644 --- a/src/primitives/canvas/reference.zig +++ b/src/primitives/canvas/reference.zig @@ -225,9 +225,11 @@ pub const ReferenceRenderSurface = struct { } pub fn renderPass(self: ReferenceRenderSurface, pass: CanvasRenderPass, clear_color: Color) Error!void { - // One-time sRGB decode table fill (see the table's doc comment); - // outside the per-pixel loops so the hot path pays no checks. + // One-time sRGB decode/encode table fills (see the tables' doc + // comments); outside the per-pixel loops so the hot path pays no + // checks. ensureSrgbToLinearByteTable(); + ensureLinearToSrgbTable(); // Fresh per-pass panel-fill budget (see the memo's doc comment). if (self.render_memo) |memo| memo.image_scale_fills_this_pass = 0; const scale = referencePassScale(pass.scale); @@ -309,7 +311,7 @@ pub const ReferenceRenderSurface = struct { continue; } const coverage = referenceRoundedRectCoverage(point, rect, radius); - if (coverage > 0) self.blendPixel(@intCast(x), @intCast(y), referenceScaleColorAlpha(referenceSampleFill(value.fill, command.transform, point), coverage), command.opacity); + if (coverage > 0) self.blendPixelCoverage(@intCast(x), @intCast(y), referenceSampleFill(value.fill, command.transform, point), coverage, command.opacity, .linear_light); } } self.memoStore(probe, pixel_rect); @@ -353,7 +355,7 @@ pub const ReferenceRenderSurface = struct { } const coverage = std.math.clamp(referenceRoundedRectCoverage(point, outer, outer_radius) - referenceRoundedRectCoverage(point, inner, inner_radius), 0, 1); if (coverage > 0) { - self.blendPixel(@intCast(x), @intCast(y), referenceScaleColorAlpha(referenceSampleFill(value.stroke.fill, command.transform, point), coverage), command.opacity); + self.blendPixelCoverage(@intCast(x), @intCast(y), referenceSampleFill(value.stroke.fill, command.transform, point), coverage, command.opacity, .linear_light); } } } @@ -389,6 +391,7 @@ pub const ReferenceRenderSurface = struct { .fill = value.fill, .transform = command.transform, .opacity = command.opacity, + .coverage_blend = .linear_light, }; vector.fillPath( value.elements, @@ -416,6 +419,7 @@ pub const ReferenceRenderSurface = struct { .fill = value.stroke.fill, .transform = command.transform, .opacity = command.opacity, + .coverage_blend = .linear_light, }; vector.strokePath( value.elements, @@ -823,11 +827,16 @@ pub const ReferenceRenderSurface = struct { if (builder.slice().len == 0) return true; // Space: nothing to ink. const pixel_rect = referencePixelRect(draw_bounds, self.width, self.height) orelse return true; + // Glyph coverage blends in sRGB, not linear light (see + // `CoverageBlend`): apparent text weight is set by how edge + // pixels darken, and re-blending them in linear light thins + // dark-on-light runs and blooms light-on-dark runs at UI sizes. var sink = ReferenceCoverageSink{ .surface = self, .fill = .{ .color = value.color }, .transform = command.transform, .opacity = command.opacity, + .coverage_blend = .srgb, }; // The outline is already in device space; TrueType interiorness // is the nonzero rule. @@ -869,18 +878,66 @@ pub const ReferenceRenderSurface = struct { self.pixels[index + 3] = out[3]; } + /// Blend one pixel whose fractional alpha is ANTI-ALIASED EDGE + /// COVERAGE (kept separate from the color's own alpha so the blend + /// can tell an AA fringe from a translucent wash — see + /// `CoverageBlend`). + fn blendPixelCoverage(self: ReferenceRenderSurface, x: usize, y: usize, color: Color, coverage: f32, opacity: f32, blend: CoverageBlend) void { + const index = (y * self.width + x) * 4; + const dst = [4]u8{ + self.pixels[index + 0], + self.pixels[index + 1], + self.pixels[index + 2], + self.pixels[index + 3], + }; + const out = blendRgba8Coverage(dst, color, coverage, opacity, blend); + self.pixels[index + 0] = out[0]; + self.pixels[index + 1] = out[1]; + self.pixels[index + 2] = out[2]; + self.pixels[index + 3] = out[3]; + } + fn findImage(self: ReferenceRenderSurface, id: ImageId) ?ReferenceImage { return findReferenceImage(self.images, id); } }; +/// Which space a shape's fractional edge coverage blends in. +/// +/// GEOMETRY — rounded rects, filled/stroked paths, icons, chart marks — +/// blends its anti-aliased edge pixels in LINEAR LIGHT. Compositing the +/// sRGB-encoded bytes directly weights half coverage far below half the +/// light (a 50% black-on-white fringe lands near 21% luminance instead +/// of 50%), so every edge grows a dark rim on light backgrounds (a light +/// halo on dark ones) that reads as jagged even though the coverage +/// values are correct. Decoding to linear light, blending, and +/// re-encoding removes the rim. +/// +/// GLYPHS stay in sRGB. Text coverage funnels through the exact same +/// vector core, but apparent text WEIGHT is a product of how edge pixels +/// darken: the same coverage blended in linear light renders visibly +/// thinner dark-on-light runs and bloomier light-on-dark runs at UI +/// sizes, and the toolkit's type ramp was tuned against sRGB-blended +/// stems. sRGB glyph compositing also matches the packet-backed macOS +/// text pipeline, so mixed CPU/host frames keep one text weight. +/// +/// Only opaque-source fractional-coverage pixels differ between the two +/// modes: fully covered pixels short-circuit identically in either +/// space, and translucent sources (washes, scrims, faded layers) keep +/// sRGB blending so overlay brightness — tuned in sRGB terms — is +/// untouched and an AA edge never diverges from the interior it borders. +const CoverageBlend = enum { linear_light, srgb }; + /// Per-pixel coverage sink for the vector core: samples the fill at the -/// pixel center and blends with the coverage folded into alpha. +/// pixel center and blends with the coverage, in the blend space the +/// emitter declared (geometry linear-light, glyphs sRGB — see +/// `CoverageBlend`). const ReferenceCoverageSink = struct { surface: ReferenceRenderSurface, fill: Fill, transform: Affine, opacity: f32, + coverage_blend: CoverageBlend, pub fn pixel(self: *ReferenceCoverageSink, x: i32, y: i32, coverage: f32) void { if (x < 0 or y < 0) return; @@ -889,7 +946,7 @@ const ReferenceCoverageSink = struct { if (px >= self.surface.width or py >= self.surface.height) return; const point = referencePixelCenter(px, py); const color = referenceSampleFill(self.fill, self.transform, point); - self.surface.blendPixel(px, py, referenceScaleColorAlpha(color, coverage), self.opacity); + self.surface.blendPixelCoverage(px, py, color, coverage, self.opacity, self.coverage_blend); } }; @@ -1339,6 +1396,31 @@ fn ensureSrgbToLinearByteTable() void { srgb_to_linear_byte_table_ready = true; } +/// Precomputed `referenceLinearToSrgb` over evenly spaced linear inputs: +/// the linear-light coverage blend re-encodes three channels per fringe +/// pixel, and each direct encode costs a `pow`. 4096 entries keep the +/// nearest-entry result within one 8-bit step of direct evaluation: the +/// curve is steepest near black (slope 12.92), where one table cell +/// still spans under one output byte step, so the looked-up value sits +/// within half a step of exact and the final byte rounding moves by at +/// most one level. Same benign-race lazy fill as the decode table above. +const linear_to_srgb_table_len = 4096; +var linear_to_srgb_table: [linear_to_srgb_table_len]f32 = undefined; +var linear_to_srgb_table_ready: bool = false; + +fn ensureLinearToSrgbTable() void { + if (linear_to_srgb_table_ready) return; + for (&linear_to_srgb_table, 0..) |*value, index| { + value.* = referenceLinearToSrgb(@as(f32, @floatFromInt(index)) / (linear_to_srgb_table_len - 1)); + } + linear_to_srgb_table_ready = true; +} + +fn referenceLinearToSrgbLut(value: f32) f32 { + const index: usize = @intFromFloat(@round(std.math.clamp(value, 0, 1) * (linear_to_srgb_table_len - 1))); + return linear_to_srgb_table[index]; +} + fn referencePremultiplySrgba8(pixel: [4]u8) ReferencePremultipliedLinearColor { const alpha = @as(f32, @floatFromInt(pixel[3])) / 255.0; return .{ @@ -1528,6 +1610,53 @@ fn blendRgba8(dst: [4]u8, src: Color, opacity: f32) [4]u8 { }; } +/// Source-over with the source's fractional alpha split into COLOR alpha +/// and EDGE COVERAGE, so the blend space can key off what the alpha +/// means (see `CoverageBlend`). +/// +/// Linear-light blending is reserved for the one case the split targets: +/// an effectively opaque source's anti-aliased fringe. Everything else — +/// sRGB-mode callers (glyphs), fully covered pixels, and translucent +/// sources — folds coverage into alpha and takes the historical sRGB +/// blend, byte for byte. That routing is also the cost story: interiors +/// (`coverage >= 1`) and washes never pay a decode/encode round-trip, so +/// the linear math runs only on the thin edge band, where the two table +/// lookups per channel replace `pow` evaluations. +fn blendRgba8Coverage(dst: [4]u8, src: Color, coverage: f32, opacity: f32, blend: CoverageBlend) [4]u8 { + const cov = std.math.clamp(coverage, 0, 1); + const src_a = std.math.clamp(src.a, 0, 1) * std.math.clamp(opacity, 0, 1); + if (blend == .srgb or cov <= 0 or cov >= 1 or src_a < 1) { + return blendRgba8(dst, referenceScaleColorAlpha(src, cov), opacity); + } + + // Belt over the renderPass-level fills for direct callers (unit + // tests, future paths): two predictable branches per fringe pixel. + ensureSrgbToLinearByteTable(); + ensureLinearToSrgbTable(); + + // Alpha stays in coverage space — it counts covered area, not light + // — so the alpha math is IDENTICAL to the sRGB path; only the color + // channels decode to linear light. `out_a >= cov > 0` here, so the + // straight-alpha un-premultiply divide is safe. The source decodes + // through the same byte quantization its coverage-1 pixels store, + // so a fringe converges exactly onto the interior bytes it borders. + const dst_a = @as(f32, @floatFromInt(dst[3])) / 255.0; + const out_a = cov + dst_a * (1 - cov); + const src_r = srgb_to_linear_byte_table[colorChannelToByte(src.r)]; + const src_g = srgb_to_linear_byte_table[colorChannelToByte(src.g)]; + const src_b = srgb_to_linear_byte_table[colorChannelToByte(src.b)]; + const dst_r = srgb_to_linear_byte_table[dst[0]]; + const dst_g = srgb_to_linear_byte_table[dst[1]]; + const dst_b = srgb_to_linear_byte_table[dst[2]]; + const dst_weight = dst_a * (1 - cov); + return .{ + colorChannelToByte(referenceLinearToSrgbLut((src_r * cov + dst_r * dst_weight) / out_a)), + colorChannelToByte(referenceLinearToSrgbLut((src_g * cov + dst_g * dst_weight) / out_a)), + colorChannelToByte(referenceLinearToSrgbLut((src_b * cov + dst_b * dst_weight) / out_a)), + colorChannelToByte(out_a), + }; +} + fn colorChannelToByte(value: f32) u8 { return @intFromFloat(@round(std.math.clamp(value, 0, 1) * 255.0)); } diff --git a/src/primitives/canvas/reference_tests.zig b/src/primitives/canvas/reference_tests.zig index 6fd7ca231..1e4fb9acb 100644 --- a/src/primitives/canvas/reference_tests.zig +++ b/src/primitives/canvas/reference_tests.zig @@ -788,6 +788,168 @@ test "rounded-rect coverage matches supersampled ground truth with no silhouette } } +// --------------------------------------------------------------------------- +// Coverage blend-space split. +// +// Geometry (paths, rounded rects) blends its anti-aliased edge coverage +// in linear light; glyph coverage blends in sRGB to preserve text +// weight. These tests pin the split from both sides with independently +// computed ground truth, so a refactor can never silently swap the two +// (a swap fails BOTH assertions, loudly). + +/// The exact sRGB decode the renderer's 256-entry byte table holds. +fn blendSplitSrgbToLinear(value: f32) f32 { + const channel = std.math.clamp(value, 0, 1); + if (channel <= 0.04045) return channel / 12.92; + return std.math.pow(f32, (channel + 0.055) / 1.055, 2.4); +} + +fn blendSplitLinearToSrgb(value: f32) f32 { + const channel = std.math.clamp(value, 0, 1); + if (channel <= 0.0031308) return channel * 12.92; + return 1.055 * std.math.pow(f32, channel, 1.0 / 2.4) - 0.055; +} + +/// The renderer's encode-table lookup, replicated: nearest of 4096 +/// evenly spaced entries, then the final byte rounding. +fn blendSplitEncodeByte(value: f32) u8 { + const index = @round(std.math.clamp(value, 0, 1) * 4095.0); + return @intFromFloat(@round(blendSplitLinearToSrgb(index / 4095.0) * 255.0)); +} + +/// Linear-light coverage blend of an opaque source byte over an opaque +/// destination byte, from the same LUT math the renderer tabulates. +fn blendSplitLinearByte(src: u8, dst: u8, coverage: f32) u8 { + const src_linear = blendSplitSrgbToLinear(@as(f32, @floatFromInt(src)) / 255.0); + const dst_linear = blendSplitSrgbToLinear(@as(f32, @floatFromInt(dst)) / 255.0); + return blendSplitEncodeByte(src_linear * coverage + dst_linear * (1 - coverage)); +} + +/// sRGB-space coverage blend of the same pixel (the historical fold). +fn blendSplitSrgbByte(src: u8, dst: u8, coverage: f32) u8 { + const src_f = @as(f32, @floatFromInt(src)) / 255.0; + const dst_f = @as(f32, @floatFromInt(dst)) / 255.0; + return @intFromFloat(@round((src_f * coverage + dst_f * (1 - coverage)) * 255.0)); +} + +fn blendSplitRenderPass(surface: ReferenceRenderSurface, command: RenderCommand, clear: Color, width: usize, height: usize) !void { + const pass = CanvasRenderPass{ + .surface_size = geometry.SizeF.init(@floatFromInt(width), @floatFromInt(height)), + .scale = 1, + .full_repaint = true, + .commands = &.{command}, + }; + try surface.renderPass(pass, clear); +} + +test "geometry edge coverage blends in linear light, computed from the LUT math" { + // A black path whose right edge splits pixel column 12 exactly in + // half: the vector core reports coverage 0.5 there, interiors 1. + const split_width: usize = 24; + const split_height: usize = 16; + const white = Color{ .r = 1, .g = 1, .b = 1, .a = 1 }; + const black = Color{ .r = 0, .g = 0, .b = 0, .a = 1 }; + const bounds = geometry.RectF.init(0, 0, split_width, split_height); + const elements = [_]PathElement{ + .{ .verb = .move_to, .points = .{ geometry.PointF.init(4, 4), geometry.PointF.zero(), geometry.PointF.zero() } }, + .{ .verb = .line_to, .points = .{ geometry.PointF.init(12.5, 4), geometry.PointF.zero(), geometry.PointF.zero() } }, + .{ .verb = .line_to, .points = .{ geometry.PointF.init(12.5, 12), geometry.PointF.zero(), geometry.PointF.zero() } }, + .{ .verb = .line_to, .points = .{ geometry.PointF.init(4, 12), geometry.PointF.zero(), geometry.PointF.zero() } }, + .{ .verb = .close, .points = .{ geometry.PointF.zero(), geometry.PointF.zero(), geometry.PointF.zero() } }, + }; + + const linear_edge = blendSplitLinearByte(0, 255, 0.5); + const srgb_edge = blendSplitSrgbByte(0, 255, 0.5); + // The split is only observable if the two spaces disagree here. + try std.testing.expect(linear_edge != srgb_edge); + + // fill_path: the vector-core geometry route. + { + var pixels: [split_width * split_height * 4]u8 = undefined; + const surface = try ReferenceRenderSurface.init(split_width, split_height, &pixels); + try blendSplitRenderPass(surface, .{ + .command = .{ .fill_path = .{ .elements = &elements, .fill = .{ .color = black } } }, + .local_bounds = bounds, + .bounds = bounds, + }, white, split_width, split_height); + // Fully covered interior pixels stay bit-identical to the plain + // sRGB blend: an opaque source at coverage 1 is a copy in either + // space. + try expectPixelRgba8(.{ 0, 0, 0, 255 }, surface, 8, 8); + // The half-covered edge pixel holds the linear-light value. + try expectPixelRgba8(.{ linear_edge, linear_edge, linear_edge, 255 }, surface, 12, 8); + } + + // fill_rounded_rect: the signed-distance geometry route. Its right + // edge is a straight segment at the same half-pixel boundary (the + // radius-2 corners are far from row 8), so coverage is 0.5 again. + { + var pixels: [split_width * split_height * 4]u8 = undefined; + const surface = try ReferenceRenderSurface.init(split_width, split_height, &pixels); + try blendSplitRenderPass(surface, .{ + .command = .{ .fill_rounded_rect = .{ .rect = geometry.RectF.init(4, 4, 8.5, 8), .radius = Radius.all(2), .fill = .{ .color = black } } }, + .local_bounds = bounds, + .bounds = bounds, + }, white, split_width, split_height); + try expectPixelRgba8(.{ 0, 0, 0, 255 }, surface, 8, 8); + try expectPixelRgba8(.{ linear_edge, linear_edge, linear_edge, 255 }, surface, 12, 8); + } +} + +test "glyph edge coverage blends in sRGB, not linear light" { + // The same discrimination from the text side: render one glyph twice + // — once over transparent (whose alpha channel IS the coverage, in + // any blend space) and once over white — then check every fringe + // pixel of the white render against both models. Text must track the + // sRGB fold and stay far from the linear-light value at mid + // coverage, so inverting the sink's blend space can never pass. + const glyph_width: usize = 32; + const glyph_height: usize = 48; + const white = Color{ .r = 1, .g = 1, .b = 1, .a = 1 }; + const black = Color{ .r = 0, .g = 0, .b = 0, .a = 1 }; + const clear = Color{ .r = 0, .g = 0, .b = 0, .a = 0 }; + const bounds = geometry.RectF.init(0, 0, glyph_width, glyph_height); + const command = RenderCommand{ + .command = .{ .draw_text = .{ .size = 32, .origin = geometry.PointF.init(4, 40), .color = black, .text = "o" } }, + .local_bounds = bounds, + .bounds = bounds, + }; + + var coverage_pixels: [glyph_width * glyph_height * 4]u8 = undefined; + const coverage_surface = try ReferenceRenderSurface.init(glyph_width, glyph_height, &coverage_pixels); + try blendSplitRenderPass(coverage_surface, command, clear, glyph_width, glyph_height); + + var blended_pixels: [glyph_width * glyph_height * 4]u8 = undefined; + const blended_surface = try ReferenceRenderSurface.init(glyph_width, glyph_height, &blended_pixels); + try blendSplitRenderPass(blended_surface, command, white, glyph_width, glyph_height); + + var mid_coverage_pixels: usize = 0; + var y: usize = 0; + while (y < glyph_height) : (y += 1) { + var x: usize = 0; + while (x < glyph_width) : (x += 1) { + const coverage_byte = coverage_surface.pixelRgba8(x, y)[3]; + if (coverage_byte == 0 or coverage_byte == 255) continue; + const coverage = @as(f32, @floatFromInt(coverage_byte)) / 255.0; + const rendered: i32 = blended_surface.pixelRgba8(x, y)[0]; + const srgb_expected: i32 = blendSplitSrgbByte(0, 255, coverage); + // One level of slack: the recovered coverage byte is itself + // rounded, so the re-derived sRGB fold can sit one step off + // the value blended from the unrounded coverage. + try std.testing.expect(@max(rendered - srgb_expected, srgb_expected - rendered) <= 1); + // Mid-coverage fringes are where the spaces disagree most; + // hold text a wide margin away from the linear value there. + if (coverage_byte >= 64 and coverage_byte <= 192) { + mid_coverage_pixels += 1; + const linear_expected: i32 = blendSplitLinearByte(0, 255, coverage); + try std.testing.expect(linear_expected - rendered >= 16); + } + } + } + // The glyph must actually have exercised the fringe band. + try std.testing.expect(mid_coverage_pixels >= 4); +} + test "reference renderer applies clip transform and opacity" { const commands = [_]CanvasCommand{ .{ .push_clip = .{ .rect = geometry.RectF.init(1, 1, 2, 2) } }, @@ -965,13 +1127,16 @@ test "reference renderer strokes paths: butt caps end at the segment, round caps // One horizontal unit-width segment, rendered once per cap shape. // The end pixels (0,1) and (2,1) are where the caps live: the butt // cap stops at the endpoint (the segment covers exactly half of each - // end pixel — 128 after coverage rounding), while the round cap - // bulges a half-width semicircle past it (75% coverage per the - // anti-aliased vector core). Interior and off-stroke pixels are - // cap-independent. + // end pixel), while the round cap bulges a half-width semicircle + // past it (75% coverage per the anti-aliased vector core). Interior + // and off-stroke pixels are cap-independent. The expected end-pixel + // bytes are the LINEAR-LIGHT encodings of those coverages over black + // — geometry edge coverage blends in linear light (see the renderer's + // `CoverageBlend`), so 50% coverage of a 255 channel re-encodes to + // 188 and 75% to 225, not the 128/191 sRGB-space folds. const cases = [_]struct { cap: canvas.LineCap, end_coverage: u8 }{ - .{ .cap = .butt, .end_coverage = 128 }, - .{ .cap = .round, .end_coverage = 191 }, + .{ .cap = .butt, .end_coverage = 188 }, + .{ .cap = .round, .end_coverage = 225 }, }; for (cases) |case| { const elements = [_]PathElement{ diff --git a/src/primitives/canvas/widget_builtin_tests.zig b/src/primitives/canvas/widget_builtin_tests.zig index c4db2f494..171456aa5 100644 --- a/src/primitives/canvas/widget_builtin_tests.zig +++ b/src/primitives/canvas/widget_builtin_tests.zig @@ -416,9 +416,10 @@ test "icon widgets render built-in vector icons as tinted path commands" { if (pixels[index] < 250) ink += 1; } try std.testing.expect(ink > 20); - // Regenerated for the house default palette: the tint (the text - // token) moved from #09090b to #0a0a0a; same check-mark coverage. - try std.testing.expectEqual(@as(u64, 1722938743772709742), support.referenceSurfaceSignature(&pixels)); + // Regenerated when geometry edge coverage moved to linear-light + // blending (the icon is a vector path): same coverage, smoother + // fringe bytes. + try std.testing.expectEqual(@as(u64, 5692773564953859754), support.referenceSurfaceSignature(&pixels)); // A non-registry text keeps the historical glyph rendering. const glyph = Widget{ @@ -490,7 +491,7 @@ test "checkbox check mark strokes one anti-aliased vector path" { } } try std.testing.expect(partial >= 4); - try std.testing.expectEqual(@as(u64, 12485073109273199295), support.referenceSurfaceSignature(&pixels)); + try std.testing.expectEqual(@as(u64, 10271374105851145327), support.referenceSurfaceSignature(&pixels)); } test "a builder accumulating two widget trees keeps each checkbox mark's own geometry" {