Add braille renderer with amber demo GIF

- Braille canvas: 2×4 dots per cell (4× resolution vs half-blocks)
- Shared palettes, effects, and audio between both renderers
- README with both demos and build instructions for each

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
rolandnsharp
2026-04-07 20:08:31 +10:00
parent 5dbd3ebfb6
commit 7b7da1bca5
8 changed files with 344 additions and 14 deletions

155
src/osc/canvas/braille.nim Normal file
View File

@@ -0,0 +1,155 @@
## Braille-dot terminal canvas — 2×4 dots per character cell.
## Each cell maps to a Unicode braille character (U+2800U+28FF).
##
## Dot layout per cell:
## ┌───┐
## │ 0 3 │ bit 0 = top-left, bit 3 = top-right
## │ 1 4 │ bit 1 = mid-left, bit 4 = mid-right
## │ 2 5 │ bit 2 = bottom-left, bit 5 = bottom-right
## │ 6 7 │ bit 6 = low-left, bit 7 = low-right
## └───┘
##
## Resolution: terminal columns × 2, terminal rows × 4
import terminal
import term except Canvas, newCanvas, resize, pixIdx, addPixel, decayPixels, flush
const MinBright* = 0.02
type
BrailleCanvas* = object
w*, h*: int # terminal columns/rows
pixW*, pixH*: int # pixel dimensions (w*2, h*4)
pixels*: seq[float] # brightness per pixel [pixW * pixH]
buf: string
pal*: Palette
thresholds*: array[3, float]
# ── Init / resize ───────────────────────────────────────────────────
proc newBrailleCanvas*(w, h: int, palette = "green",
thresholds = [0.7, 0.4, 0.15]): BrailleCanvas =
BrailleCanvas(
w: w, h: h, pixW: w * 2, pixH: h * 4,
pixels: newSeq[float](w * 2 * h * 4),
buf: newStringOfCap(w * h * 16),
pal: makePalette(palette),
thresholds: thresholds
)
proc resize*(c: var BrailleCanvas, w, h: int) =
if w == c.w and h == c.h: return
let pal = c.pal
let thr = c.thresholds
c = BrailleCanvas(
w: w, h: h, pixW: w * 2, pixH: h * 4,
pixels: newSeq[float](w * 2 * h * 4),
buf: newStringOfCap(w * h * 16),
pal: pal, thresholds: thr
)
# ── Pixel operations ─────────────────────────────────────────────────
proc pixIdx*(c: BrailleCanvas, x, y: int): int {.inline.} = y * c.pixW + x
proc addPixel*(c: var BrailleCanvas, x, y: int, intensity: float) {.inline.} =
if x >= 0 and x < c.pixW and y >= 0 and y < c.pixH:
let i = c.pixIdx(x, y)
c.pixels[i] = min(c.pixels[i] + intensity, 1.0)
proc decayPixels*(c: var BrailleCanvas, factor: float) =
for i in 0..<c.pixels.len:
c.pixels[i] *= factor
if c.pixels[i] < MinBright: c.pixels[i] = 0.0
# ── Braille encoding ─────────────────────────────────────────────────
# Braille base: U+2800
# Bit positions within the cell:
# col 0: bits 0,1,2,6 (top to bottom)
# col 1: bits 3,4,5,7 (top to bottom)
const DotBits = [
[0u8, 1, 2, 6], # left column: rows 0-3
[3u8, 4, 5, 7], # right column: rows 0-3
]
proc brailleChar(pattern: uint8): string =
## Convert an 8-bit dot pattern to a UTF-8 braille character.
let codepoint = 0x2800 + pattern.int
# UTF-8 encode: braille is U+2800..U+28FF (3 bytes)
result = newString(3)
result[0] = char(0xE0 or (codepoint shr 12))
result[1] = char(0x80 or ((codepoint shr 6) and 0x3F))
result[2] = char(0x80 or (codepoint and 0x3F))
# ── Flush ────────────────────────────────────────────────────────────
proc tintFor*(c: BrailleCanvas, b: float): Tint {.inline.} =
if b > c.thresholds[0]: tHot
elif b > c.thresholds[1]: tWarm
elif b > c.thresholds[2]: tNormal
else: tDim
proc ansiFor*(c: BrailleCanvas, t: Tint): string {.inline.} =
case t
of tHot: c.pal.hot
of tWarm: c.pal.bright
of tNormal: c.pal.normal
of tDim: c.pal.dim
of tNone: c.pal.reset
proc flush*(c: var BrailleCanvas,
textOverlays: openArray[(int, int, Tint, string)] = []) =
c.buf.setLen(0)
c.buf.add "\x1b[H"
var last = tNone
for ty in 0..<c.h:
for tx in 0..<c.w:
# Build the 8-bit dot pattern for this cell
var pattern: uint8 = 0
var maxB: float = 0.0
var anyLit = false
for col in 0..1:
for row in 0..3:
let px = tx * 2 + col
let py = ty * 4 + row
if px < c.pixW and py < c.pixH:
let b = c.pixels[c.pixIdx(px, py)]
if b > MinBright:
pattern = pattern or (1u8 shl DotBits[col][row])
if b > maxB: maxB = b
anyLit = true
if anyLit:
let tint = c.tintFor(maxB)
if tint != last:
c.buf.add "\x1b[0m"
c.buf.add c.ansiFor(tint)
last = tint
c.buf.add brailleChar(pattern)
else:
if last != tNone:
c.buf.add c.pal.reset
last = tNone
c.buf.add " "
c.buf.add "\x1b[0m"
for (x, y, tint, text) in textOverlays:
if y >= 0 and y < c.h and x >= 0:
c.buf.add "\x1b["
c.buf.add $(y + 1)
c.buf.add ";"
c.buf.add $(x + 1)
c.buf.add "H"
c.buf.add c.ansiFor(tint)
c.buf.add text
c.buf.add "\x1b[0m"
stdout.write c.buf
stdout.flushFile()
# Terminal setup/teardown and termWidth/termHeight — use from term.nim

View File

@@ -52,7 +52,7 @@ proc crtTurnOn*(c: Canvas) =
if elapsed < OnFlashMs:
# Phase 1: White flash
let ansi = if elapsed < OnFlashMs div 2: c.ansiFor(tHot) else: c.ansiFor(tBright)
let ansi = if elapsed < OnFlashMs div 2: c.ansiFor(tHot) else: c.ansiFor(tWarm)
buf.add "\x1b[0m"
buf.add ansi
for y in 0..<h:
@@ -69,7 +69,7 @@ proc crtTurnOn*(c: Canvas) =
let gx = rng.rand(w - 1)
let gy = rng.rand(h - 1)
let ch = GlitchChars[rng.rand(GlitchChars.high)]
let tint = if rng.rand(2) == 0: tBright else: tHot
let tint = if rng.rand(2) == 0: tWarm else: tHot
buf.goto(gx, gy)
buf.add "\x1b[0m"
buf.add c.ansiFor(tint)
@@ -79,7 +79,7 @@ proc crtTurnOn*(c: Canvas) =
# Phase 3: Phosphor ramp
let p = (elapsed - OnGlitchMs).float / (OnPhosphorMs - OnGlitchMs).float
let ch = if p < 0.4: "" elif p < 0.8: "" else: ""
let tint = if p < 0.4: tDim elif p < 0.7: tNormal else: tBright
let tint = if p < 0.4: tDim elif p < 0.7: tNormal else: tWarm
buf.add "\x1b[0m"
buf.add c.ansiFor(tint)
for y in 0..<h:
@@ -93,7 +93,7 @@ proc crtTurnOn*(c: Canvas) =
for x in 0..<w:
let r = lcg(seed)
let ch = NoiseChars[int(r shr 16) mod NoiseChars.len]
let tint = [tNormal, tBright, tHot, tNormal][int(r shr 24) mod 4]
let tint = [tNormal, tWarm, tHot, tNormal][int(r shr 24) mod 4]
buf.add "\x1b[0m"
buf.add c.ansiFor(tint)
buf.add ch
@@ -114,7 +114,7 @@ proc crtTurnOn*(c: Canvas) =
for x in 0..<w: buf.add ""
elif dist == 1:
buf.add "\x1b[0m"
buf.add c.ansiFor(tBright)
buf.add c.ansiFor(tWarm)
for x in 0..<w: buf.add ""
elif y < beamRow:
buf.add "\x1b[0m"
@@ -148,7 +148,7 @@ proc crtTurnOff*(c: Canvas) =
for y in max(cy - halfH, 0)..<min(cy + halfH + 1, h):
let b = 1.0 - abs(y - cy).float / max(halfH, 1).float * 0.6
let ch = if b > 0.8: "" elif b > 0.6: "" elif b > 0.3: "" else: "·"
let tint = if b > 0.8: tHot elif b > 0.4: tBright else: tNormal
let tint = if b > 0.8: tHot elif b > 0.4: tWarm else: tNormal
buf.goto(0, y)
buf.add "\x1b[0m"
buf.add c.ansiFor(tint)
@@ -184,7 +184,7 @@ proc crtTurnOff*(c: Canvas) =
elif dist < glowR.float * 0.4: ""
else: "·"
let tint = if falloff > 0.7: tHot
elif falloff > 0.4: tBright
elif falloff > 0.4: tWarm
else: tNormal
buf.goto(px, py)
buf.add "\x1b[0m"

View File

@@ -10,14 +10,14 @@ const
type
Tint* = enum
tNone, tDim, tNormal, tBright, tHot
tNone, tDim, tNormal, tWarm, tHot
Palette* = object
hot*: string # white-hot beam core
bright*: string # bright phosphor
normal*: string # standard glow
dim*: string # faint persistence
reset: string
reset*: string
Canvas* = object
w*, h*: int
@@ -94,14 +94,14 @@ proc decayPixels*(c: var Canvas, factor: float) =
proc tintFor*(c: Canvas, b: float): Tint {.inline.} =
if b > c.thresholds[0]: tHot
elif b > c.thresholds[1]: tBright
elif b > c.thresholds[1]: tWarm
elif b > c.thresholds[2]: tNormal
else: tDim
proc ansiFor*(c: Canvas, t: Tint): string {.inline.} =
case t
of tHot: c.pal.hot
of tBright: c.pal.bright
of tWarm: c.pal.bright
of tNormal: c.pal.normal
of tDim: c.pal.dim
of tNone: c.pal.reset