diff --git a/README.md b/README.md index 146aef4..2b4fa7e 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,10 @@ Fields are length-prefixed, so `1.23.4` and `12.3.4` cannot collide. **2 — Parameter mapping.** A `Renderer` draws its parameters from those streams and decides what the mark *means*. This is the stage that determines whether -the library is useful. +the library is useful. Not every renderer hashes: `cistercian`, `dial` and +`orbit` encode the number itself, so the version can be read back out of the +mark. Those are marked `Ordered` and are held to uniqueness rather than to the +distance thresholds — being systematic is the point of them. **3 — Render primitives.** A `canvas.Canvas` turns drawing calls into SVG, pixels or terminal cells. This stage decides only how it looks. Every renderer @@ -134,6 +137,8 @@ blazon dist -r truchet | `truchet` | Truchet tiles. Arcs meet at cell edge midpoints, so the marks join across the grid into loops and labyrinths. | | `polar` | A bitmap in rings and sectors instead of rows and columns, mirrored across a fold from the archetype stream, with a centre device in the hole and a radial profile that varies ring by ring. | | `cistercian` | Medieval numerals: one stave per version component, four digits per stave. Legible — you can read the version back out of it. | +| `dial` | One quadrant per component, the value written in binary as radial bands. The separator is the angle: the spokes are the dots of the version string. | +| `orbit` | One ring per component, major innermost, the value written in binary as cells running clockwise from noon. The separator is the radius. | | `bishop` | The drunken bishop walk from OpenSSH randomart, for the terminal. | ## Output @@ -177,6 +182,14 @@ Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` ![cistercian family strip](docs/gallery/cistercian-family.png) +### dial + +![dial across a version corpus](docs/gallery/dial-sheet.png) + +Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` + +![dial family strip](docs/gallery/dial-family.png) + ### flowfield ![flowfield across a version corpus](docs/gallery/flowfield-sheet.png) @@ -185,6 +198,14 @@ Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` ![flowfield family strip](docs/gallery/flowfield-family.png) +### orbit + +![orbit across a version corpus](docs/gallery/orbit-sheet.png) + +Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` + +![orbit family strip](docs/gallery/orbit-family.png) + ### polar ![polar across a version corpus](docs/gallery/polar-sheet.png) diff --git a/distinctness_test.go b/distinctness_test.go index f2a2240..fb9e81e 100644 --- a/distinctness_test.go +++ b/distinctness_test.go @@ -35,6 +35,11 @@ var thresholds = map[string]blazontest.Thresholds{ // skips the distance thresholds for it and checks that no two versions // share a mark instead, which is why this entry is empty. "cistercian": {}, + // dial and orbit are ordered for the same reason: they write the version + // in binary, one field per component, with the field boundary standing in + // for the dot. + "dial": {}, + "orbit": {}, "bishop": { MinPatch: 14, MinMinor: 23, diff --git a/docs/gallery/dial-1.4.2.svg b/docs/gallery/dial-1.4.2.svg new file mode 100644 index 0000000..7175d3e --- /dev/null +++ b/docs/gallery/dial-1.4.2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/docs/gallery/dial-family.png b/docs/gallery/dial-family.png new file mode 100644 index 0000000..d92ff16 Binary files /dev/null and b/docs/gallery/dial-family.png differ diff --git a/docs/gallery/dial-sheet.png b/docs/gallery/dial-sheet.png new file mode 100644 index 0000000..57b1078 Binary files /dev/null and b/docs/gallery/dial-sheet.png differ diff --git a/docs/gallery/orbit-1.4.2.svg b/docs/gallery/orbit-1.4.2.svg new file mode 100644 index 0000000..411ee8a --- /dev/null +++ b/docs/gallery/orbit-1.4.2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/gallery/orbit-family.png b/docs/gallery/orbit-family.png new file mode 100644 index 0000000..b9eb0fc Binary files /dev/null and b/docs/gallery/orbit-family.png differ diff --git a/docs/gallery/orbit-sheet.png b/docs/gallery/orbit-sheet.png new file mode 100644 index 0000000..78ee8e9 Binary files /dev/null and b/docs/gallery/orbit-sheet.png differ diff --git a/render_codex.go b/render_codex.go new file mode 100644 index 0000000..74a575f --- /dev/null +++ b/render_codex.go @@ -0,0 +1,312 @@ +package blazon + +import ( + "image/color" + "math" + "math/bits" + + "github.com/timzifer/blazon/canvas" +) + +func init() { + Register(dialRenderer{}) + Register(orbitRenderer{}) +} + +// The two codex renderers take the version's punctuation literally. +// +// A semantic version is separated by dots and a dash. Here the separator is +// geometric: in dial it is an angle, in orbit a radius. Each component gets a +// field of its own, and the field boundary is the dot. Nothing is hashed +// except the prerelease, so the marks are ordered — 1.4.2 and 1.4.3 differ by +// one cell, deliberately — and both are exempt from the distinctness +// thresholds and held to uniqueness instead. +// +// Within a field the value is written in binary, one cell per bit, most +// significant first: outermost band in dial, first cell clockwise from noon in +// orbit. Binary rather than a tally because version components are unbounded +// and a tally stops being readable somewhere around sixteen; binary reads any +// component a semantic version can hold, and reads it exactly. Two minutes of +// learning buys the whole scheme. +// +// The prerelease field is the exception: it carries hash bits from the +// prerelease stream, not a number. There is no useful ordering over "rc.1" +// versus "beta.2", so the field says *that* this is a prerelease and which one, +// without pretending to be legible. An empty field means a release, which is +// the distinction that matters at a glance. + +// codexMinBits is the smallest field width. Eight cells looks like a field +// even when the version is 0.1.0, where a width fitted to the value would be a +// single cell and the mark would read as a mistake. +const codexMinBits = 8 + +// codexFieldCount is major, minor, patch, prerelease. +const codexFieldCount = 4 + +// codexWidth returns how many cells each field gets. All fields share one +// width so that the mark reads as a table rather than as four ragged rows, and +// so that a decoder needs no separators beyond the geometric ones. +// +// The width is rounded up to a whole nibble, which makes counting cells in +// groups of four possible by eye — the same reason hex is grouped that way. +func codexWidth(v Version) int { + largest := v.Major + if v.Minor > largest { + largest = v.Minor + } + if v.Patch > largest { + largest = v.Patch + } + n := bits.Len64(largest) + if n < codexMinBits { + n = codexMinBits + } + n = (n + 3) / 4 * 4 + if n > 64 { + n = 64 + } + return n +} + +// codexField is one component's cells, most significant first. +type codexField struct { + // present is false for the prerelease field of a released version, which + // is drawn as an empty field rather than as zero. + present bool + // noise marks a field whose bits are hashed rather than counted. + noise bool + cells []bool +} + +// codexFields lays out the four fields for a version. +func codexFields(p *Params, width int) [codexFieldCount]codexField { + v := p.Version() + var out [codexFieldCount]codexField + + for i, value := range []uint64{v.Major, v.Minor, v.Patch} { + out[i] = codexField{present: true, cells: codexCells(value, width)} + } + + if v.IsPrerelease() { + out[3] = codexField{ + present: true, + noise: true, + cells: codexCells(p.Pre().Uint(uint(width)), width), + } + } + return out +} + +// codexCells renders a value most-significant-bit first. +func codexCells(value uint64, width int) []bool { + cells := make([]bool, width) + for i := 0; i < width; i++ { + shift := uint(width - 1 - i) + if shift < 64 && value>>shift&1 == 1 { + cells[i] = true + } + } + return cells +} + +// codexValue reads a field back. It exists for the round-trip test: an ordered +// renderer's promise is that the number can be recovered, and the honest way +// to check that is to recover it. +func codexValue(cells []bool) uint64 { + var v uint64 + for _, on := range cells { + v = v<<1 | boolBit(on) + } + return v +} + +func boolBit(b bool) uint64 { + if b { + return 1 + } + return 0 +} + +// ghostAlpha is how strongly an unset cell is drawn. +// +// Drawing the empty cells at all is what makes these renderers readable. With +// only the set bits shown, a small version lights a handful of cells at one end +// of its field and the rest of the mark is blank: there is no visible grid, so +// there is nothing to count against and no way to see which bit position a +// mark belongs to. The ghosts turn the mark into a form with boxes to tick. +const ghostAlpha = 36 + +// fade returns a colour at the given alpha, premultiplied as image/color +// expects. +func fade(c color.Color, alpha uint8) color.RGBA { + r, g, b, _ := c.RGBA() + a := uint32(alpha) + return color.RGBA{ + R: uint8(r * a / 0xffff >> 8), + G: uint8(g * a / 0xffff >> 8), + B: uint8(b * a / 0xffff >> 8), + A: alpha, + } +} + +// codexInk maps a field to a palette slot. Major, minor and patch each get +// their own, so the eye can find the field it wants without counting. +func codexInk(field int) int { + if field >= 3 { + return 2 + } + return field +} + +// dialRenderer gives each component a quadrant, and writes its bits as radial +// bands. The separator is the angle: the spokes at the quadrant boundaries are +// the dots of the version string. +// +// Reading order is clockwise from noon, the way a clock is read: major in the +// first quadrant, then minor, patch and prerelease. Within a quadrant the +// outermost band is the most significant bit, so a larger number reaches +// further out. +type dialRenderer struct{} + +func (dialRenderer) Name() string { return "dial" } + +func (dialRenderer) Caps() Caps { return Caps{Ordered: true} } + +func (dialRenderer) Render(ctx *Context) error { + p := ctx.Params + cx, cy := ctx.Box.Center() + outer := ctx.Box.Min() / 2 + + width := codexWidth(p.Version()) + fields := codexFields(p, width) + + // The hub is where the spokes meet; the bands start outside it. + hub := outer * 0.15 + inner := outer * 0.24 + // A gap on each side of a quadrant boundary, so the separator reads as a + // separator rather than as two touching fields. + const quadrantGap = 0.06 + + bandStep := (outer - inner) / float64(width) + + for f, field := range fields { + if !field.present { + continue + } + // Quadrants run clockwise from noon. In canvas coordinates y grows + // downwards, so increasing angle is already clockwise. + base := -math.Pi/2 + math.Pi/2*float64(f) + a0 := base + math.Pi/2*quadrantGap + a1 := base + math.Pi/2*(1-quadrantGap) + + ink := ctx.Palette.Ink(codexInk(f)) + + // The grid first, then the bits on top of it. + for i := range field.cells { + band := width - 1 - i + r0 := inner + bandStep*float64(band) + canvas.AnnularSector(ctx.Canvas, cx, cy, r0, r0+bandStep*0.82, a0, a1) + } + ctx.Canvas.Fill(fade(ink, ghostAlpha)) + + any := false + for i, on := range field.cells { + if !on { + continue + } + any = true + // Cell 0 is the most significant bit and sits outermost. + band := width - 1 - i + r0 := inner + bandStep*float64(band) + canvas.AnnularSector(ctx.Canvas, cx, cy, r0, r0+bandStep*0.82, a0, a1) + } + if any { + ctx.Canvas.Fill(ink) + } + } + + // The spokes, drawn last so they sit on top of the bands: these are the + // punctuation. + for f := 0; f < codexFieldCount; f++ { + a := -math.Pi/2 + math.Pi/2*float64(f) + ctx.Canvas.MoveTo(cx+hub*math.Cos(a), cy+hub*math.Sin(a)) + ctx.Canvas.LineTo(cx+outer*math.Cos(a), cy+outer*math.Sin(a)) + } + ctx.Canvas.Stroke(ctx.Palette.Ink(0), outer*0.016) + + circle(ctx.Canvas, cx, cy, hub*0.6) + ctx.Canvas.Fill(ctx.Palette.Ink(0)) + + MarkPrerelease(ctx) + return nil +} + +// orbitRenderer gives each component a ring, and writes its bits as cells +// around it. The separator is the radius: crossing from one ring to the next +// is the dot. +// +// Major is innermost, so the version reads outwards in the order it is +// written. Each ring starts at noon and runs clockwise, most significant bit +// first; a tick at noon marks where to start reading. +type orbitRenderer struct{} + +func (orbitRenderer) Name() string { return "orbit" } + +func (orbitRenderer) Caps() Caps { return Caps{Ordered: true} } + +func (orbitRenderer) Render(ctx *Context) error { + p := ctx.Params + cx, cy := ctx.Box.Center() + outer := ctx.Box.Min() / 2 + + width := codexWidth(p.Version()) + fields := codexFields(p, width) + + // A hole in the middle, then one ring per field. + hole := outer * 0.22 + ringStep := (outer - hole) / codexFieldCount + // A share of each ring left blank, so the radius reads as a separator. + const ringGap = 0.22 + // A share of each cell left blank, so neighbouring bits stay countable. + const cellGap = 0.12 + + for f, field := range fields { + if !field.present { + continue + } + r0 := hole + ringStep*float64(f) + r1 := r0 + ringStep*(1-ringGap) + + step := 2 * math.Pi / float64(width) + ink := ctx.Palette.Ink(codexInk(f)) + + for i := range field.cells { + a0 := -math.Pi/2 + step*float64(i) + canvas.AnnularSector(ctx.Canvas, cx, cy, r0, r1, a0, a0+step*(1-cellGap)) + } + ctx.Canvas.Fill(fade(ink, ghostAlpha)) + + any := false + for i, on := range field.cells { + if !on { + continue + } + any = true + // Cell 0 is the most significant bit and starts at noon. + a0 := -math.Pi/2 + step*float64(i) + canvas.AnnularSector(ctx.Canvas, cx, cy, r0, r1, a0, a0+step*(1-cellGap)) + } + if any { + ctx.Canvas.Fill(ink) + } + } + + // The noon tick: without it a ring of bits has no beginning and the mark + // is decorative rather than readable. + ctx.Canvas.MoveTo(cx, cy-hole*0.55) + ctx.Canvas.LineTo(cx, cy-outer) + ctx.Canvas.Stroke(ctx.Palette.Ink(0), outer*0.02) + + MarkPrerelease(ctx) + return nil +} diff --git a/render_codex_test.go b/render_codex_test.go new file mode 100644 index 0000000..d7bb453 --- /dev/null +++ b/render_codex_test.go @@ -0,0 +1,161 @@ +package blazon + +import ( + "fmt" + "testing" +) + +func TestCodexWidth(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"0.0.0", 8}, + {"1.4.2", 8}, + {"255.0.0", 8}, + {"256.0.0", 12}, + {"0.4095.0", 12}, + {"0.0.4096", 16}, + {"18446744073709551615.0.0", 64}, + } + for _, c := range cases { + v, err := ParseVersion(c.in) + if err != nil { + t.Fatalf("%s: %v", c.in, err) + } + if got := codexWidth(v); got != c.want { + t.Errorf("codexWidth(%s) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestCodexWidthIsAWholeNibble(t *testing.T) { + for _, s := range []string{"0.0.0", "1.4.2", "300.12.5", "70000.0.0", "1.0.0-rc.1"} { + v, _ := ParseVersion(s) + if w := codexWidth(v); w%4 != 0 { + t.Errorf("codexWidth(%s) = %d, which is not a whole nibble", s, w) + } + } +} + +// TestCodexRoundTrip is the promise an ordered renderer makes, checked by +// keeping it: the number has to come back out of the cells. +func TestCodexRoundTrip(t *testing.T) { + versions := []string{ + "0.0.0", "0.0.1", "1.0.0", "1.4.2", "1.4.3", "12.4.7", + "300.12.5", "65535.1.0", "1.0.0-rc.1", "18446744073709551615.0.0", + } + for _, s := range versions { + v, err := ParseVersion(s) + if err != nil { + t.Fatalf("%s: %v", s, err) + } + p := NewParams(NewSeed(v), PolicyFamilyAmplified) + width := codexWidth(v) + fields := codexFields(p, width) + + got := Version{ + Major: codexValue(fields[0].cells), + Minor: codexValue(fields[1].cells), + Patch: codexValue(fields[2].cells), + } + if got != v.Core() { + t.Errorf("%s decoded to %s", s, got) + } + } +} + +func TestCodexMostSignificantBitComesFirst(t *testing.T) { + v, _ := ParseVersion("1.0.0") + p := NewParams(NewSeed(v), PolicyFamilyAmplified) + cells := codexFields(p, 8)[0].cells + + // The value 1 is the least significant bit, so only the last cell is lit. + for i, on := range cells { + want := i == len(cells)-1 + if on != want { + t.Fatalf("cell %d of major=1 is %v, want %v (cells %v)", i, on, want, cells) + } + } +} + +func TestCodexPrereleaseFieldOnlyForPrereleases(t *testing.T) { + release, _ := ParseVersion("1.0.0") + pre, _ := ParseVersion("1.0.0-rc.1") + + rf := codexFields(NewParams(NewSeed(release), PolicyFamilyAmplified), 8) + if rf[3].present { + t.Error("a released version has a prerelease field") + } + pf := codexFields(NewParams(NewSeed(pre), PolicyFamilyAmplified), 8) + if !pf[3].present { + t.Fatal("a prerelease has no prerelease field") + } + if !pf[3].noise { + t.Error("the prerelease field is not marked as hashed") + } + // The core fields must be untouched by the prerelease. + for i := 0; i < 3; i++ { + if fmt.Sprint(rf[i].cells) != fmt.Sprint(pf[i].cells) { + t.Errorf("field %d differs between 1.0.0 and 1.0.0-rc.1", i) + } + } +} + +// TestCodexPrereleasesDiffer guards the one thing the noise field is for. +func TestCodexPrereleasesDiffer(t *testing.T) { + seen := map[string]string{} + for _, s := range []string{"1.0.0-alpha.1", "1.0.0-alpha.2", "1.0.0-beta.1", "1.0.0-rc.1", "1.0.0-rc.2"} { + v, _ := ParseVersion(s) + f := codexFields(NewParams(NewSeed(v), PolicyFamilyAmplified), 8) + key := fmt.Sprint(f[3].cells) + if prev, dup := seen[key]; dup { + t.Errorf("%s and %s produced the same prerelease field", prev, s) + continue + } + seen[key] = s + } +} + +func TestCodexRenderersAreOrdered(t *testing.T) { + for _, name := range []string{"dial", "orbit"} { + r, ok := Lookup(name) + if !ok { + t.Fatalf("%s is not registered", name) + } + if !r.Caps().Ordered { + t.Errorf("%s does not declare itself ordered", name) + } + + o := Options{Renderer: name, Size: 128, Palette: PaletteMono, Background: BackgroundLight} + a, err := SVG("1.4.2", o) + if err != nil { + t.Fatal(err) + } + b, err := SVG("1.4.3", o) + if err != nil { + t.Fatal(err) + } + if string(a) == string(b) { + t.Fatalf("%s: 1.4.2 and 1.4.3 render identically", name) + } + // One bit apart in the patch field, so nearly the whole document is + // shared. A hashing renderer would share almost nothing. + if shared := commonPrefix(string(a), string(b)); shared < len(a)/3 { + t.Errorf("%s: only %d of %d bytes shared between 1.4.2 and 1.4.3", + name, shared, len(a)) + } + } +} + +func TestFadeIsPremultiplied(t *testing.T) { + opaque := Palette{}.Ink(0) // opaque black + got := fade(opaque, 128) + if got.A != 128 { + t.Errorf("alpha = %d, want 128", got.A) + } + // Premultiplied: no channel may exceed alpha. + if got.R > got.A || got.G > got.A || got.B > got.A { + t.Errorf("%+v is not premultiplied", got) + } +}