On this page
Design for a single Go implementation of every PixelWeaver pixel semantic (geometry, fills, effects, compositing, roles, palettes/variants, dimensions, iso seams), consumed by the browser via WASM, the Python server via a c-shared library, and later a Go asset pipeline -- replacing today's divergent TypeScript and Python ports.
#Shared Go pixel core
PixelWeaver currently implements every pixel-mutating operation at least twice -- once in TypeScript for the browser and once in Python for the headless server -- and in several places three times, because the server itself has two independent code paths (the WebSocket path that replays browser commands and the MCP path that serves agent tools). These ports have drifted. Identical commands already produce different pixels depending on which path runs them.
This document proposes collapsing all pixel semantics into one Go module, compiled to WebAssembly for the browser, to a C-shared library for Python, and imported natively by a future Go asset pipeline. The Go source becomes the single source of truth; the TypeScript and Python algorithm cores are deleted.
#1. Motivation: one bug class, many instances
#1.1 The duplication census
Every entry below is the same algorithm implemented independently in two or three languages. Each pair is an opportunity for silent drift.
| Domain | TypeScript | Python (MCP) | Python (WS) |
|---|---|---|---|
| Geometry + fills (line/rect/ellipse/diamond/flood) | plugins/builtin/drawing-utils.ts | server/src/pixelweaver/command_raster.py | (shares MCP raster) |
| OKLab tolerance fill | plugins/builtin/advanced-fill-tool.ts | command_raster.py _rgb_to_oklab/oklab_distance/flood_fill_tolerance | -- |
| Gradient | plugins/builtin/gradient-tool.ts | command_raster.py _rasterize_gradient | server/src/pixelweaver/ws_drawing.py gradient_pixels |
| Noise | plugins/builtin/noise-tool.ts | command_raster.py _rasterize_noise | ws_drawing.py noise_pixels |
| Dither | plugins/builtin/dither-tool.ts | command_raster.py _rasterize_dither | ws_drawing.py dither_pixels |
| Pattern stamp | plugins/builtin/pattern-stamp-tool.ts | -- | ws_drawing.py pattern_pixels |
| Color + spatial effects | plugins/builtin/effects/*.ts (blur, glow, outline, shadow, sharpen, color-effects, flip, rotate, scale) | server/src/pixelweaver/effects.py | -- |
| Compositing / blend modes | src/lib/layers/compositor.ts | server/src/pixelweaver/layer_composite.py | -- |
| Role-plane raster ops | plugins/builtin/role-tool-utils.ts | server/src/pixelweaver/role_raster.py | -- |
| Palette / variant maths | src/lib/variants/* (palette-swap, palette-interpolation, palette-extraction) | server/src/pixelweaver/variant_palette.py | -- |
| Dimension ops (rotate/scale/crop/resize canvas) | plugin effects + tool code | server/src/pixelweaver/dimension_ops.py, resize.py | -- |
| Iso seam checking | src/lib/iso/seam-checker.ts | server/src/pixelweaver/iso_seams.py | -- |
Two independent PRNGs and one rounding convention are re-implemented in almost every file above: Mulberry32 (plugins/builtin/noise-tool.ts, server/src/pixelweaver/ws_drawing.py), xorshift32 (src/lib/util/seeded-rng.ts, server/src/pixelweaver/variant_palette.py, server/src/pixelweaver/mcp_variant_tools.py), and the JavaScript Math.round half-up convention floor(x + 0.5) -- spelled _js_round in ws_drawing.py:46, variant_palette.py:30, layer_composite.py:57, and part_export.py:333, and inline in mcp_manifest_tools.py:61.
#1.2 The seven live determinism divergences
These are not hypothetical. The same command, dispatched through the browser (WS path) versus an MCP agent, writes different bytes today:
- Gradient algorithm. The client and WS path
(ws_drawing.py:94 gradient_pixels) project each pixel onto the drag vector ((px-x0)*dx + (py-y0)*dy) / len_sq. The MCP path (command_raster.py:539 _rasterize_gradient) instead uses axis-aligned ramps keyed by a direction string (horizontal/vertical/diagonal) with t = dx / (w-1). Different geometry entirely.
- Gradient rounding. The WS path interpolates with
_js_round
(floor(x+0.5), ws_drawing.py:121); the MCP path uses Python's built-in round (banker's rounding, command_raster.py:536 _lerp). Half-values round in opposite directions.
- Noise PRNG. The WS path seeds Mulberry32 (
ws_drawing.py:170); the MCP
path seeds random.Random and, when no seed is supplied, derives it from Python's salted hash(...) (command_raster.py:574-577) -- non-reproducible across processes.
- Noise color selection. WS picks
int(rng() * len); MCP picks
rng.randrange(len) -- different index distributions over different streams.
- Dither color assignment is inverted. For
threshold < 0.5, the WS path
emits color1 (ws_drawing.py:156) while the MCP path emits color_a (command_raster.py:615-616). The two paths paint the checkerboard in opposite colors.
- Dither matrices differ. The WS path selects a matrix by
matrix_size
(ws_drawing.py:149); the MCP path selects by a pattern string (checker/bayer/ordered, command_raster.py:603-608), including a checker mode the WS path has no equivalent for.
- Noise/pattern seeding contract. The WS path requires an explicit
seed
in the command; the MCP path silently invents one. Same nominal command, different determinism guarantee.
Every one of these is a bug that a shared core makes structurally impossible: there is only one gradient, one PRNG seed contract, one dither. This design's primary justification is not code deduplication -- it is the elimination of the "browser and agent disagree" bug class.
#2. Module layout
#2.1 An in-repo Go module
The core lives in the existing repository under core/, as its own Go module. It claims no external package name -- a Go module path is just an import string.
- Module path (recommended):
pixelweaver.smmh.dev/core, matching the
domain already used for the strictspec schemas (schemas/part-manifest.toml) and selfdoc base_url (selfdoc.json). This is confirmation-pending, not a registry claim; a purely local path such as pixelweaver/core is an equally valid fallback.
- Workspace: a
go.workat the repo root lets the future Go asset pipeline
(a sibling module) resolve core/ locally without a published version. If no second module ever lands in-repo, go.work is optional and core/go.mod alone suffices.
- The Go module is self-contained: no dependency on Node, Python, or any
PixelWeaver runtime type. It is a pure computational library.
#2.2 Packages by domain
core/
buf/ RGBA and role buffer types, clamping, region math
rng/ Mulberry32, xorshift32 -- the two canonical PRNGs
color/ sRGB<->linear, OKLab, HSV, hex parse (culori constants, no deps)
geom/ line, rect, ellipse, diamond, polygon scanline, bresenham
fill/ flood fill, OKLab-tolerance flood fill
computed/ gradient, noise, dither, pattern stamp
effects/ blur, glow, outline, shadow, sharpen, color ops, flip
composite/ blend modes + source-over layer compositing
roles/ role-plane raster + role flood + resolve-plane-to-RGBA
dims/ rotate/scale/crop/resize canvas, single-layer transforms
iso/ isometric seam checking + iso outline
palette/ palette swap, interpolation, extraction, variant maths
png/ optional: deterministic PNG encode (see 4.4)
api/ the exported façade (WASM exports + cgo exports live here)No game or app semantics enter the core. It knows buffers, regions, palettes, and parameters -- never projects, layers-as-documents, undo history, MCP tools, or WebSocket framing. Those stay in the callers. Every function is "pure buffers + params in, buffers + dirty region out." This mirrors the existing boundary in part_export.py (headless, engine-agnostic) but pushes it one level lower, to the pixel primitives.
#3. The API boundary
#3.1 Function surface, derived from the command groups
The command groups are already enumerated in server/src/pixelweaver/command_applier.py:78-129. The core exposes one entry point per group, plus the palette/variant and iso helpers:
| Group (applier constant) | Commands | Core entry point |
|---|---|---|
_RGBA_RASTER | draw_pixels, erase_pixels, flood_fill, draw_line, draw_rect, draw_ellipse, draw_diamond | Draw(op, params, rgba) -> Region |
_RGBA_PRIMITIVE | scatter, copy_region, replace_color, checkerboard, draw_polygon, iso_outline, flood_fill_tolerance | Draw(...) (same entry) |
_RGBA_COMPUTED | draw_gradient, draw_noise, draw_dither, draw_pattern | Draw(...) (seed required, see 3.3) |
_EFFECT_COMMANDS | the _FIXED_DIM_EFFECTS set (effects.py:324) | ApplyEffect(name, params, rgba) -> (Region, Dims) |
_LAYER_TRANSFORM_COMMANDS | rotate, scale | ApplyEffect(...) (returns new Dims) |
_DIMENSION_COMMANDS | rotate_canvas, scale_canvas, crop_to_selection, resize_canvas | Dimension(op, params, rgba) -> (rgba', Dims) |
ROLE_COMMANDS (role_raster.py:31) | role paint / role flood / role line | RoleDraw(op, params, plane) -> Region |
| (resolve) | role plane -> resolved RGBA | ResolveRolePlane(plane, palette) -> rgba |
| palette / variants | swap, interpolate, extract, variant build | PaletteSwap, PaletteInterpolate, PaletteExtract, BuildVariant |
| iso | seam check | CheckSeams(params, rgba) -> []Seam |
_RGBA_EMBEDDED (cut/paste/delete/move selection, command_applier.py:88) carries verbatim per-pixel RGBA and needs no algorithm -- it stays a plain buffer write in the callers, not a core call.
#3.2 The canonical parameter schema
Today the browser and MCP disagree on parameter shapes: per-pixel RGBA + layerId + x0/y0/x1/y1 on the client versus a single color + layer_id + x1/y1/x2/y2 for MCP (documented at command_applier.py:1-19). This split is a second source of divergence.
The core defines the one canonical parameter schema for every operation. Both callers adapt into it at their boundary:
- The Python WS applier already normalizes client commands before rastering;
that normalization moves to a thin adapter that targets the core schema.
- The MCP tools normalize their own arguments into the same core schema.
- The client, calling the core via WASM, builds the core schema directly.
Concretely the core accepts a small flat parameter struct per op (integers and fixed enums -- no maps of arbitrary JSON), which keeps the WASM boundary TinyGo-friendly (see 4.1) and removes the client/MCP schema fork by construction.
#3.3 Buffer ownership, copy semantics, and seeds
- RGBA buffers are flat, 4 bytes per pixel,
Uint8ClampedArraysemantics:
writes clamp to [0,255]. In Go this is []byte with clamping in buf.
- Role planes are 1 byte per pixel, sentinel
0xFF= transparent role,
matching today's server planes and the exported indexed strips (docs/part-manifest-format.md).
- Regions returned are dirty rectangles
{x, y, w, h}so callers repaint
minimally.
- Ownership per boundary:
- JS <-> WASM: the buffer lives in WASM linear memory. The client copies the canvas bytes in, calls the op, reads the mutated region out, copies back to the ImageData. Canvas sizes are 32..256 squared, so a full copy is at most 256 KB and negligible per the existing measurements. - Python <-> cgo: Python hands a pointer to a bytes/bytearray (via ctypes/cffi); the core mutates in place or writes into a caller-provided output buffer. No hidden allocation crosses the boundary; the core never frees memory it did not allocate.
- Seeds are explicit everywhere. The core never calls
Date.now(),
time.Now(), or a hash of inputs to invent a seed (this is exactly divergence #3/#7 above). Any stochastic op (draw_noise, scatter, variant randomizer) takes an explicit uint32 seed parameter; supplying it is the caller's responsibility. A missing seed is a hard error, not a silently-invented value -- consistent with the repo's "no implicit defaults / no silent degradation" rules.
#4. Artifacts and toolchains
One Go source tree, three build outputs.
#4.1 Browser: TinyGo -> WASM (standard Go WASM as fallback)
- Primary: TinyGo. TinyGo emits compact WASM (tens to low-hundreds of KB)
with a lightweight GC, versus standard Go's GOOS=js GOARCH=wasm output (typically 1.5-2.5 MB plus the wasm_exec.js runtime shim). For a browser editor loaded on every session, size matters.
- TinyGo subset risks. TinyGo does not implement all of the standard library
or reflection. The known risk areas for this core: - encoding/json and heavy reflect are partial. Mitigation: the boundary is JSON-free by design (3.2) -- flat structs and linear memory, not marshalled JSON -- so this never bites. - Goroutines/channels have a cooperative scheduler; the core is single-threaded synchronous compute, so this is a non-issue. - Per-algorithm compatibility must be proven, not assumed. The migration gate (Phase 1) compiles every package under TinyGo in CI.
- Fallback: standard Go WASM. If a specific package refuses to compile under
TinyGo, the fallback is standard GOARCH=wasm. It is larger but complete. This is an explicit build-target choice made once at build time -- not a runtime fallback -- so it does not violate the no-silent-degradation rule.
- Init timing. WASM instantiation is async and one-time; all hot drawing
paths must remain synchronous after init. The client blocks first-draw on a resolved "core ready" promise (see risk 7.4).
#4.2 Python: c-shared .so via cgo
- The core builds with
go build -buildmode=c-sharedinto a platform-native
shared library plus a C header. Python calls it through ctypes (or cffi).
- cgo is required for
c-shared, so the CI build needs a Go toolchain and a C
toolchain for each target platform.
#4.3 Python packaging: how the .so ships
This is the genuinely hard distribution decision. Three honest options:
| Option | What ships | Pros | Cons |
|---|---|---|---|
| A. Platform wheels | Prebuilt .so per (OS, arch), built in CI | Fast native speed; zero build tools for the user; standard pip install | CI matrix (manylinux, macOS x86_64 + arm64, Windows) to build and maintain; each wheel carries a binary |
| B. Source build | .so compiled at install time | One artifact | Requires Go + C toolchain on every install machine -- unacceptable for pip install pixelweaver |
| C. WASM + wasmtime | One .wasm module run via wasmtime-py | Single platform-independent artifact; no cgo, no matrix | ~2-10x slower than native; adds a wasmtime runtime dependency |
Recommendation: A as primary, C as an explicit alternate wheel. Build native platform wheels in CI for the common targets (this is the fast, zero-friction path most users hit), and publish a separate universal wheel that carries the .wasm module and depends on wasmtime-py for platforms outside the matrix.
Crucially, the selection is made at install time by the platform wheel tag, not at runtime by a try/except. A machine either installs the native wheel or the wasmtime wheel; the code path is fixed for that install. This is explicit mode selection, not silent runtime degradation -- the repo's No silent degradation rule is satisfied because the same install always runs the same path.
The current build backend is uv_build with module-root = server/src (pyproject.toml); native wheels will need a build step that invokes the Go compiler and stages the .so under the package, which likely means a cibuildwheel-style CI job feeding artifacts to the existing publish flow rather than uv_build alone.
#4.4 Later: native Go import
The future asset pipeline is a Go program; it imports pixelweaver.smmh.dev/core directly -- no WASM, no cgo, no serialization. This is the cheapest and fastest consumer and needs nothing beyond the go.work/module path from section 2.1. The png package (2.2) exists mainly for this consumer and for parity tests; the browser uses the platform CanvasRenderingContext2D/CompressionStream and the server uses Pillow, so png in the core is optional and gated on the pipeline's needs.
#5. Determinism charter
The core is bit-reproducible across all three targets. The rules:
- Integer-only where the domain is integer. Geometry, dither thresholds,
role indices, and blend arithmetic stay in integer/fixed math. No float creeps into a path that a caller could observe as a rounded pixel.
- One rounding convention. JavaScript
Math.roundsemantics --
floor(x + 0.5), half toward +Infinity -- is the canonical rounding, applied once in buf/color and reused. This replaces the five independent _js_round/inline reimplementations catalogued in 1.1. Go's math.Round (half away from zero) must not be used where JS parity matters, because it diverges for negative half-values.
- Two fixed PRNGs, specified by algorithm, seeded explicitly.
- Mulberry32 for noise/scatter -- the exact bit sequence in noise-tool.ts / ws_drawing.py (_imul, >>> 15, | 1, ...), returning a float in [0,1). - xorshift32 for palette/variant randomization -- matching seeded-rng.ts / variant_palette.py. Both take a uint32 seed as a parameter (3.3). The MCP random.Random path (divergence #3) is retired.
- Float paths are pinned by fixtures. OKLab (tolerance fill) and HSV (color
effects) unavoidably use floats. Their correctness is locked by golden fixtures, not by hoping two languages round identically. The OKLab constants are already fixed in command_raster.py:295-324 (a documented culori port).
- culori decoupling. The browser's dependency on the
culoriJS library for
color conversion is removed: the OKLab / linear-sRGB matrices move into core/color as plain constants (the same values already ported into Python). After migration neither the browser nor the server depends on culori for these conversions; the core is the single definition.
#6. Migration plan
The contract at every step is the existing golden fixtures. The core is not "done" until it reproduces them byte-for-byte.
#Phase 1 -- Core plus fixtures green
- Implement every package in section 2.2.
- Golden parity:
- Palette/variant output matches server/tests/fixtures/variants/palette_golden.json byte-exact (the 89/89 entries generated by src/lib/variants/golden-fixtures.gen.test.ts). - Effects goldens (today server/tests/png_golden.py / test_png_golden.py) are unified into a shared JSON the Go tests also read. - New geometry/gradient/noise/dither goldens are added and frozen -- crucially, they encode the one canonical algorithm, resolving the seven divergences in 1.2 by fiat (the design must state which side each divergence resolves to; recommended: the client/WS geometry for gradient, Mulberry32 for noise, and a single dither color/matrix convention -- to be ratified before Phase 1 freezes).
- Gate: all Go tests green; every package compiles under both TinyGo and
standard Go WASM in CI; .so builds for the CI platform matrix.
#Phase 2 -- Server adopts the core
- Replace the algorithm bodies in the server with cgo calls into the
.so. - Delete (superseded, per the dead-code policy) the Python algorithm cores:
command_raster.py, ws_drawing.py, role_raster.py, effects.py, layer_composite.py, dimension_ops.py, resize.py, variant_palette.py, iso_seams.py -- about 2,477 LOC across those nine modules today.
- The command-coverage contract test
(server/tests/test_command_coverage_contract.py) must stay green: every command group in command_applier.py still resolves to a core call.
- Gate: existing server test suite green against the core, including the
part-manifest byte-for-byte golden (server/tests/fixtures/parts-project/golden/part-manifest.json).
#Phase 3 -- Client adopts the core (WASM)
- The browser loads the WASM module at startup and routes all pixel ops through
it. Live previews (pointer-move) call the same core, so preview and commit are identical by construction.
- Delete the TS algorithm cores:
plugins/builtin/drawing-utils.ts,
advanced-fill-tool.ts, the gradient/noise/dither/pattern tool compute cores, src/lib/layers/compositor.ts, the effect algorithm bodies under plugins/builtin/effects/, src/lib/iso/seam-checker.ts, the src/lib/variants/* palette maths, and src/lib/util/seeded-rng.ts -- roughly 3,000 LOC once the effects and variant maths are counted with the core tools. The tool plugins stay; only their algorithm bodies become thin WASM calls.
- The Vite 8 + Svelte 5 app has no WASM plugin yet; Phase 3 adds one and wires
the async init. Hot paths stay synchronous after init.
- Gate: client test suite green; the browser-generated
palette_golden.json regeneration still matches the committed fixture.
#Phase 4 -- Game-pipeline consumption (future)
- The Go asset pipeline imports the core natively (4.4). No new algorithm code;
it reuses the frozen goldens as its own regression contract.
- Gate: pipeline output matches the shared goldens.
#7. Risks and open questions
- TinyGo per-algorithm compatibility. The blanket "TinyGo works" claim must
be proven package by package in Phase 1 CI. The OKLab math.Cbrt and any float-heavy effect are the first things to compile-check. Fallback is standard Go WASM (4.1); the open question is whether the size regression is acceptable if even one package forces the whole module onto standard Go.
- Wheel platform matrix cost. Native wheels (4.3 option A) mean maintaining a
manylinux + macOS(x86_64, arm64) + Windows build matrix in CI. Open question: is the wasmtime universal wheel (option C) fast enough to be the default and skip the native matrix entirely? That needs a benchmark before Phase 2 commits to the native path.
- Resolving the seven divergences is a product decision. Phase 1 must ratify
which algorithm wins for each of the seven cases in 1.2 (whose gradient, which dither colors/matrix, which noise index rule). This changes observable output for whichever side loses and should be reviewed, not decided silently in code.
- WASM init timing vs first draw. Instantiation is async; the client must
gate the first paint on a "core ready" signal without a visible startup stutter. Open question: preload the module in a worker versus on the main thread.
- Preview hot-path overhead. A pointer-move preview may call the core dozens
of times per second. Each JS->WASM call copies a region across the linear-memory boundary. Open question / recommended mitigation: batch -- accumulate a stroke's points and call once per animation frame rather than per pointer event, and pass only the dirty region rather than the whole canvas. Buffer copies at 256 KB worst case are cheap, but call frequency, not copy size, is the thing to watch.
- cgo and free-threaded / async Python. The server is Python 3.13; the
ctypes boundary must be checked for GIL behavior and for not leaking or double-freeing buffers across the boundary (3.3). Recommended: the core never frees caller memory, and Python owns every buffer it passes in.
- Module path confirmation.
pixelweaver.smmh.dev/core(2.1) reuses the
existing project domain but is still a name; it needs sign-off before the module is created, and a local pixelweaver/core path is the no-domain fallback.