1/// Shading: how a tone becomes marks.2///3/// This started life as dithering and was the poorer for it. Dithering is one4/// answer to the question — how do you print a grey with one ink — and naming5/// the module after it quietly ruled out every other answer. Hatching is not a6/// dither. A comic's two-plate overprint is not a dither. Both are shading, and7/// so is Atkinson, so shading is what this is.8///9/// The house style is still Bill Atkinson's, the one QuickDraw used on the Lisa10/// and the Macintosh. It is error diffusion: each pixel is forced to black or11/// white and the difference is pushed onto pixels not yet drawn. What makes it12/// *Atkinson* rather than Floyd-Steinberg is that it spreads only six eighths13/// of the error and throws the other quarter away. Discarding error is,14/// strictly, a bug. It is also the entire flavour: highlights run clean to15/// white and shadows to solid black instead of being speckled, so the midtones16/// carry all the texture and the picture reads with far more snap.17///18/// Everything else is a *screen*: a small table of thresholds, tiled across the19/// picture, saying in what order pixels give way to ink. That one idea covers20/// far more than it looks like it should, because the difference between a21/// dither, a halftone, a hatch and a flat fill is nothing but which numbers are22/// in the table and how they are arranged.23///24/// dispersed thresholds as far from their neighbours as possible; ink25/// scatters, which is a dither26/// clustered thresholds ascending outward from centres; ink grows into27/// round blobs, which is a halftone28/// ruled thresholds varying along one axis only; ink becomes a band29/// whose width is the tone, which is a hatch30/// flat one threshold; tone stops existing and only the reprinted31/// colour survives32///33/// The tables and every rule about which of them may be printed at what34/// frequency live here, in Gleam. What lives in FFI is the per-pixel loop that35/// applies them, and nothing else: the pipeline is a platform gap, the design36/// system is not.37import gleam/int38import gleam/list39import gleam/result4041/// Slides are quantised to this, so a pattern never lands out of phase.42pub const period = 84344pub type Pattern {45 Atkinson46 Ordered47 BenDay48 Lines49 Diagonal50 Crosshatch51 Comic52 Solid53}5455/// How many pixels one cell of the screen covers.56///57/// Not a setting: nothing here is a preference, it is the press this board58/// happened to be printed on, and a list of five is only legible because nobody59/// has to read it. The same table read at one pixel a cell or at six resolves60/// identical tone and changes only the grain, which is most of the difference61/// between newsprint and a magazine.62pub type Ruling {63 Fine64 Medium65 Coarse66 Poster67 Billboard68}6970pub fn patterns() -> List(Pattern) {71 [Atkinson, Ordered, BenDay, Lines, Diagonal, Crosshatch, Comic, Solid]72}7374pub fn rulings() -> List(Ruling) {75 [Fine, Medium, Coarse, Poster, Billboard]76}7778pub fn pattern_id(pattern: Pattern) -> String {79 case pattern {80 Atkinson -> "atkinson"81 Ordered -> "ordered"82 BenDay -> "benday"83 Lines -> "lines"84 Diagonal -> "diagonal"85 Crosshatch -> "crosshatch"86 Comic -> "comic"87 Solid -> "solid"88 }89}9091pub fn pattern_from_id(value: String) -> Result(Pattern, Nil) {92 patterns() |> list.find(fn(pattern) { pattern_id(pattern) == value })93}9495pub fn pattern_label(pattern: Pattern) -> String {96 case pattern {97 Atkinson -> "ATKINSON"98 Ordered -> "ORDERED"99 BenDay -> "BEN-DAY"100 Lines -> "LINES"101 Diagonal -> "DIAGONAL"102 Crosshatch -> "CROSSHATCH"103 Comic -> "COMIC"104 Solid -> "SOLID"105 }106}107108pub fn ruling_id(ruling: Ruling) -> String {109 case ruling {110 Fine -> "fine"111 Medium -> "medium"112 Coarse -> "coarse"113 Poster -> "poster"114 Billboard -> "billboard"115 }116}117118pub fn ruling_label(ruling: Ruling) -> String {119 case ruling {120 Fine -> "FINE"121 Medium -> "MEDIUM"122 Coarse -> "COARSE"123 Poster -> "POSTER"124 Billboard -> "BILLBOARD"125 }126}127128pub fn ruling_from_id(value: String) -> Ruling {129 rulings()130 |> list.find(fn(ruling) { ruling_id(ruling) == value })131 |> result.unwrap(Fine)132}133134/// How many pixels one cell covers.135pub fn cell(ruling: Ruling) -> Int {136 case ruling {137 Fine -> 1138 Medium -> 2139 Coarse -> 3140 Poster -> 4141 Billboard -> 6142 }143}144145fn ruling_rank(ruling: Ruling) -> Int {146 case ruling {147 Fine -> 0148 Medium -> 1149 Coarse -> 2150 Poster -> 3151 Billboard -> 4152 }153}154155fn ruling_at_rank(rank: Int) -> Ruling {156 case rank {157 0 -> Fine158 1 -> Medium159 2 -> Coarse160 3 -> Poster161 _ -> Billboard162 }163}164165/// Whether a ruling means anything for this shape.166///167/// Diffusion has no cell to make bigger — its grain *is* the pixel grid — and a168/// flat threshold has no grain at all. Saying so is more honest than inventing169/// a frequency for them and shipping five identical looks.170pub fn is_ruled(pattern: Pattern) -> Bool {171 case pattern {172 Atkinson -> False173 Solid -> False174 _ -> True175 }176}177178/// True when this pattern splits its ink across two plates.179pub fn has_overprint(pattern: Pattern) -> Bool {180 pattern == Comic181}182183/// How many rungs of the ladder a shape can actually be printed at.184pub fn rulings_for(pattern: Pattern) -> List(Ruling) {185 case is_ruled(pattern) {186 False -> [Fine]187 True -> rulings()188 }189}190191/// The ruling a pattern will actually use.192///193/// 'Fine' for the shapes that take none. Normalising here rather than in the194/// pools is what keeps every count honest: a theme lists five rulings, the195/// unruled shapes can print at one of them, and the roll landing on BILLBOARD196/// gives that one rather than a sixth name for a picture already on the list.197pub fn effective_ruling(pattern: Pattern, ruling: Ruling) -> Ruling {198 let allowed = rulings_for(pattern)199 case list.contains(allowed, ruling) {200 True -> ruling201 False -> allowed |> list.last |> result.unwrap(Fine)202 }203}204205/// A screen coarser than the thing it is printed on stops being a screen.206///207/// Proportional coverage fixes an unrepresentative *slice* of a screen, because208/// there is still a slice to choose within. It cannot do anything when there is209/// barely a slice at all, and the threshold for "barely" is higher than it210/// first looks. Two cells is enough to guarantee the block is neither all ink211/// nor all paper — which is what this once asked for, and it was not enough. A212/// screen's shape lives in its whole period: eight cells make one, and a block213/// spanning less than that does not show a pattern, it shows one or two214/// arbitrary bands of it. So the requirement is a period and a half.215///216/// `fill_px` is in *buffer* pixels, because that is the space screens are217/// measured in. Passing layout units instead was the other half of the same218/// bug: the guard compared a cell size in real pixels against a length in219/// layout ones and quietly permitted whatever the density factor happened to220/// be.221///222/// The second requirement is register. The screen is ruled across the whole223/// picture rather than restarted at each block, so a block landing one cell224/// over lands on a different part of it — unless the cell is a whole number of225/// periods wide, in which case it lands on exactly the pattern it left.226/// `grid_px` is that cell, and a ruling whose period does not divide it is227/// stepped down until one does. A screen that does not hold register is not a228/// coarser screen, it is a misprint.229///230/// Pass zero for `grid_px` and the register check is skipped, which is what the231/// previews want: an atlas row is a picture of a board rather than a board, and232/// nothing on it moves.233pub fn fit_ruling(ruling: Ruling, fill_px: Int, grid_px: Int) -> Ruling {234 let size = cell(ruling)235 let too_coarse = size * 12 > fill_px236 // Eight cells x 1.5.237 let off_register = grid_px > 0 && grid_px % { size * period } != 0238 case too_coarse || off_register {239 False -> ruling240 True ->241 case ruling_rank(ruling) {242 0 -> Fine243 rank -> fit_ruling(ruling_at_rank(rank - 1), fill_px, grid_px)244 }245 }246}247248/// Thresholds over 0..255, compared against the intended coverage of paper.249///250/// Raw tables are 0..n-1 and land inside the range without reaching either end;251/// every screen therefore agrees at 0 and 255, which matters because the scene252/// outside the block sprites is composed entirely at those two values.253fn spread(raw: List(Int), levels: Int) -> List(Int) {254 list.map(raw, fn(value) {255 float_round({ int.to_float(value) +. 0.5 } /. int.to_float(levels) *. 255.0)256 })257}258259@external(javascript, "./shading_ffi.mjs", "round")260fn float_round(value: Float) -> Int261262/// The 8x8 threshold table this pattern is cut from, at one pixel a cell. The263/// renderer scales it to the ruling.264pub fn table(pattern: Pattern) -> List(Int) {265 case pattern {266 // Diffusion has no table; it is asked for by shape, never by threshold.267 Atkinson -> bayer()268 Ordered -> bayer()269 BenDay -> cluster()270 Comic -> cluster()271 Lines -> lines()272 Diagonal -> diagonal()273 Crosshatch -> crosshatch()274 // One threshold, set above every tone a block uses and below bare paper.275 // Every block comes out solid and is carried entirely by its reprinted276 // colour — which the piece colours were always able to do on their own,277 // since upright and recumbent share a tone and are told apart by hue.278 Solid -> list.repeat(224, 64)279 }280}281282/// The second plate: the clustered screen turned about 30 degrees, which is283/// what the second plate of a colour job is. Only ever an overprint — rotating284/// a screen changes nothing you can see until there is another screen for it to285/// be out of step with.286pub fn overprint_table(pattern: Pattern) -> Result(List(Int), Nil) {287 case has_overprint(pattern) {288 False -> Error(Nil)289 True -> Ok(cluster_rotated())290 }291}292293/// Dispersed. The classic ordered screen.294fn bayer() -> List(Int) {295 spread(296 [297 0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36,298 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22, 3, 35, 11, 43, 1, 33, 9, 41,299 51, 19, 59, 27, 49, 17, 57, 25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55,300 23, 61, 29, 53, 21,301 ],302 64,303 )304}305306/// Clustered. Thresholds ascend outward from two centres, so as tone darkens307/// the on-pixels grow into round blobs instead of scattering — which is what a308/// halftone screen is, and why this reads as printed rather than as a computer.309fn cluster_raw() -> List(Int) {310 [311 24, 10, 12, 26, 35, 47, 49, 37, 8, 0, 2, 14, 45, 59, 61, 51, 22, 6, 4, 16,312 43, 57, 63, 53, 30, 20, 18, 28, 33, 41, 55, 39, 34, 46, 48, 38, 25, 11, 13,313 27, 44, 58, 60, 50, 9, 1, 3, 15, 42, 56, 62, 52, 23, 7, 5, 17, 32, 40, 54,314 36, 31, 21, 19, 29,315 ]316}317318fn cluster() -> List(Int) {319 spread(cluster_raw(), 64)320}321322fn cluster_rotated() -> List(Int) {323 let base = cluster()324 list.flat_map(grid_range(), fn(y) {325 list.map(grid_range(), fn(x) {326 // A shear either way approximates a rotation on a grid this small, and327 // unlike a real rotation it still tiles.328 let sy = { y + { x * 3 } / 8 } % 8329 let sx = { x + { y * 5 } / 8 } % 8330 base |> list.drop(sy * 8 + sx) |> list.first |> result.unwrap(0)331 })332 })333}334335/// One axis only, so tone becomes the width of a rule.336///337/// The eight levels ascend outward from the middle rather than running top to338/// bottom, so darkening widens one band symmetrically instead of adding a stray339/// row at the edge — and, just as importantly, keeps the band away from the340/// tile seam, where a small block could miss it entirely.341fn ruled_order() -> List(Int) {342 [6, 4, 2, 0, 1, 3, 5, 7]343}344345fn order_at(index: Int) -> Int {346 ruled_order() |> list.drop(index) |> list.first |> result.unwrap(0)347}348349fn band(levels: fn(Int, Int) -> Int) -> List(Int) {350 list.flat_map(grid_range(), fn(y) {351 list.map(grid_range(), fn(x) {352 float_round({ int.to_float(levels(x, y)) +. 0.5 } /. 8.0 *. 255.0)353 })354 })355}356357fn lines() -> List(Int) {358 band(fn(_x, y) { order_at(y) })359}360361/// The same rule laid at 45 degrees. Not a dither at all — it is hatching, the362/// thing a pen does.363fn diagonal() -> List(Int) {364 band(fn(x, y) { order_at({ x + y } % 8) })365}366367/// Hatched both ways at once. The two rules are unioned rather than averaged —368/// a pixel is inked if either stroke would ink it — so tone thickens the mesh369/// instead of fading it, which is how a crosshatch actually darkens.370fn crosshatch() -> List(Int) {371 band(fn(x, y) {372 int.max(order_at({ x + y } % 8), order_at({ x - y + 8 } % 8))373 })374}375376fn grid_range() -> List(Int) {377 [0, 1, 2, 3, 4, 5, 6, 7]378}