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`

+### dial
+
+
+
+Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
+
+
+
### flowfield

@@ -185,6 +198,14 @@ Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`

+### orbit
+
+
+
+Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0`
+
+
+
### polar

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)
+ }
+}