From df3c48213fc513152105ad2ad84dda6d57d56f24 Mon Sep 17 00:00:00 2001 From: funbiscuit Date: Fri, 9 Oct 2026 14:31:39 +0300 Subject: [PATCH 1/9] Add mist-core: dependency-free core module, break Generator back-imports --- build.zig | 8 +++ src/Generator.zig | 114 ++---------------------------------- src/coloring.zig | 2 +- src/core.zig | 123 +++++++++++++++++++++++++++++++++++++++ src/error_correction.zig | 4 +- 5 files changed, 138 insertions(+), 113 deletions(-) create mode 100644 src/core.zig diff --git a/build.zig b/build.zig index ff56c32..5d71bdf 100644 --- a/build.zig +++ b/build.zig @@ -3,9 +3,17 @@ const std = @import("std"); pub fn build(b: *std.Build) void { const optimize = b.standardOptimizeOption(.{}); const target = b.standardTargetOptions(.{}); + + // Dependency-free SDF core; usable on any target, including freestanding + // ones (wasm), as it neither links nor imports freetype/libc. + const core_module = b.addModule("mist-core", .{ + .root_source_file = b.path("src/core.zig"), + }); + _ = b.addModule("mist", .{ .root_source_file = b.path("src/Generator.zig"), .imports = &.{ + .{ .name = "mist-core", .module = core_module }, .{ .name = "mach-freetype", .module = b.dependency("mach_freetype", .{ diff --git a/src/Generator.zig b/src/Generator.zig index ee6bee6..8326c2d 100644 --- a/src/Generator.zig +++ b/src/Generator.zig @@ -1,19 +1,17 @@ const std = @import("std"); +const core = @import("core.zig"); const ft = @import("mach-freetype"); const pack = @import("turbopack"); const Bitmap = @import("bitmap.zig").Bitmap; const coloring = @import("coloring.zig"); -const EdgeColor = coloring.EdgeColor; -const EdgeSegment = @import("EdgeSegment.zig"); const error_correction = @import("error_correction.zig"); const math = @import("math.zig"); const Scanline = @import("Scanline.zig"); const Shape = @import("Shape.zig"); const Vec2 = @Vector(2, f64); -const f64_nan = std.math.nan(f64); const Generator = @This(); @@ -71,35 +69,7 @@ pub const GeneratedAtlas = struct { } }; -pub const SdfType = enum { - sdf, - psdf, - msdf, - mtsdf, - /// Experimental: A packed BGR MSDF where each channel is 10-bit, - /// with a 2-bit alpha channel that is ignored (set to u2 max). - /// - /// Can prove useful in place of MSDFs as native `R8G8B8_X` (and equivalent) - /// format support is scarce. Additionally, 3-channel images are often padded - /// to have an alignment of 4 bytes per pixel on a lot of hardware, - /// which results in the final byte getting wasted on such formats. - msdf10, - - pub fn numChannels(self: SdfType) u8 { - return switch (self) { - .sdf, .psdf => 1, - .msdf, .msdf10 => 3, - .mtsdf => 4, - }; - } - - pub fn requiresColoring(self: SdfType) bool { - return switch (self) { - .msdf, .msdf10, .mtsdf => true, - else => false, - }; - } -}; +pub const SdfType = core.SdfType; pub const ColoringMethod = enum { simple, @@ -736,81 +706,7 @@ fn msdfSignCorrection( }; } -fn pxRangeNorm(dist: f64, px_range: f64) f64 { - return (dist + px_range / 2.0) / px_range; -} - -pub fn findDistanceAt( - comptime sdf_type: SdfType, - shape: Shape, - p: Vec2, - px_range: f64, -) switch (sdf_type) { - .sdf, .psdf => f64, - inline .msdf, .msdf10, .mtsdf => |ty| [ty.numChannels()]f64, -} { - const PsdfData = struct { - dist: EdgeSegment.SignedDist = .init, - edge: ?*const EdgeSegment = null, - point_pos: EdgeSegment.PointPosition = .within_segment, - }; - - var true_ch: EdgeSegment.SignedDist = .init; - var perp_ch: [if (sdf_type == .psdf) 1 else 3]PsdfData = @splat(.{}); - for (shape.contours.items) |contour| for (contour.edges.items) |*edge| { - const dist, const point_pos = edge.signedDistance(p); - - switch (sdf_type) { - .sdf, .mtsdf => { - if (dist.lessThan(true_ch)) - true_ch = dist; - }, - .psdf => { - if (dist.lessThan(perp_ch[0].dist)) perp_ch[0] = .{ - .dist = dist, - .edge = edge, - .point_pos = point_pos, - }; - }, - else => {}, - } - - if (sdf_type != .sdf and sdf_type != .psdf) - for ([_]struct { channel: EdgeColor, target: *PsdfData }{ - .{ .channel = .red, .target = &perp_ch[0] }, - .{ .channel = .green, .target = &perp_ch[1] }, - .{ .channel = .blue, .target = &perp_ch[2] }, - }) |params| - if (edge.color.hasChannel(params.channel) and dist.lessThan(params.target.dist)) { - params.target.* = .{ - .dist = dist, - .edge = edge, - .point_pos = point_pos, - }; - }; - }; - - if (sdf_type != .sdf) for (&perp_ch) |*psdf| { - if (psdf.edge) |edge| - edge.perpDistConvert(&psdf.dist, p, psdf.point_pos); - }; - - return switch (sdf_type) { - .sdf => pxRangeNorm(true_ch.distance, px_range), - .psdf => pxRangeNorm(perp_ch[0].dist.distance, px_range), - .msdf, .msdf10 => .{ - pxRangeNorm(perp_ch[0].dist.distance, px_range), - pxRangeNorm(perp_ch[1].dist.distance, px_range), - pxRangeNorm(perp_ch[2].dist.distance, px_range), - }, - .mtsdf => .{ - pxRangeNorm(perp_ch[0].dist.distance, px_range), - pxRangeNorm(perp_ch[1].dist.distance, px_range), - pxRangeNorm(perp_ch[2].dist.distance, px_range), - pxRangeNorm(true_ch.distance, px_range), - }, - }; -} +pub const findDistanceAt = core.findDistanceAt; fn generate( comptime sdf_type: SdfType, @@ -905,6 +801,4 @@ fn ftCubicTo( return 0; } -pub fn f64i(int: anytype) f64 { - return @floatFromInt(int); -} +pub const f64i = core.f64i; diff --git a/src/coloring.zig b/src/coloring.zig index 8f6d0ff..42aa600 100644 --- a/src/coloring.zig +++ b/src/coloring.zig @@ -1,7 +1,7 @@ const std = @import("std"); const EdgeSegment = @import("EdgeSegment.zig"); -const f64i = @import("Generator.zig").f64i; +const f64i = @import("core.zig").f64i; const math = @import("math.zig"); const Shape = @import("Shape.zig"); diff --git a/src/core.zig b/src/core.zig new file mode 100644 index 0000000..5cd06f3 --- /dev/null +++ b/src/core.zig @@ -0,0 +1,123 @@ +//! Dependency-free SDF generation core. +//! Has no dependencies, so it's usable on freestanding targets. + +pub const coloring = @import("coloring.zig"); +pub const error_correction = @import("error_correction.zig"); +pub const math = @import("math.zig"); +pub const EdgeSegment = @import("EdgeSegment.zig"); +pub const Scanline = @import("Scanline.zig"); +pub const Shape = @import("Shape.zig"); + +const EdgeColor = coloring.EdgeColor; + +const Vec2 = @Vector(2, f64); + +pub const SdfType = enum { + sdf, + psdf, + msdf, + mtsdf, + /// Experimental: A packed BGR MSDF where each channel is 10-bit, + /// with a 2-bit alpha channel that is ignored (set to u2 max). + /// + /// Can prove useful in place of MSDFs as native `R8G8B8_X` (and equivalent) + /// format support is scarce. Additionally, 3-channel images are often padded + /// to have an alignment of 4 bytes per pixel on a lot of hardware, + /// which results in the final byte getting wasted on such formats. + msdf10, + + pub fn numChannels(self: SdfType) u8 { + return switch (self) { + .sdf, .psdf => 1, + .msdf, .msdf10 => 3, + .mtsdf => 4, + }; + } + + pub fn requiresColoring(self: SdfType) bool { + return switch (self) { + .msdf, .msdf10, .mtsdf => true, + else => false, + }; + } +}; + +fn pxRangeNorm(dist: f64, px_range: f64) f64 { + return (dist + px_range / 2.0) / px_range; +} + +pub fn findDistanceAt( + comptime sdf_type: SdfType, + shape: Shape, + p: Vec2, + px_range: f64, +) switch (sdf_type) { + .sdf, .psdf => f64, + inline .msdf, .msdf10, .mtsdf => |ty| [ty.numChannels()]f64, +} { + const PsdfData = struct { + dist: EdgeSegment.SignedDist = .init, + edge: ?*const EdgeSegment = null, + point_pos: EdgeSegment.PointPosition = .within_segment, + }; + + var true_ch: EdgeSegment.SignedDist = .init; + var perp_ch: [if (sdf_type == .psdf) 1 else 3]PsdfData = @splat(.{}); + for (shape.contours.items) |contour| for (contour.edges.items) |*edge| { + const dist, const point_pos = edge.signedDistance(p); + + switch (sdf_type) { + .sdf, .mtsdf => { + if (dist.lessThan(true_ch)) + true_ch = dist; + }, + .psdf => { + if (dist.lessThan(perp_ch[0].dist)) perp_ch[0] = .{ + .dist = dist, + .edge = edge, + .point_pos = point_pos, + }; + }, + else => {}, + } + + if (sdf_type != .sdf and sdf_type != .psdf) + for ([_]struct { channel: EdgeColor, target: *PsdfData }{ + .{ .channel = .red, .target = &perp_ch[0] }, + .{ .channel = .green, .target = &perp_ch[1] }, + .{ .channel = .blue, .target = &perp_ch[2] }, + }) |params| + if (edge.color.hasChannel(params.channel) and dist.lessThan(params.target.dist)) { + params.target.* = .{ + .dist = dist, + .edge = edge, + .point_pos = point_pos, + }; + }; + }; + + if (sdf_type != .sdf) for (&perp_ch) |*psdf| { + if (psdf.edge) |edge| + edge.perpDistConvert(&psdf.dist, p, psdf.point_pos); + }; + + return switch (sdf_type) { + .sdf => pxRangeNorm(true_ch.distance, px_range), + .psdf => pxRangeNorm(perp_ch[0].dist.distance, px_range), + .msdf, .msdf10 => .{ + pxRangeNorm(perp_ch[0].dist.distance, px_range), + pxRangeNorm(perp_ch[1].dist.distance, px_range), + pxRangeNorm(perp_ch[2].dist.distance, px_range), + }, + .mtsdf => .{ + pxRangeNorm(perp_ch[0].dist.distance, px_range), + pxRangeNorm(perp_ch[1].dist.distance, px_range), + pxRangeNorm(perp_ch[2].dist.distance, px_range), + pxRangeNorm(true_ch.distance, px_range), + }, + }; +} + +pub fn f64i(int: anytype) f64 { + return @floatFromInt(int); +} diff --git a/src/error_correction.zig b/src/error_correction.zig index 1ffac1c..9d83274 100644 --- a/src/error_correction.zig +++ b/src/error_correction.zig @@ -2,8 +2,8 @@ const std = @import("std"); const Bitmap = @import("bitmap.zig").Bitmap; const EdgeSegment = @import("EdgeSegment.zig"); -const f64i = @import("Generator.zig").f64i; -const findDistanceAt = @import("Generator.zig").findDistanceAt; +const f64i = @import("core.zig").f64i; +const findDistanceAt = @import("core.zig").findDistanceAt; const math = @import("math.zig"); const median = math.median; const Shape = @import("Shape.zig"); From bb3b402f5e23502d8b35a5efcb34d396c16e3b3a Mon Sep 17 00:00:00 2001 From: funbiscuit Date: Fri, 9 Oct 2026 14:46:35 +0300 Subject: [PATCH 2/9] Fix wasm32 portability and never-analyzed Scanline code --- src/Scanline.zig | 25 +++++++++++++------------ src/coloring.zig | 12 ++++++------ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/Scanline.zig b/src/Scanline.zig index ea1ca10..7af680d 100644 --- a/src/Scanline.zig +++ b/src/Scanline.zig @@ -36,21 +36,21 @@ pub fn overlap(a: Scanline, b: Scanline, x_from: f64, x_to: f64, fill_rule: Fill var total: f64 = 0.0; var a_inside = false; var b_inside = false; - var ai: i32 = 0; - var bi: i32 = 0; - var ax = if (!a.intersections.empty()) a.intersections[ai].x else x_to; - var bx = if (!b.intersections.empty()) b.intersections[bi].x else x_to; + var ai: usize = 0; + var bi: usize = 0; + var ax = if (a.intersections.items.len != 0) a.intersections.items[ai].x else x_to; + var bx = if (b.intersections.items.len != 0) b.intersections.items[bi].x else x_to; while (ax < x_from or bx < x_from) { const x_next = @min(ax, bx); if (ax == x_next and ai < a.intersections.items.len) { a_inside = interpretFillRule(a.intersections.items[ai].dir, fill_rule); ai += 1; - ax = if (ai < a.intersections.items.len) a.intersections[ai].x else x_to; + ax = if (ai < a.intersections.items.len) a.intersections.items[ai].x else x_to; } if (bx == x_next and bi < b.intersections.items.len) { - b_inside = interpretFillRule(b.intersections[bi].dir, fill_rule); + b_inside = interpretFillRule(b.intersections.items[bi].dir, fill_rule); bi += 1; - bx = if (bi < b.intersections.items.len) b.intersections[bi].x else x_to; + bx = if (bi < b.intersections.items.len) b.intersections.items[bi].x else x_to; } } var x = x_from; @@ -58,14 +58,14 @@ pub fn overlap(a: Scanline, b: Scanline, x_from: f64, x_to: f64, fill_rule: Fill const x_next = @min(ax, bx); if (a_inside == b_inside) total += x_next - x; if (ax == x_next and ai < a.intersections.items.len) { - a_inside = interpretFillRule(a.intersections[ai].dir, fill_rule); + a_inside = interpretFillRule(a.intersections.items[ai].dir, fill_rule); ai += 1; - ax = if (ai < a.intersections.items.len) a.intersections[ai].x else x_to; + ax = if (ai < a.intersections.items.len) a.intersections.items[ai].x else x_to; } if (bx == x_next and bi < b.intersections.items.len) { - b_inside = interpretFillRule(b.intersections[bi].dir, fill_rule); + b_inside = interpretFillRule(b.intersections.items[bi].dir, fill_rule); bi += 1; - bx = if (bi < b.intersections.items.len) b.intersections[bi].x else x_to; + bx = if (bi < b.intersections.items.len) b.intersections.items[bi].x else x_to; } x = x_next; } @@ -74,7 +74,8 @@ pub fn overlap(a: Scanline, b: Scanline, x_from: f64, x_to: f64, fill_rule: Fill } pub fn countIntersections(self: *Scanline, x: f64) i32 { - return (self.moveTo(x) orelse return 0) + 1; + const index = self.moveTo(x) orelse return 0; + return @intCast(index + 1); } pub fn sumIntersections(self: *Scanline, x: f64) i32 { diff --git a/src/coloring.zig b/src/coloring.zig index 42aa600..cfb03c9 100644 --- a/src/coloring.zig +++ b/src/coloring.zig @@ -45,7 +45,7 @@ pub const EdgeColor = enum(u8) { pub fn init(rng: *std.Random.DefaultPrng) EdgeColor { const two_ch: [3]EdgeColor = .{ .red_green, .red_blue, .green_blue }; - return two_ch[rng.next() % two_ch.len]; + return two_ch[@intCast(rng.next() % two_ch.len)]; } fn cmyWithExcls(excl_1: EdgeColor, excl_2: EdgeColor) EdgeColor { @@ -90,7 +90,7 @@ pub const EdgeColor = enum(u8) { switch (self.*) { inline .red_green, .red_blue, .green_blue => |c| { const switch_targets = comptime colorSwitchTargets(c); - self.* = switch_targets[rng.next() % switch_targets.len]; + self.* = switch_targets[@intCast(rng.next() % switch_targets.len)]; }, else => unreachable, } @@ -379,19 +379,19 @@ fn colorGraph(colors: []EdgeColor, edge_matrix: []const []const bool, rng: *std. switch (banned_colors) { .{} => { const rem_colors: [3]EdgeColor = .{ .red_green, .red_blue, .green_blue }; - color.* = rem_colors[rng.next() % rem_colors.len]; + color.* = rem_colors[@intCast(rng.next() % rem_colors.len)]; }, .{ .red_green = true } => { const rem_colors: [2]EdgeColor = .{ .red_blue, .green_blue }; - color.* = rem_colors[rng.next() % rem_colors.len]; + color.* = rem_colors[@intCast(rng.next() % rem_colors.len)]; }, .{ .red_blue = true } => { const rem_colors: [2]EdgeColor = .{ .red_green, .green_blue }; - color.* = rem_colors[rng.next() % rem_colors.len]; + color.* = rem_colors[@intCast(rng.next() % rem_colors.len)]; }, .{ .green_blue = true } => { const rem_colors: [2]EdgeColor = .{ .red_green, .red_blue }; - color.* = rem_colors[rng.next() % rem_colors.len]; + color.* = rem_colors[@intCast(rng.next() % rem_colors.len)]; }, .{ .red_green = true, .red_blue = true } => color.* = .green_blue, .{ .red_green = true, .green_blue = true } => color.* = .red_blue, From e4515f01f732dc24a7c59b6480bf7ee46d2cfda3 Mon Sep 17 00:00:00 2001 From: funbiscuit Date: Fri, 9 Oct 2026 15:01:32 +0300 Subject: [PATCH 3/9] Move the SDF config/result types to core.zig --- src/Generator.zig | 90 ++++++----------------------------------------- src/core.zig | 89 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 80 deletions(-) diff --git a/src/Generator.zig b/src/Generator.zig index 8326c2d..b867280 100644 --- a/src/Generator.zig +++ b/src/Generator.zig @@ -5,7 +5,6 @@ const ft = @import("mach-freetype"); const pack = @import("turbopack"); const Bitmap = @import("bitmap.zig").Bitmap; -const coloring = @import("coloring.zig"); const error_correction = @import("error_correction.zig"); const math = @import("math.zig"); const Scanline = @import("Scanline.zig"); @@ -23,13 +22,7 @@ pub const FontMetrics = struct { underline_thickness: f64, }; -pub const GlyphMetrics = struct { - advance: f64, - bearing_x: f64, - bearing_y: f64, - width: u16, - height: u16, -}; +pub const GlyphMetrics = core.GlyphMetrics; pub const KerningPair = struct { codepoint_1: u21, @@ -38,14 +31,7 @@ pub const KerningPair = struct { y: f64, }; -pub const GeneratedGlyph = struct { - metrics: GlyphMetrics, - pixels: []const u8, - - pub fn deinit(self: GeneratedGlyph, allocator: std.mem.Allocator) void { - allocator.free(self.pixels); - } -}; +pub const GeneratedGlyph = core.GeneratedGlyph; pub const GeneratedAtlasGlyph = struct { metrics: GlyphMetrics, @@ -69,76 +55,20 @@ pub const GeneratedAtlas = struct { } }; +// The SDF generation types and pipeline live in the dependency-free +// `mist-core` module (see `core.zig`); they're re-exported here +// so the `mist` module's public API is unchanged. pub const SdfType = core.SdfType; -pub const ColoringMethod = enum { - simple, - /// Only for use with ink trap fonts, as the coloring remains correct - /// after removing the edges required for trapping ink. - ink_trap, - /// Performs the coloring based on edge distances. - /// Somewhat slower than other methods, but it produces a better result most of the time. - distance, - - pub fn execute(self: ColoringMethod, args: anytype) !void { - try switch (self) { - .simple => @call(.auto, coloring.colorSimple, args), - .ink_trap => @call(.auto, coloring.colorInkTrap, args), - .distance => @call(.auto, coloring.colorDistance, args), - }; - } -}; +pub const ColoringMethod = core.ColoringMethod; -pub const Winding = enum { - /// Attempts to figure out winding on its own, by checking - /// the polarity of an OOB point's distance. - guess, - positive, - negative, -}; +pub const Winding = core.Winding; -pub const VarFontArgument = struct { - name: []const u8, - value: f64, -}; +pub const VarFontArgument = core.VarFontArgument; -pub const Options = struct { - sdf_type: SdfType, - px_size: u16, - px_range: u16, - /// Has no effect if `sdf_type.requiresColoring()` is false. - coloring_rng_seed: u64 = 0, - /// The method with which to perform the MSDF 3-coloring. - /// While the implementations are based on msdfgen, they're (intentionally) - /// not equivalent, but should resolve corners similarly well. - /// - /// Has no effect if `sdf_type.requiresColoring()` is false. - coloring_method: ColoringMethod = .distance, - /// The angle which is considered to be a corner, in radians. - corner_angle_threshold: f64 = 3.0, - winding: Winding = .guess, - /// Validates that the given (or generated) shapes' contours form a - /// closed loop, with each edge connecting to each other properly. - validate_shape: bool = false, - normalize_shape: bool = false, - orient_contours: bool = false, - /// Requires `orient_contours` to be disabled. - scanline_fill_rule: ?Scanline.FillRule = null, - /// Only MSDFs (both their normal and their 10-bit versions) and MTSDFs can be error corrected. - error_correction_opts: ?error_correction.Options = null, - /// The list of arguments to use if the given font has multiple masters. - var_font_args: []const VarFontArgument = &.{}, - /// Whether to use async tasks over concurrent ones during atlas generation. - /// Currently has no effect outside of atlas generation. - disable_concurrency: bool = false, -}; +pub const Options = core.Options; -pub const Msdf10Pixel = packed struct(u32) { - r: u10 = 0, - g: u10 = 0, - b: u10 = 0, - a: u2 = std.math.maxInt(u2), -}; +pub const Msdf10Pixel = core.Msdf10Pixel; const FreetypeContext = struct { allocator: std.mem.Allocator, diff --git a/src/core.zig b/src/core.zig index 5cd06f3..34eac65 100644 --- a/src/core.zig +++ b/src/core.zig @@ -1,6 +1,8 @@ //! Dependency-free SDF generation core. //! Has no dependencies, so it's usable on freestanding targets. +const std = @import("std"); + pub const coloring = @import("coloring.zig"); pub const error_correction = @import("error_correction.zig"); pub const math = @import("math.zig"); @@ -12,6 +14,23 @@ const EdgeColor = coloring.EdgeColor; const Vec2 = @Vector(2, f64); +pub const GlyphMetrics = struct { + advance: f64, + bearing_x: f64, + bearing_y: f64, + width: u16, + height: u16, +}; + +pub const GeneratedGlyph = struct { + metrics: GlyphMetrics, + pixels: []const u8, + + pub fn deinit(self: GeneratedGlyph, allocator: std.mem.Allocator) void { + allocator.free(self.pixels); + } +}; + pub const SdfType = enum { sdf, psdf, @@ -42,6 +61,76 @@ pub const SdfType = enum { } }; +pub const ColoringMethod = enum { + simple, + /// Only for use with ink trap fonts, as the coloring remains correct + /// after removing the edges required for trapping ink. + ink_trap, + /// Performs the coloring based on edge distances. + /// Somewhat slower than other methods, but it produces a better result most of the time. + distance, + + pub fn execute(self: ColoringMethod, args: anytype) !void { + try switch (self) { + .simple => @call(.auto, coloring.colorSimple, args), + .ink_trap => @call(.auto, coloring.colorInkTrap, args), + .distance => @call(.auto, coloring.colorDistance, args), + }; + } +}; + +pub const Winding = enum { + /// Attempts to figure out winding on its own, by checking + /// the polarity of an OOB point's distance. + guess, + positive, + negative, +}; + +pub const VarFontArgument = struct { + name: []const u8, + value: f64, +}; + +pub const Options = struct { + sdf_type: SdfType, + px_size: u16, + px_range: u16, + /// Has no effect if `sdf_type.requiresColoring()` is false. + coloring_rng_seed: u64 = 0, + /// The method with which to perform the MSDF 3-coloring. + /// While the implementations are based on msdfgen, they're (intentionally) + /// not equivalent, but should resolve corners similarly well. + /// + /// Has no effect if `sdf_type.requiresColoring()` is false. + coloring_method: ColoringMethod = .distance, + /// The angle which is considered to be a corner, in radians. + corner_angle_threshold: f64 = 3.0, + winding: Winding = .guess, + /// Validates that the given (or generated) shapes' contours form a + /// closed loop, with each edge connecting to each other properly. + validate_shape: bool = false, + normalize_shape: bool = false, + orient_contours: bool = false, + /// Requires `orient_contours` to be disabled. + scanline_fill_rule: ?Scanline.FillRule = null, + /// Only MSDFs (both their normal and their 10-bit versions) and MTSDFs can be error corrected. + error_correction_opts: ?error_correction.Options = null, + /// The list of arguments to use if the given font has multiple masters. + /// Only used by font front ends (e.g. the freetype-based `mist` module). + var_font_args: []const VarFontArgument = &.{}, + /// Whether to use async tasks over concurrent ones during atlas generation. + /// Currently has no effect outside of atlas generation. + disable_concurrency: bool = false, +}; + +pub const Msdf10Pixel = packed struct(u32) { + r: u10 = 0, + g: u10 = 0, + b: u10 = 0, + a: u2 = std.math.maxInt(u2), +}; + fn pxRangeNorm(dist: f64, px_range: f64) f64 { return (dist + px_range / 2.0) / px_range; } From 826388c40c59a91d87912ad959caabcc4c1b11cf Mon Sep 17 00:00:00 2001 From: funbiscuit Date: Fri, 9 Oct 2026 16:16:47 +0300 Subject: [PATCH 4/9] Move the SDF generation pipeline to core.zig --- src/Generator.zig | 208 +--------------------------------------------- src/core.zig | 205 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 206 deletions(-) diff --git a/src/Generator.zig b/src/Generator.zig index b867280..be6bb39 100644 --- a/src/Generator.zig +++ b/src/Generator.zig @@ -4,10 +4,8 @@ const core = @import("core.zig"); const ft = @import("mach-freetype"); const pack = @import("turbopack"); -const Bitmap = @import("bitmap.zig").Bitmap; -const error_correction = @import("error_correction.zig"); const math = @import("math.zig"); -const Scanline = @import("Scanline.zig"); + const Shape = @import("Shape.zig"); const Vec2 = @Vector(2, f64); @@ -232,7 +230,7 @@ pub fn generateSingle( .width = w, .height = h, }, - .pixels = try getSdfPixels( + .pixels = try core.getSdfPixels( allocator, opts, w, @@ -461,210 +459,8 @@ fn processAtlasCodepointInner( }; } -fn getSdfPixels( - allocator: std.mem.Allocator, - opts: *const Options, - w: u16, - h: u16, - shape: *Shape, - tfm: Vec2, -) ![]const u8 { - const px_size = f64i(opts.px_size); - const px_range = f64i(opts.px_range) / px_size; - - const channels = opts.sdf_type.numChannels(); - var dist_bmp: Bitmap(f64) = try .create(0.0, allocator, w, h, channels); - defer dist_bmp.destroy(allocator); - - switch (opts.sdf_type) { - inline else => |ty| { - if (ty.requiresColoring()) - try opts.coloring_method.execute(.{ allocator, opts.coloring_rng_seed, shape, opts.corner_angle_threshold }); - generate(ty, &dist_bmp, w, h, px_size, shape.*, px_range, tfm); - }, - } - - if (!opts.orient_contours) - if (opts.scanline_fill_rule) |fill_rule| - switch (opts.sdf_type) { - .sdf, .psdf => try sdfSignCorrection( - allocator, - &dist_bmp, - w, - h, - px_size, - shape.*, - tfm, - fill_rule, - ), - inline .msdf, .msdf10, .mtsdf => |ty| try msdfSignCorrection( - ty, - allocator, - &dist_bmp, - w, - h, - px_size, - shape.*, - tfm, - fill_rule, - ), - }; - - if (opts.sdf_type == .msdf or - opts.sdf_type == .mtsdf or - opts.sdf_type == .msdf10) - if (opts.error_correction_opts) |*ec_opts| - try error_correction.correct( - allocator, - shape, - ec_opts, - px_size, - px_range, - tfm, - &dist_bmp, - opts.scanline_fill_rule != null, - ); - - const mod_channels = if (opts.sdf_type == .msdf10) - 4 - else - channels; - const pixels = try allocator.alloc(u8, @as(usize, w) * @as(usize, h) * @as(usize, mod_channels)); - - for (0..h) |y| for (0..w) |x| { - const idx = y * w * mod_channels + x * mod_channels; - const ux: u16 = @intCast(x); - const uy: u16 = @intCast(y); - const dists = dist_bmp.at(false, ux, uy, channels); - - if (opts.sdf_type == .msdf10) { - @memcpy(pixels[idx..][0..mod_channels], &std.mem.toBytes(Msdf10Pixel{ - .r = @trunc(std.math.maxInt(u10) * std.math.clamp(dists[0], 0.0, 1.0)), - .g = @trunc(std.math.maxInt(u10) * std.math.clamp(dists[1], 0.0, 1.0)), - .b = @trunc(std.math.maxInt(u10) * std.math.clamp(dists[2], 0.0, 1.0)), - .a = std.math.maxInt(u2), - })); - } else for (0..channels) |i| - pixels[idx + i] = @trunc(std.math.maxInt(u8) * std.math.clamp(dists[i], 0.0, 1.0)); - }; - return pixels; -} - -fn sdfSignCorrection( - allocator: std.mem.Allocator, - out_pixels: *Bitmap(f64), - w: u16, - h: u16, - scale: f64, - shape: Shape, - tfm: Vec2, - fill_rule: Scanline.FillRule, -) !void { - var scanline: Scanline = .{}; - defer scanline.intersections.deinit(allocator); - for (0..h) |y| { - try shape.scanline(&scanline, (f64i(y) + 0.5) / scale + tfm[1], allocator); - for (0..w) |x| { - const dists = out_pixels.at(true, @intCast(x), @intCast(y), 1); - if ((dists[0] > 0.5) != scanline.filled((f64i(x) + 0.5) / scale + tfm[0], fill_rule)) - dists[0] = 1.0 - dists[0]; - } - } -} - -fn msdfSignCorrection( - comptime sdf_type: SdfType, - allocator: std.mem.Allocator, - out_pixels: *Bitmap(f64), - w: u16, - h: u16, - scale: f64, - shape: Shape, - tfm: Vec2, - fill_rule: Scanline.FillRule, -) !void { - if (sdf_type != .msdf and - sdf_type != .mtsdf and - sdf_type != .msdf10) - @compileError("Invalid SDF type. Use `sdfSignCorrection()` instead."); - - var scanline: Scanline = .{}; - defer scanline.intersections.deinit(allocator); - - const match_map = try allocator.alloc(i32, w * h); - defer allocator.free(match_map); - @memset(match_map, 0); - - var ambiguous = false; - var match_idx: usize = 0; - for (0..h) |y| { - try shape.scanline(&scanline, (f64i(y) + 0.5) / scale + tfm[1], allocator); - for (0..w) |x| { - const filled = scanline.filled((f64i(x) + 0.5) / scale + tfm[0], fill_rule); - const px = out_pixels.at(true, @intCast(x), @intCast(y), comptime sdf_type.numChannels()); - const msdf_dist = math.median(px[0..3]); - - if (msdf_dist == 0.5) { - ambiguous = true; - } else if ((msdf_dist > 0.5) != filled) { - for (px[0..3]) |*ch| ch.* = 1.0 - ch.*; - match_map[match_idx] = -1; - } else match_map[match_idx] = 1; - - if (sdf_type == .mtsdf and (px[3] > 0.5) != filled) - px[3] = 1.0 - px[3]; - match_idx += 1; - } - } - - if (!ambiguous) return; - - match_idx = 0; - for (0..h) |y| for (0..w) |x| { - if (match_map[match_idx] != 0) { - var neighbor_match: i32 = 0; - if (x > 0) neighbor_match += match_map[match_idx - 1]; - if (x < w - 1) neighbor_match += match_map[match_idx + 1]; - if (y > 0) neighbor_match += match_map[match_idx - w]; - if (y < h - 1) neighbor_match += match_map[match_idx + w]; - if (neighbor_match < 0) { - const px = out_pixels.at(true, @intCast(x), @intCast(y), comptime sdf_type.numChannels()); - for (px[0..3]) |*ch| ch.* = 1.0 - ch.*; - } - } - match_idx += 1; - }; -} - pub const findDistanceAt = core.findDistanceAt; -fn generate( - comptime sdf_type: SdfType, - out_pixels: *Bitmap(f64), - w: u16, - h: u16, - scale: f64, - shape: Shape, - px_range: f64, - tfm: Vec2, -) void { - for (0..h) |y| for (0..w) |x| { - const channels = comptime sdf_type.numChannels(); - @memcpy( - @as(*[channels]f64, @ptrCast(out_pixels.at(true, @intCast(x), @intCast(y), channels))), - @as(*const [channels]f64, &findDistanceAt( - sdf_type, - shape, - Vec2{ - (f64i(x) + 0.5), - (f64i(y) + 0.5), - } / math.v2(scale) + tfm, - px_range, - )), - ); - }; -} - fn scaledFtVec(vec: [*c]const ft.Vector, scale: f64) Vec2 { return .{ f64i(vec.*.x) * scale, diff --git a/src/core.zig b/src/core.zig index 34eac65..533618d 100644 --- a/src/core.zig +++ b/src/core.zig @@ -10,6 +10,7 @@ pub const EdgeSegment = @import("EdgeSegment.zig"); pub const Scanline = @import("Scanline.zig"); pub const Shape = @import("Shape.zig"); +const Bitmap = @import("bitmap.zig").Bitmap; const EdgeColor = coloring.EdgeColor; const Vec2 = @Vector(2, f64); @@ -131,6 +132,183 @@ pub const Msdf10Pixel = packed struct(u32) { a: u2 = std.math.maxInt(u2), }; +/// Renders a shape into an SDF pixel buffer. Exposed for the freetype front +/// end until `generateSingle()` takes over as the only caller. +pub fn getSdfPixels( + allocator: std.mem.Allocator, + opts: *const Options, + w: u16, + h: u16, + shape: *Shape, + tfm: Vec2, +) ![]const u8 { + const px_size = f64i(opts.px_size); + const px_range = f64i(opts.px_range) / px_size; + + const channels = opts.sdf_type.numChannels(); + var dist_bmp: Bitmap(f64) = try .create(0.0, allocator, w, h, channels); + defer dist_bmp.destroy(allocator); + + switch (opts.sdf_type) { + inline else => |ty| { + if (ty.requiresColoring()) + try opts.coloring_method.execute(.{ allocator, opts.coloring_rng_seed, shape, opts.corner_angle_threshold }); + generate(ty, &dist_bmp, w, h, px_size, shape.*, px_range, tfm); + }, + } + + if (!opts.orient_contours) + if (opts.scanline_fill_rule) |fill_rule| + switch (opts.sdf_type) { + .sdf, .psdf => try sdfSignCorrection( + allocator, + &dist_bmp, + w, + h, + px_size, + shape.*, + tfm, + fill_rule, + ), + inline .msdf, .msdf10, .mtsdf => |ty| try msdfSignCorrection( + ty, + allocator, + &dist_bmp, + w, + h, + px_size, + shape.*, + tfm, + fill_rule, + ), + }; + + if (opts.sdf_type == .msdf or + opts.sdf_type == .mtsdf or + opts.sdf_type == .msdf10) + if (opts.error_correction_opts) |*ec_opts| + try error_correction.correct( + allocator, + shape, + ec_opts, + px_size, + px_range, + tfm, + &dist_bmp, + opts.scanline_fill_rule != null, + ); + + const mod_channels = if (opts.sdf_type == .msdf10) + 4 + else + channels; + const pixels = try allocator.alloc(u8, @as(usize, w) * @as(usize, h) * @as(usize, mod_channels)); + + for (0..h) |y| for (0..w) |x| { + const idx = y * w * mod_channels + x * mod_channels; + const ux: u16 = @intCast(x); + const uy: u16 = @intCast(y); + const dists = dist_bmp.at(false, ux, uy, channels); + + if (opts.sdf_type == .msdf10) { + @memcpy(pixels[idx..][0..mod_channels], &std.mem.toBytes(Msdf10Pixel{ + .r = @trunc(std.math.maxInt(u10) * std.math.clamp(dists[0], 0.0, 1.0)), + .g = @trunc(std.math.maxInt(u10) * std.math.clamp(dists[1], 0.0, 1.0)), + .b = @trunc(std.math.maxInt(u10) * std.math.clamp(dists[2], 0.0, 1.0)), + .a = std.math.maxInt(u2), + })); + } else for (0..channels) |i| + pixels[idx + i] = @trunc(std.math.maxInt(u8) * std.math.clamp(dists[i], 0.0, 1.0)); + }; + return pixels; +} + +fn sdfSignCorrection( + allocator: std.mem.Allocator, + out_pixels: *Bitmap(f64), + w: u16, + h: u16, + scale: f64, + shape: Shape, + tfm: Vec2, + fill_rule: Scanline.FillRule, +) !void { + var scanline: Scanline = .{}; + defer scanline.intersections.deinit(allocator); + for (0..h) |y| { + try shape.scanline(&scanline, (f64i(y) + 0.5) / scale + tfm[1], allocator); + for (0..w) |x| { + const dists = out_pixels.at(true, @intCast(x), @intCast(y), 1); + if ((dists[0] > 0.5) != scanline.filled((f64i(x) + 0.5) / scale + tfm[0], fill_rule)) + dists[0] = 1.0 - dists[0]; + } + } +} + +fn msdfSignCorrection( + comptime sdf_type: SdfType, + allocator: std.mem.Allocator, + out_pixels: *Bitmap(f64), + w: u16, + h: u16, + scale: f64, + shape: Shape, + tfm: Vec2, + fill_rule: Scanline.FillRule, +) !void { + if (sdf_type != .msdf and + sdf_type != .mtsdf and + sdf_type != .msdf10) + @compileError("Invalid SDF type. Use `sdfSignCorrection()` instead."); + + var scanline: Scanline = .{}; + defer scanline.intersections.deinit(allocator); + + const match_map = try allocator.alloc(i32, w * h); + defer allocator.free(match_map); + @memset(match_map, 0); + + var ambiguous = false; + var match_idx: usize = 0; + for (0..h) |y| { + try shape.scanline(&scanline, (f64i(y) + 0.5) / scale + tfm[1], allocator); + for (0..w) |x| { + const filled = scanline.filled((f64i(x) + 0.5) / scale + tfm[0], fill_rule); + const px = out_pixels.at(true, @intCast(x), @intCast(y), comptime sdf_type.numChannels()); + const msdf_dist = math.median(px[0..3]); + + if (msdf_dist == 0.5) { + ambiguous = true; + } else if ((msdf_dist > 0.5) != filled) { + for (px[0..3]) |*ch| ch.* = 1.0 - ch.*; + match_map[match_idx] = -1; + } else match_map[match_idx] = 1; + + if (sdf_type == .mtsdf and (px[3] > 0.5) != filled) + px[3] = 1.0 - px[3]; + match_idx += 1; + } + } + + if (!ambiguous) return; + + match_idx = 0; + for (0..h) |y| for (0..w) |x| { + if (match_map[match_idx] != 0) { + var neighbor_match: i32 = 0; + if (x > 0) neighbor_match += match_map[match_idx - 1]; + if (x < w - 1) neighbor_match += match_map[match_idx + 1]; + if (y > 0) neighbor_match += match_map[match_idx - w]; + if (y < h - 1) neighbor_match += match_map[match_idx + w]; + if (neighbor_match < 0) { + const px = out_pixels.at(true, @intCast(x), @intCast(y), comptime sdf_type.numChannels()); + for (px[0..3]) |*ch| ch.* = 1.0 - ch.*; + } + } + match_idx += 1; + }; +} + fn pxRangeNorm(dist: f64, px_range: f64) f64 { return (dist + px_range / 2.0) / px_range; } @@ -207,6 +385,33 @@ pub fn findDistanceAt( }; } +fn generate( + comptime sdf_type: SdfType, + out_pixels: *Bitmap(f64), + w: u16, + h: u16, + scale: f64, + shape: Shape, + px_range: f64, + tfm: Vec2, +) void { + for (0..h) |y| for (0..w) |x| { + const channels = comptime sdf_type.numChannels(); + @memcpy( + @as(*[channels]f64, @ptrCast(out_pixels.at(true, @intCast(x), @intCast(y), channels))), + @as(*const [channels]f64, &findDistanceAt( + sdf_type, + shape, + Vec2{ + (f64i(x) + 0.5), + (f64i(y) + 0.5), + } / math.v2(scale) + tfm, + px_range, + )), + ); + }; +} + pub fn f64i(int: anytype) f64 { return @floatFromInt(int); } From 5dbee368f2e4dad9b8bc5f2458809d9210e821b9 Mon Sep 17 00:00:00 2001 From: funbiscuit Date: Fri, 9 Oct 2026 16:34:58 +0300 Subject: [PATCH 5/9] Add shape-based core.generateSingle; freetype front end delegates --- src/Generator.zig | 75 +++++------------------------------- src/core.zig | 97 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 69 deletions(-) diff --git a/src/Generator.zig b/src/Generator.zig index be6bb39..02ffa86 100644 --- a/src/Generator.zig +++ b/src/Generator.zig @@ -1,12 +1,11 @@ const std = @import("std"); -const core = @import("core.zig"); +const core = @import("mist-core"); const ft = @import("mach-freetype"); const pack = @import("turbopack"); -const math = @import("math.zig"); - -const Shape = @import("Shape.zig"); +const Shape = core.Shape; +const math = core.math; const Vec2 = @Vector(2, f64); @@ -176,72 +175,16 @@ pub fn generateSingle( )); const metrics = face.glyph().metrics(); - if (shape.contours.items.len == 0) - return .{ - .metrics = .{ - .advance = scale * f64i(face.glyph().advance().x), - .bearing_x = scale * f64i(metrics.horiBearingX), - .bearing_y = scale * f64i(metrics.horiBearingY), - .width = 0, - .height = 0, - }, - .pixels = &.{}, - }; - - var contour_it = std.mem.reverseIterator(shape.contours.items); - var i: isize = @intCast(shape.contours.items.len - 1); - while (contour_it.next()) |contour| : (i -= 1) - if (contour.edges.items.len == 0) { - _ = shape.contours.swapRemove(@intCast(i)); - }; - - if (opts.validate_shape and !shape.validate()) return error.InvalidShape; - if (opts.orient_contours) try shape.orientContours(allocator); - if (opts.normalize_shape) try shape.normalize(allocator); - - const px_size = f64i(opts.px_size); - const px_range = f64i(opts.px_range) / px_size; - - var bounds = shape.calcBounds(); - if (bounds.left >= bounds.right or bounds.bottom >= bounds.top) - bounds = .whole_frame; - - const bound_w = bounds.right - bounds.left; - const bound_h = bounds.top - bounds.bottom; - const w: u16 = @trunc((bound_w + px_range) * px_size); - const h: u16 = @trunc((bound_h + px_range) * px_size); - - if (opts.winding == .negative or - opts.winding == .guess and findDistanceAt( - .sdf, - shape, - .{ - bounds.left - px_range - bound_w - 1.0, - bounds.bottom - px_range - bound_h - 1.0, - }, - px_range, - ) > 0) for (shape.contours.items) |*contour| contour.reverse(); - - return .{ - .metrics = .{ + return core.generateSingle( + allocator, + &shape, + .{ .advance = scale * f64i(face.glyph().advance().x), .bearing_x = scale * f64i(metrics.horiBearingX), .bearing_y = scale * f64i(metrics.horiBearingY), - .width = w, - .height = h, }, - .pixels = try core.getSdfPixels( - allocator, - opts, - w, - h, - &shape, - .{ - bounds.left - px_range / 2.0, - bounds.bottom - px_range / 2.0, - }, - ), - }; + opts, + ); } /// The result is under the caller's ownership (call `deinit()` or deallocate fields manually) diff --git a/src/core.zig b/src/core.zig index 533618d..16aef14 100644 --- a/src/core.zig +++ b/src/core.zig @@ -132,9 +132,100 @@ pub const Msdf10Pixel = packed struct(u32) { a: u2 = std.math.maxInt(u2), }; -/// Renders a shape into an SDF pixel buffer. Exposed for the freetype front -/// end until `generateSingle()` takes over as the only caller. -pub fn getSdfPixels( +/// Font-agnostic glyph placement: the advance and bearings of the glyph the +/// shape was taken from, in em units, as reported by whatever produced the +/// outline. The result's pixel `width`/`height` are computed by `generateSingle`. +pub const GlyphPlacement = struct { + advance: f64, + bearing_x: f64, + bearing_y: f64, +}; + +/// Renders a shape (in em units) into an SDF pixel buffer. +/// +/// `shape` is mutated in place: empty contours are dropped, and depending on +/// `opts` the contours may get reoriented or normalized. An empty shape +/// produces a zero-size glyph. The placement's advance/bearings flow through +/// to the result's metrics; `width`/`height` are computed from the shape. +/// +/// The result is under the caller's ownership (call `deinit()` or deallocate fields manually) +pub fn generateSingle( + allocator: std.mem.Allocator, + shape: *Shape, + placement: GlyphPlacement, + opts: *const Options, +) !GeneratedGlyph { + if (shape.contours.items.len > 0) { + var contour_it = std.mem.reverseIterator(shape.contours.items); + var i: isize = @intCast(shape.contours.items.len - 1); + while (contour_it.next()) |contour| : (i -= 1) + if (contour.edges.items.len == 0) { + _ = shape.contours.swapRemove(@intCast(i)); + }; + } + + if (shape.contours.items.len == 0) + return .{ + .metrics = .{ + .advance = placement.advance, + .bearing_x = placement.bearing_x, + .bearing_y = placement.bearing_y, + .width = 0, + .height = 0, + }, + .pixels = &.{}, + }; + + if (opts.validate_shape and !shape.validate()) return error.InvalidShape; + if (opts.orient_contours) try shape.orientContours(allocator); + if (opts.normalize_shape) try shape.normalize(allocator); + + const px_size = f64i(opts.px_size); + const px_range = f64i(opts.px_range) / px_size; + + var bounds = shape.calcBounds(); + if (bounds.left >= bounds.right or bounds.bottom >= bounds.top) + bounds = .whole_frame; + + const bound_w = bounds.right - bounds.left; + const bound_h = bounds.top - bounds.bottom; + const w: u16 = @trunc((bound_w + px_range) * px_size); + const h: u16 = @trunc((bound_h + px_range) * px_size); + + if (opts.winding == .negative or + opts.winding == .guess and findDistanceAt( + .sdf, + shape.*, + .{ + bounds.left - px_range - bound_w - 1.0, + bounds.bottom - px_range - bound_h - 1.0, + }, + px_range, + ) > 0) for (shape.contours.items) |*contour| contour.reverse(); + + return .{ + .metrics = .{ + .advance = placement.advance, + .bearing_x = placement.bearing_x, + .bearing_y = placement.bearing_y, + .width = w, + .height = h, + }, + .pixels = try getSdfPixels( + allocator, + opts, + w, + h, + shape, + .{ + bounds.left - px_range / 2.0, + bounds.bottom - px_range / 2.0, + }, + ), + }; +} + +fn getSdfPixels( allocator: std.mem.Allocator, opts: *const Options, w: u16, From 9cb4a248ab687756d31bd5d43f7c96bfc7278f3e Mon Sep 17 00:00:00 2001 From: funbiscuit Date: Fri, 9 Oct 2026 16:53:38 +0300 Subject: [PATCH 6/9] Add ShapeSink; freetype decompose goes through it --- src/Generator.zig | 68 +++++++++++---------------------------------- src/core.zig | 70 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 52 deletions(-) diff --git a/src/Generator.zig b/src/Generator.zig index 02ffa86..5c179ed 100644 --- a/src/Generator.zig +++ b/src/Generator.zig @@ -5,7 +5,6 @@ const ft = @import("mach-freetype"); const pack = @import("turbopack"); const Shape = core.Shape; -const math = core.math; const Vec2 = @Vector(2, f64); @@ -68,11 +67,7 @@ pub const Options = core.Options; pub const Msdf10Pixel = core.Msdf10Pixel; const FreetypeContext = struct { - allocator: std.mem.Allocator, - scale: f64, - shape: *Shape, - pos: Vec2 = @splat(0.0), - contour: ?*Shape.Contour = null, + sink: core.ShapeSink, }; library: ft.Library, @@ -155,9 +150,11 @@ pub fn generateSingle( defer shape.deinit(allocator); var context: FreetypeContext = .{ - .allocator = allocator, - .scale = scale, - .shape = &shape, + .sink = .{ + .allocator = allocator, + .shape = &shape, + .scale = scale, + }, }; const outline = face.glyph().outline().?; @@ -404,49 +401,25 @@ fn processAtlasCodepointInner( pub const findDistanceAt = core.findDistanceAt; -fn scaledFtVec(vec: [*c]const ft.Vector, scale: f64) Vec2 { - return .{ - f64i(vec.*.x) * scale, - f64i(vec.*.y) * scale, - }; +fn ftVec(vec: [*c]const ft.Vector) Vec2 { + return .{ f64i(vec.*.x), f64i(vec.*.y) }; } fn ftMoveTo(to: [*c]const ft.Vector, ud: ?*anyopaque) callconv(.c) i32 { - var context: *FreetypeContext = @ptrCast(@alignCast(ud)); - if (context.contour == null or context.contour.?.edges.items.len != 0) { - context.contour = context.shape.contours.addOne(context.allocator) catch return ft.c.FT_Err_Out_Of_Memory; - context.contour.?.* = .{}; - } - context.pos = scaledFtVec(to, context.scale); + const context: *FreetypeContext = @ptrCast(@alignCast(ud)); + context.sink.moveTo(ftVec(to)) catch return ft.c.FT_Err_Out_Of_Memory; return 0; } fn ftLineTo(to: [*c]const ft.Vector, ud: ?*anyopaque) callconv(.c) i32 { - var context: *FreetypeContext = @ptrCast(@alignCast(ud)); - const endpoint: Vec2 = scaledFtVec(to, context.scale); - if (!std.meta.eql(endpoint, context.pos)) { - context.contour.?.edges.append( - context.allocator, - .create(context.pos, endpoint, null, null, .all), - ) catch return ft.c.FT_Err_Out_Of_Memory; - context.pos = endpoint; - } + const context: *FreetypeContext = @ptrCast(@alignCast(ud)); + context.sink.lineTo(ftVec(to)) catch return ft.c.FT_Err_Out_Of_Memory; return 0; } fn ftConicTo(control: [*c]const ft.Vector, to: [*c]const ft.Vector, ud: ?*anyopaque) callconv(.c) i32 { - var context: *FreetypeContext = @ptrCast(@alignCast(ud)); - const endpoint: Vec2 = scaledFtVec(to, context.scale); - if (!std.meta.eql(endpoint, context.pos)) { - context.contour.?.edges.append(context.allocator, .create( - context.pos, - scaledFtVec(control, context.scale), - endpoint, - null, - .all, - )) catch return ft.c.FT_Err_Out_Of_Memory; - context.pos = endpoint; - } + const context: *FreetypeContext = @ptrCast(@alignCast(ud)); + context.sink.quadTo(ftVec(control), ftVec(to)) catch return ft.c.FT_Err_Out_Of_Memory; return 0; } @@ -456,17 +429,8 @@ fn ftCubicTo( to: [*c]const ft.Vector, ud: ?*anyopaque, ) callconv(.c) i32 { - var context: *FreetypeContext = @ptrCast(@alignCast(ud)); - const endpoint: Vec2 = scaledFtVec(to, context.scale); - const scaled_c1: Vec2 = scaledFtVec(control_1, context.scale); - const scaled_c2: Vec2 = scaledFtVec(control_2, context.scale); - if (!std.meta.eql(endpoint, context.pos) or math.cross(scaled_c1 - endpoint, scaled_c2 - endpoint) != 0.0) { - context.contour.?.edges.append( - context.allocator, - .create(context.pos, scaled_c1, scaled_c2, endpoint, .all), - ) catch return ft.c.FT_Err_Out_Of_Memory; - context.pos = endpoint; - } + const context: *FreetypeContext = @ptrCast(@alignCast(ud)); + context.sink.cubicTo(ftVec(control_1), ftVec(control_2), ftVec(to)) catch return ft.c.FT_Err_Out_Of_Memory; return 0; } diff --git a/src/core.zig b/src/core.zig index 16aef14..7895e19 100644 --- a/src/core.zig +++ b/src/core.zig @@ -141,6 +141,76 @@ pub const GlyphPlacement = struct { bearing_y: f64, }; +/// Font-agnostic outline ingestion: feed one glyph's contours through the +/// methods in outline order, starting with `moveTo`, then hand the shape to +/// `generateSingle`. Incoming coordinates are multiplied by `scale`, so raw +/// font units can be pushed directly with `scale` set to 1/units_per_em. +/// Zero-length lines/curves are dropped, matching what the freetype front end does. +pub const ShapeSink = struct { + allocator: std.mem.Allocator, + shape: *Shape, + scale: f64, + pos: Vec2 = @splat(0.0), + contour: ?*Shape.Contour = null, + + fn scaled(p: Vec2, scale: f64) Vec2 { + return p * @as(Vec2, @splat(scale)); + } + + pub fn moveTo(self: *ShapeSink, to: Vec2) !void { + if (self.contour == null or self.contour.?.edges.items.len != 0) { + self.contour = self.shape.contours.addOne(self.allocator) catch return error.OutOfMemory; + self.contour.?.* = .{}; + } + self.pos = scaled(to, self.scale); + } + + pub fn lineTo(self: *ShapeSink, to: Vec2) !void { + const endpoint = scaled(to, self.scale); + if (!std.meta.eql(endpoint, self.pos)) { + self.contour.?.edges.append( + self.allocator, + .create(self.pos, endpoint, null, null, .all), + ) catch return error.OutOfMemory; + self.pos = endpoint; + } + } + + pub fn quadTo(self: *ShapeSink, control: Vec2, to: Vec2) !void { + const endpoint = scaled(to, self.scale); + if (!std.meta.eql(endpoint, self.pos)) { + self.contour.?.edges.append(self.allocator, .create( + self.pos, + scaled(control, self.scale), + endpoint, + null, + .all, + )) catch return error.OutOfMemory; + self.pos = endpoint; + } + } + + pub fn cubicTo(self: *ShapeSink, control_1: Vec2, control_2: Vec2, to: Vec2) !void { + const endpoint = scaled(to, self.scale); + const c1 = scaled(control_1, self.scale); + const c2 = scaled(control_2, self.scale); + if (!std.meta.eql(endpoint, self.pos) or math.cross(c1 - endpoint, c2 - endpoint) != 0.0) { + self.contour.?.edges.append( + self.allocator, + .create(self.pos, c1, c2, endpoint, .all), + ) catch return error.OutOfMemory; + self.pos = endpoint; + } + } + + /// Ends the current contour. Only observable right after a `moveTo`: + /// the next `moveTo` then starts a fresh contour instead of reusing the + /// still-empty one. + pub fn close(self: *ShapeSink) void { + self.contour = null; + } +}; + /// Renders a shape (in em units) into an SDF pixel buffer. /// /// `shape` is mutated in place: empty contours are dropped, and depending on From d3982333e948e16f2c8e7af3db04bbcfcd848323 Mon Sep 17 00:00:00 2001 From: funbiscuit Date: Fri, 9 Oct 2026 17:06:53 +0300 Subject: [PATCH 7/9] Add test step: core unit tests --- build.zig | 11 +++++++ src/core.zig | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/build.zig b/build.zig index 5d71bdf..4367db4 100644 --- a/build.zig +++ b/build.zig @@ -30,4 +30,15 @@ pub fn build(b: *std.Build) void { }, }, }); + + const test_step = b.step("test", "Run module tests"); + + const core_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/core.zig"), + .target = target, + .optimize = optimize, + }), + }); + test_step.dependOn(&b.addRunArtifact(core_tests).step); } diff --git a/src/core.zig b/src/core.zig index 7895e19..e99e953 100644 --- a/src/core.zig +++ b/src/core.zig @@ -576,3 +576,92 @@ fn generate( pub fn f64i(int: anytype) f64 { return @floatFromInt(int); } + +test { + std.testing.refAllDecls(@This()); +} + +test "generateSingle triangle" { + const allocator = std.testing.allocator; + + var shape: Shape = .{}; + defer shape.deinit(allocator); + + var sink: ShapeSink = .{ .allocator = allocator, .shape = &shape, .scale = 1.0 }; + try sink.moveTo(.{ 0.1, 0.1 }); + try sink.lineTo(.{ 0.9, 0.1 }); + try sink.lineTo(.{ 0.5, 0.9 }); + try sink.lineTo(.{ 0.1, 0.1 }); + sink.close(); + + const opts: Options = .{ + .sdf_type = .mtsdf, + .px_size = 64, + .px_range = 8, + .validate_shape = true, + .normalize_shape = true, + .orient_contours = true, + .error_correction_opts = .{}, + }; + const glyph = try generateSingle(allocator, &shape, .{ + .advance = 0.8, + .bearing_x = 0.1, + .bearing_y = 0.9, + }, &opts); + defer glyph.deinit(allocator); + + try std.testing.expect(glyph.metrics.width > 16); + try std.testing.expect(glyph.metrics.height > 16); + try std.testing.expectEqual( + @as(usize, glyph.metrics.width) * glyph.metrics.height * SdfType.mtsdf.numChannels(), + glyph.pixels.len, + ); + try std.testing.expectEqual(@as(f64, 0.8), glyph.metrics.advance); + try std.testing.expectEqual(@as(f64, 0.1), glyph.metrics.bearing_x); + try std.testing.expectEqual(@as(f64, 0.9), glyph.metrics.bearing_y); + + // px_range in em units, matching what generateSingle feeds the pipeline + // (8px at 64px per em); the winding is normalized by now, so distances + // are positive inside. + const inside = findDistanceAt(.sdf, shape, .{ 0.5, 0.4 }, 8.0 / 64.0); + try std.testing.expect(inside > 0.5); + const outside = findDistanceAt(.sdf, shape, .{ -1.0, -1.0 }, 8.0 / 64.0); + try std.testing.expect(outside < 0.5); + + var max_px: u8 = 0; + for (glyph.pixels) |px| max_px = @max(max_px, px); + try std.testing.expect(max_px > 200); +} + +test "generateSingle empty shape" { + const allocator = std.testing.allocator; + const opts: Options = .{ .sdf_type = .sdf, .px_size = 64, .px_range = 8 }; + + var shape: Shape = .{}; + defer shape.deinit(allocator); + const glyph = try generateSingle(allocator, &shape, .{ + .advance = 0.3, + .bearing_x = 0.0, + .bearing_y = 0.0, + }, &opts); + defer glyph.deinit(allocator); + try std.testing.expectEqual(@as(u16, 0), glyph.metrics.width); + try std.testing.expectEqual(@as(u16, 0), glyph.metrics.height); + try std.testing.expectEqual(@as(f64, 0.3), glyph.metrics.advance); + try std.testing.expectEqual(@as(usize, 0), glyph.pixels.len); + + // A phantom empty contour, as whitespace glyph outlines tend to have. + var shape2: Shape = .{}; + defer shape2.deinit(allocator); + var sink: ShapeSink = .{ .allocator = allocator, .shape = &shape2, .scale = 1.0 }; + try sink.moveTo(.{ 1.0, 1.0 }); + sink.close(); + const glyph2 = try generateSingle(allocator, &shape2, .{ + .advance = 0.0, + .bearing_x = 0.0, + .bearing_y = 0.0, + }, &opts); + defer glyph2.deinit(allocator); + try std.testing.expectEqual(@as(u16, 0), glyph2.metrics.width); + try std.testing.expectEqual(@as(usize, 0), glyph2.pixels.len); +} From bdc7f741eeff44409647c111811f900dbf323758 Mon Sep 17 00:00:00 2001 From: funbiscuit Date: Fri, 9 Oct 2026 17:31:57 +0300 Subject: [PATCH 8/9] Document the modules, run tests in CI, bump version --- .github/workflows/ci.yml | 2 ++ README.md | 28 ++++++++++++++++++++++++++++ build.zig.zon | 2 +- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a2fcbc..135fc4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: uses: actions/checkout@v3 - name: Install Zig uses: mlugg/setup-zig@v2 + - name: Test + run: zig build test - name: Build and run working-directory: ./example run: zig build run -Doptimize=${{ matrix.opt }} \ No newline at end of file diff --git a/README.md b/README.md index 96d7325..9d66547 100644 --- a/README.md +++ b/README.md @@ -44,5 +44,33 @@ for ([_]u21{ 'A', 'B', 'C' }) |codepoint| { } ``` +## Modules +The package provides two modules: + +- `mist` — the full library: the freetype-backed font frontend (glyph outlines, metrics, kerning, atlas packing) on top + of everything below. Requires libc. +- `mist-core` — just the SDF generation core. It has no dependencies: no freetype, no libc, so it builds for + freestanding targets such as `wasm32-freestanding`. Feed it outlines yourself (e.g. from your own font parser) through + `ShapeSink`: + +```zig +const mist = @import("mist-core"); + +var shape: mist.Shape = .{}; +defer shape.deinit(allocator); + +var sink: mist.ShapeSink = .{ .allocator = allocator, .shape = &shape, .scale = 1.0 / font_units_per_em }; +try sink.moveTo(.{ x0, y0 }); +try sink.lineTo(.{ x1, y1 }); +try sink.quadTo(.{ cx, cy }, .{ x2, y2 }); + +const glyph = try mist.generateSingle(allocator, &shape, .{ + .advance = advance, + .bearing_x = bearing_x, + .bearing_y = bearing_y, +}, &gen_opts); +defer glyph.deinit(allocator); +``` + ## Note The library exposes a similar set of functionality to Chlumský's work, but it's not intended to be its equivalent, some features will be implemented differently and some will be intentionally omitted. Issues and PRs can still compare to msdfgen, like if the library generates a less correct SDF than it, but direct ports, or the request for them will be closed. diff --git a/build.zig.zon b/build.zig.zon index eaf0e43..bdb801c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,7 +1,7 @@ .{ .name = .mist, .fingerprint = 0xfc749f7d2126c2dd, - .version = "1.0.0", + .version = "1.1.0", .paths = .{ "LICENSE", "libs", From 1ff94a5ccdb8e32b4798cf04506e3224715fd803 Mon Sep 17 00:00:00 2001 From: funbiscuit Date: Fri, 9 Oct 2026 19:32:11 +0300 Subject: [PATCH 9/9] Split src/ into src/mist and src/mist-core source trees --- build.zig | 6 +++--- src/{ => mist-core}/EdgeSegment.zig | 0 src/{ => mist-core}/Scanline.zig | 0 src/{ => mist-core}/Shape.zig | 0 src/{ => mist-core}/bitmap.zig | 0 src/{ => mist-core}/coloring.zig | 0 src/{ => mist-core}/core.zig | 0 src/{ => mist-core}/error_correction.zig | 0 src/{ => mist-core}/math.zig | 0 src/{ => mist}/Generator.zig | 0 10 files changed, 3 insertions(+), 3 deletions(-) rename src/{ => mist-core}/EdgeSegment.zig (100%) rename src/{ => mist-core}/Scanline.zig (100%) rename src/{ => mist-core}/Shape.zig (100%) rename src/{ => mist-core}/bitmap.zig (100%) rename src/{ => mist-core}/coloring.zig (100%) rename src/{ => mist-core}/core.zig (100%) rename src/{ => mist-core}/error_correction.zig (100%) rename src/{ => mist-core}/math.zig (100%) rename src/{ => mist}/Generator.zig (100%) diff --git a/build.zig b/build.zig index 4367db4..9660137 100644 --- a/build.zig +++ b/build.zig @@ -7,11 +7,11 @@ pub fn build(b: *std.Build) void { // Dependency-free SDF core; usable on any target, including freestanding // ones (wasm), as it neither links nor imports freetype/libc. const core_module = b.addModule("mist-core", .{ - .root_source_file = b.path("src/core.zig"), + .root_source_file = b.path("src/mist-core/core.zig"), }); _ = b.addModule("mist", .{ - .root_source_file = b.path("src/Generator.zig"), + .root_source_file = b.path("src/mist/Generator.zig"), .imports = &.{ .{ .name = "mist-core", .module = core_module }, .{ @@ -35,7 +35,7 @@ pub fn build(b: *std.Build) void { const core_tests = b.addTest(.{ .root_module = b.createModule(.{ - .root_source_file = b.path("src/core.zig"), + .root_source_file = b.path("src/mist-core/core.zig"), .target = target, .optimize = optimize, }), diff --git a/src/EdgeSegment.zig b/src/mist-core/EdgeSegment.zig similarity index 100% rename from src/EdgeSegment.zig rename to src/mist-core/EdgeSegment.zig diff --git a/src/Scanline.zig b/src/mist-core/Scanline.zig similarity index 100% rename from src/Scanline.zig rename to src/mist-core/Scanline.zig diff --git a/src/Shape.zig b/src/mist-core/Shape.zig similarity index 100% rename from src/Shape.zig rename to src/mist-core/Shape.zig diff --git a/src/bitmap.zig b/src/mist-core/bitmap.zig similarity index 100% rename from src/bitmap.zig rename to src/mist-core/bitmap.zig diff --git a/src/coloring.zig b/src/mist-core/coloring.zig similarity index 100% rename from src/coloring.zig rename to src/mist-core/coloring.zig diff --git a/src/core.zig b/src/mist-core/core.zig similarity index 100% rename from src/core.zig rename to src/mist-core/core.zig diff --git a/src/error_correction.zig b/src/mist-core/error_correction.zig similarity index 100% rename from src/error_correction.zig rename to src/mist-core/error_correction.zig diff --git a/src/math.zig b/src/mist-core/math.zig similarity index 100% rename from src/math.zig rename to src/mist-core/math.zig diff --git a/src/Generator.zig b/src/mist/Generator.zig similarity index 100% rename from src/Generator.zig rename to src/mist/Generator.zig