Book › Developers › Architecture

Architecture

How Nekudot is put together, for anyone hacking on it.

Stack

Vanilla TypeScript + the Canvas 2D API, bundled by Vite. No UI framework - the DOM is built by hand. d3-quadtree powers neighbour lookups, zod validates all persisted data, and fflate zips the .nekudot archive.

Module map

src/
  main.ts              composition root: builds the stage, panels and navbar, wires src/app
  app/                 the app wiring, one concern per module
    drawing-input.ts   the pointer → brush stroke loop
    overlay.ts         pointer-transparent canvases (invisible glow, symmetry guides)
    map-highlight.ts   flash a memory map's dots over the canvas
    history.ts         AppHistory - undo snapshots + paint persistence plumbing
    maps-control.ts    controller behind the Maps box
    presets.ts         custom connection presets (save/update/delete/import/export)
    app-shortcuts.ts   the global shortcut table + panel hide/show toggle
  paint-host.ts        PaintHost - the one drawing surface a brush paints through;
                       createBareHost() for tests, GEOMETRY_METHODS for symmetry
  base.ts              BrushBase - the brush template; delegates connecting to a Connection
  renderer.ts          IRenderer + CanvasRenderer (Canvas2D, dpr-scaled)
  neighbor-finder.ts   NeighborFinder + quadtree (capped point cloud)
  connecting-types.ts  shared types: ConnectMap/Mode, ConnectRouter, dash
  pixel-log.ts         append-only per-pixel provenance log (IndexedDB)
  save-artwork.ts      export a .nekudot zip
  load-artwork.ts      validate + import a .nekudot (zip-bomb safe)
  export.ts            flatten layers to a PNG
  brushes/             one file per brush (Shapes = squares + circles)
    brushes.json       ordered brush index (name/file/shortcut/group/connections)
    registry.ts        builds BRUSH_DEFS from brushes.json - map, menu, shortcuts, names
    connections/       the connecting engine + art-style "connections"
      base.ts          ConnectionBase - connecting state + the web/fan engine
      registry.ts      builds the connection list from connections.json
      connections.json ordered index of connection styles
      routing.ts       ROUTING_PRESETS - where a stroke reads/stores neighbours
      classic.ts fur.ts   generic + code styles (the rest are data in connections.json)
  symmetry/            symmetry modes, each a plugin (like brushes), + the proxy
    tool.ts            SymmetryTool base - transforms(), settings(), guides
    registry.ts        builds the tool list from symmetry.json
    symmetry.json      ordered index of symmetry modes
    transforms.ts      shared affine primitives (Transform + builders)
    proxy.ts           replays every mark + deposit at each transform
    controller.ts      active mode + shared movable centre + guide style
    menu-section.ts box.ts   the panel (generic, from each tool's settings())
    tools/             radial.ts mirror.ts concentric.ts spiral.ts tile.ts
  clip/                record a few seconds of drawing to an animated GIF
    recorder.ts        captures downscaled canvas frames on a timer
    encode-gif.ts      gifenc encoder + the nekudot.app provenance comment
    exporters.ts       Exporter registry (format-agnostic; GIF today)
    timeline.ts        speed + trim math (pure)
    preview-box.ts     preview/edit modal (loop, speed, trim)
    record-flow.ts     arms on Record, starts on first stroke, the REC pill
  layered/
    manager.ts         LayerManager - the app's PaintHost over the layer stack + maps
    layer.ts           Layer = one canvas + renderer
    wet-stroke.ts      WetStrokeBuffer - faint continuous strokes composite at one alpha
    neighbors-map.ts   NeighborsMap = a named point cloud
    schema.ts          zod schema for the layer/map config
    box.ts             Layers panel UI
    maps-box.ts        Maps panel UI
    size-picker.ts     New-art size dialog
  store/               LocalStorage + IndexedDB stores, PaintStore, UndoStore
  menu.ts settings-panel.ts shortcuts.ts confirm.ts chip.ts help.ts drag.ts window-stack.ts

The drawing surface: PaintHost

Everything that draws goes through IRenderer (renderer.ts) - drawLine, fillCircle, strokeEllipse, drawChisel, etc. CanvasRenderer wraps a single CanvasRenderingContext2D and is scaled by the device pixel ratio once at construction, so brushes work in CSS pixels and stay crisp on retina.

A brush, though, holds one PaintHost (paint-host.ts): a single object serving three roles - IRenderer (marks land on the active layer), NeighborFinder (points land in the selected memory map) and ConnectRouter (target layers/maps by stable id). At runtime the host is the LayerManager, wrapped in the symmetry proxy so every mark and deposit mirrors under the active symmetry tool (Radial, Mirror, Concentric, Spiral, Tile). Tests and the headless render harnesses build one with createBareHost(renderer, finder) - a neutral router over a bare canvas - so they run the exact same code paths as the app.

The symmetry proxy overrides exactly the geometry-bearing methods listed in GEOMETRY_METHODS (paint-host.ts); its override table is typed against that list, and tests/symmetry-coverage.test.ts forces every new renderer method to be classified - so a new draw call can't silently skip symmetry.

Layers & memory maps

A Layer is one DOM canvas + renderer. The LayerManager owns the stack, composites by z-index, and exposes layer ops (add/duplicate/delete/opacity).

Point clouds are separate and top-level: each NeighborsMap owns a NeighborFinder (a d3-quadtree) - see the Memory maps page. The manager implements NeighborFinder too, routing addPixel/findNeighbors to the selected map. The cloud is capped (MAX_PIXELS) with oldest-eviction so lookups stay fast.

The stroke loop

app/drawing-input.ts turns pointer input into brush calls. Pointer moves are de-coalesced so fast gestures keep full resolution - but a connecting brush weaves its web only on the last sample per frame (Harmony's per-move cadence), or the web would build up quadratically with the pointer's report rate:

stage.addEventListener("pointerdown", (e) => {
  symmetry.beginStroke(...);          // freeze the transforms for this stroke
  brush.strokeStart(e.offsetX, e.offsetY);
  brush.stroke(e.offsetX, e.offsetY);
});
stage.addEventListener("pointermove", (e) => {
  const list = e.getCoalescedEvents();
  const frameCadence = brush.supportsConnecting();
  for (let i = 0; i < list.length; i++)   // mark: every sample; web: last only
    brush.stroke(list[i].offsetX, list[i].offsetY,
                 !frameCadence || i === list.length - 1);
});
// pointerup → brush.strokeEnd(); refresh previews; persist; pushUndo

BrushBase.stroke() is a template method: it deposits the point into the trail map, calls the subclass's onStroke() to paint the mark, then - if the brush has a Connection attached - lets it weave the web via connection.connect().

A faint continuous stroke (Round below full opacity) is buffered in a wet-stroke buffer (layered/wet-stroke.ts): the line draws opaque into an off-buffer shown at the stroke's opacity, then composites onto the layer in one pass on pointer-up - so it reads as one uniform alpha instead of darkening where segment caps overlap.

Connecting & ConnectRouter

To target specific layers/maps by stable id, the manager implements ConnectRouter (connecting-types.ts): listLayers/listMaps, addPixelToMap, findNeighborsInMap, drawConnectionToLayer, activeLayerId/selectedMapId/strokeWidth, etc. The router is the third role of the brush's PaintHost, so connections call it directly; createBareHost supplies a neutral one (no layers, a single map) for tests and the headless harnesses.

The connecting half is its own module under src/brushes/connections/. ConnectionBase holds all connecting state and the web/fan engine (connectingNeighbors, drawFanned, drawHair); each art style - Classic, Web, Shaded, Fur, Lace - is a subclass that just sets defaults, names the dials it exposes, and may override a texture hook (Fur's drawHair). A connecting brush attaches one with initConnection("classic") and the Web tab swaps it via applyArtStylePreset; routing presets live in connections/routing.ts. Both the brush list and the connection list are data-driven from JSON indexes (brushes.json, connections.json) resolved with import.meta.glob.

Persistence

Save / load / export

save-artwork.ts writes a .nekudot zip (manifest + per-layer PNGs + map JSON + pixel log). load-artwork.ts imports it defensively: validate the manifest with zod first, cap each entry's uncompressed size before inflating (zip-bomb defence), decode images via createImageBitmap. export.ts flattens the visible layers into a single PNG. build.sh bundles the whole app into one self-contained HTML file.

Clip capture (GIF)

src/clip/ records a few seconds of drawing into an animated GIF. It's frame-based, not a video: recorder.ts composites the layers into one downscaled canvas on a 12fps timer (independent of the draw loop) and holds the frames in memory, so the preview can change speed and trim instantly without re-encoding. record-flow.ts arms on the Record action and starts capturing on your first stroke (so idle time isn't recorded); the modal in preview-box.ts loops the frames with a speed slider and a two-handle trim. Save runs encode-gif.ts - gifenc on the main thread (the single-file build inlines one JS chunk, so a worker encoder would break it), which also writes a provenance comment into the GIF metadata. Output formats go through an Exporter registry (exporters.ts), so adding e.g. WebM is one entry; timeline.ts holds the speed/trim math.

Ready to add a brush? See Writing a brush.