NeedleScript
A Logo-inspired programming language and playground for generative embroidery. You write turtle-graphics code, NeedleScript turns it into machine-ready stitches — running stitch, satin, bean, blanket and tatami fills — previews them in a virtual hoop, and exports a Tajima .DST file you can sew on a real embroidery machine.
The goal: let creatives make embroidery that can't easily be drawn in traditional embroidery software — noise fields, recursion, parametric curves, randomness with a seed.
// strands drift through a smooth noise field
def strand() [
repeat 90 [
seth (noise2 xcor / 16 ycor / 16) * 720
fd 1.8
if distance(0, 0) > 40 [ return ]
]
]
seed 9
stitchlen 2
repeat 14 [
up setxy(random(64) - 32, random(64) - 32) down
strand()
trim
]
Setup
Requirements: Node.js ≥ 20 and npm.
npm install # install dependencies
npm run dev # start the playground at http://localhost:5173Other scripts:
| Command | What it does |
|---|---|
npm run build |
typecheck + production build into dist/ |
npm run build:lib |
build the publishable needlescript library into dist-lib/ |
npm run preview |
serve the production build locally |
npm test |
run the test suite once (Vitest) |
npm run test:watch |
run tests in watch mode |
npm run test:coverage |
run tests with V8 coverage |
npm run lint |
ESLint over the whole project |
The app is a React 19 + TypeScript + Vite single-page app. The language engine itself (src/lib/) has no DOM dependencies and can be used as a standalone library (see Using the engine as a library).
Project structure
src/
├── lib/ the language engine (DOM-free)
│ ├── engine.ts public library surface (re-exports everything below)
│ ├── tokenizer.ts source → tokens (with character offsets)
│ ├── prescan.ts procedures, globals and locals, collected before parsing
│ ├── parser.ts tokens → AST (modern + classic syntax)
│ ├── interpreter.ts AST → stitch events
│ ├── genmath.ts scalars, vectors, paths & curves (RFC-3, hand-rolled)
│ ├── generators.ts Poisson-disc scatter, Voronoi, hull, Lloyd's relax
│ ├── geometry.ts Clipper2-backed offset & boolean ops (µm integer coords)
│ ├── machine.ts stitch machine: satin, fills, underlay, limits
│ ├── postprocess.ts locks, autotrim, density analysis, stats
│ ├── dst.ts Tajima .DST binary encoder
│ ├── svg-importer.ts SVG → NeedleScript source converter
│ └── __tests__/ Vitest suites (the de-facto behavioural spec)
├── components/ playground UI (editor, stage, playback, reference)
├── data.ts thread palette, hoop constants, bundled examples
└── App.tsx run pipeline, DST export, SVG import, drag & drop
The playground
- Editor — write NeedleScript;
⌘/Ctrl+Enterruns,Tabinserts two spaces. - REPL — type a single command below the console; it's appended to the program and re-run (
↑/↓for history). Great for nudging a design live. - Console — run results, warnings,
printoutput, and errors with line numbers. - Stage — a 100 mm virtual hoop rendered on canvas: thread per colour, underlay drawn thinner and lighter, dashed jump lines, needle penetration points when zoomed, hoop-overflow and density warnings as chips, plus a density heatmap toggle (thread coverage in layers) for spotting bulletproof patches before they pucker.
- Playback — play (~7 s) or scrub the stitch sequence stitch by stitch. While scrubbed, the source line currently sewing is highlighted in the editor and shown next to the counter — the fastest way to answer "which line made this stitch?"
- Examples — bundled programs in the header dropdown (bloom, wreath, wander, star, badge, sampler, waves, tree, fern, flow, shell, patch, meadow, echo, shatter).
- Parameters — annotate any variable with a
// [min:max]comment to expose it as a live slider or toggle in the Parameters panel below the editor. Drag sliders, lock individual parameters, randomize unlocked ones with the shuffle button, and pick named presets from a dropdown — all without re-running the program. See Customizer below. - Download .DST — export the current design as a Tajima stitch file.
- Import SVG — convert an SVG (button or drag & drop) into editable NeedleScript code: filled shapes become
beginfillblocks (subpaths become holes), strokes become outlines, colours map to the nearest thread. Supports<path>(M L H V C S Q T A Z), rect/circle/ellipse/line/polyline/polygon, groups and transforms.
Customizer
Annotate variable declarations with comment brackets to expose them as live controls in the Parameters panel below the editor. The interpreter never sees the annotations — a program with sliders is still an ordinary program.
Parameter controls
| Annotation | Control |
|---|---|
let radius = 15 // [5:30] |
integer slider — both bounds are whole numbers and the range spans > 1 |
let smooth = 0.5 // [0:1] |
smooth slider — at least one float bound, or range ≤ 1; 100 steps |
let n = 4 // [0.5:0.5:8] |
stepped slider — [min:step:max]; any positive step including fractional |
let wave = 1 // [switch] |
toggle — 0 = off, 1 = on |
let mode = 0 // [switch:hypo,epi] |
labelled toggle — label pair shown on each side |
// --- Section --- |
section divider — groups controls with a horizontal rule and title |
All three declaration styles work: let name = value, make "name value, and bare name = value.
Randomize & lock
The shuffle button in the panel header randomises all unlocked parameters at once. Each parameter row has a lock icon (appears on hover; gold when active). Click it to pin that parameter from randomization without affecting manual slider control.
Presets (snapshots)
Bundle curated values into named presets defined as comment lines — one per line, anywhere in the source:
// @preset Classic Flower : bigR=96, rollR=60, pen=40, inside=1, layers=8
// @preset Dense Rosette : bigR=100, rollR=63, pen=50, inside=1, layers=12
// @preset Minimal : layers=1
@snapshot is accepted as an alias for @preset.
Partial presets (fewer keys than the total parameter count) set only the named parameters and leave the rest at their current values — the most common case. Values are always numbers (including 0/1 for switches); they are clamped and snapped to each parameter's configured range on apply.
When at least one @preset line exists, a preset dropdown appears below the panel header. Selecting a preset applies all its values immediately, overriding any locks. Moving any slider or switch after selecting a preset resets the dropdown to — to indicate a custom state.
Copying a snapshot: the copy icon next to the dropdown writes the current full parameter state as a // @preset My Preset : … comment to the clipboard. Before any presets exist, right-click the panel header → Copy as preset achieves the same thing. Paste the result into the source, rename it, and it appears in the dropdown on the next run.
Language guide
NeedleScript has two dialects that mix freely in the same program and compile to exactly the same stitches:
- the modern syntax —
let x = 5,setxy(a, b),def leaf(size) [ … ],return,for i = 1 to 10,else if,%,!,==,true/false,//comments; - the classic Logo syntax —
make "x 5,setxy :a :b,to leaf :size … end,output,for "i 1 10 1,;comments — which remains valid forever.
The intended idiom is a mix: classic prefix words where they shine (fd 10 rt 90, up … down), call parentheses wherever expressions nest. The bundled meadow example is the reference for that style.
Basics
- Units are millimetres. The hoop is 100 mm across; the sewable field is a 47 mm radius around the origin
(0, 0)at the centre. - Heading is in degrees,
0= up/north, clockwise (Logo convention).rt 90faces east. - Words are case-insensitive (
FD 10=fd 10). //,#, and;each start a comment to the end of the line. A lone/is still division — only two adjacent slashes comment.- There are no statement separators — whitespace and newlines are interchangeable.
- The only value type is the number (millimetres, degrees, counts, truth values).
- Truthiness:
0is false, anything else is true. Comparisons return1or0.trueandfalseare literals for1and0. - The
'character is reserved (single-quote strings are reserved for a future version) — it has never been valid, so quoted strings can arrive later without changing any program's meaning.
Negative numbers vs subtraction
Following Logo convention, a minus sign with a space before it and none after it is a negative literal, not subtraction:
setxy -6 -21 ; two arguments: the point (-6, -21)
fd 10 - 5 ; one argument: fd 5 (subtraction)
fd 10 -5 ; error — "-5" is a second value, but fd takes one argument
Inside call parentheses the ambiguity disappears: setxy(-6, -21) and fd(10 - 5) mean exactly what they say, with any spacing.
Movement
| Command | Aliases | Effect |
|---|---|---|
fd n |
forward |
sew forward n mm (long moves auto-split at stitchlen) |
bk n |
back, backward |
sew backward n mm |
rt deg / lt deg |
right / left |
turn right / left |
arc deg radius |
sew along a circle of radius, turning deg in total — positive curves right, negative curves left. Works with every stitch mode (satin arcs!) | |
up / down |
penup/pu, pendown/pd |
needle up = travel as a jump · needle down = sew |
setxy x y |
move to an absolute position | |
setx x / sety y |
move one axis at a time | |
seth deg |
setheading |
set the heading absolutely |
home |
return to (0, 0), heading 0 |
|
push / pop |
save the needle state (position, heading, pen) on a stack · jump back to it without sewing. Perfect for branching structures — no more sewing back out of every branch. Max 500 saved states; pop on an empty stack warns and is ignored |
|
cs |
clearscreen, clear |
accepted for Logo familiarity; does nothing |
Transforms
Like OpenSCAD, NeedleScript has a current transformation matrix (CTM) stack: a transform command takes its arguments then a block, applies a coordinate transform to whatever that block draws, and restores the previous frame at the end. It reads exactly like repeat n [ … ] — native Logo, not a bolted-on DSL — and nests inside-out:
// draw a leaf once; stamp it in four places, each rotated and scaled
def leaf() [
satin 1.6
repeat 2 [ repeat 18 [ fd 0.9 rt 5 ] rt 90 ]
satin 0
]
repeat 4 [
rotate repcount * 90 [
translate 20 0 [
scale 0.8 [ leaf() ]
]
]
]
Both spellings work, exactly like every other command:
translate 20 0 [ leaf() ] // classic prefix
translate(20, 0) [ leaf() ] // glued paren — same thing
| Command | Effect |
|---|---|
translate dx dy [ … ] |
shift the block by (dx, dy) mm |
rotate deg [ … ] |
rotate the block deg clockwise about the current origin (0 = north, like seth/rt) |
rotateabout deg cx cy [ … ] |
rotate about an explicit pivot (cx, cy) |
scale s [ … ] |
uniform scale |
scalexy sx sy [ … ] |
independent axis scale |
mirror deg [ … ] |
reflect across a line through the origin at heading deg (mirror 0 flips left/right, mirror 90 flips top/bottom) |
skew ax ay [ … ] |
shear by ax / ay degrees |
transform a b c d e f [ … ] |
raw 2×3 affine escape hatch: (x, y) → (a·x + c·y + e, b·x + d·y + f) |
These are Core built-ins — like movement and stitching, they can't be redefined (so scale, rotate, translate, transform, … are off-limits as variable names; the did-you-mean machinery flags the clash loudly).
The turtle lives in untransformed local space. Inside a transform block, xcor/ycor/distance/pos() all report pre-transform coordinates — the turtle walks normally and only the emitted stitches are mapped. A leaf doesn't know it's been scaled. This keeps reasoning local (a distance(0,0) > 44 guard behaves the same no matter what transform wraps it) and keeps randomness stable (wrapping a motif in a transform draws the same random/scatter values, so nothing downstream reshuffles). setxy is in local space too — "absolute within this block's frame", which is exactly what you want when stamping the same motif in different places. The history queries (coverat and friends) take local points too and map them through the transform, so they read the right patch of fabric in any frame.
Stitches stay physical. The transform maps the turtle path; stitch-length splitting, satin width and the whole physics layer are then evaluated in hoop space, after the transform:
scale 3 [ fd 30 ]sews nine 2.5 mm stitches over 90 mm — not three 7.5 mm stitches stretched out. The path is transformed, then stitched.- Satin width is transformed perpendicular to the local travel direction, per segment — so under
scalexy 2 1a column running north widens but one running east doesn't (direction-dependent, and real). - The 8 mm snag warning and
shortstitchcurvature checks run on the post-transform geometry, since that's what actually sews. pullcompis a fabric constant in real millimetres and is applied after the transform — it is never scaled.
So "what you previewed is exactly what the machine sews" holds under transforms, where a naive coordinate multiply would quietly break it.
Transformed paths — xlate / xrotate / xscale / xmirror
The block form has pure-function counterparts that transform point lists (call-syntax only, returning new lists), so transforms compose with scatter/voronoi/offsetpath data, not just with imperative drawing. translate dx dy [ block ] is exactly "run block, but every emitted point passes through xlate" — the two forms share one matrix library and produce identical stitches.
seed 4
let cell = first(voronoi(scatter(9)))
let motif = resample(cell, 2.2)
repeat 6 [
sewpath(xrotate(motif, repcount * 60)) // six rotated copies of one cell
trim
]
(See the bundled transforms example.)
Effects
Transforms are the linear case of a more general idea: instead of a fixed affine matrix mapping points on the way out, an effect is an arbitrary per-point function applied to a block's emitted geometry. Effects live on the same block-scoped stack as transforms and nest freely with them — they're "run this block, but pass every emitted point through a function," differing only in which function and where in the pipeline it runs.
scale 1.5 [
warp @ripple [
humanize 0.25 [
leaf()
]
]
]
Reading inside-out: draw the leaf, humanize its penetrations, ripple the result, scale that. Each layer is a point→point map and they compose in sequence.
| Effect | Linear? | Frame | Stage | Seeded? |
|---|---|---|---|---|
transforms (translate/rotate/scale/…) |
yes | local, composing | before split | no |
warp @fn |
no | local, post-transform | before split | only if the reporter is |
humanize amount |
no | hoop, post-transform | after split | yes (forks) |
snaptogrid … |
no | fixed hoop lattice | after split | no |
The "stage" column is the one subtlety a naive implementation gets wrong. warp is a geometric deformation — it maps the emitted path vertices before stitch-length splitting, so the deformed curve is still split into clean physical stitches (exactly like a transform). humanize and snaptogrid perturb individual penetrations, so they run after splitting — jitter or snap the final needle points, not the continuous path (warp-then-split would resample the irregularity away; snap-then-split would interpolate stitches back off the grid).
warp @fn — the shader
warp takes a procedure reference and applies it to every point the block emits. The reporter receives a point [x, y] in hoop space and returns a new one — a fisheye, a ripple, a twist, a domain-warp are all just reporters:
def push_out(p) [
let d = vlen(p)
return vscale(vnorm(p), d + 2 * snoise2(p[0] / 14, p[1] / 14))
]
warp @push_out [
repeat 6 [ fd 30 rt 60 ]
]
The @name syntax is a procedure reference — the one new value kind effects introduce. It yields a reference to a reporter, callable by the effect machinery, and is consumed only by warp (and warppath); using it anywhere else is a loud type error. A reporter must take exactly one argument (the point) and output/return a point, or you get an error naming the problem.
Because warp hands control to arbitrary user code, a shader can push points off the hoop, fold the path over itself, or stretch segments into long loose stitches. The posture is the usual one — don't forbid, warn: hoop-overflow, density and long-stitch checks all run on the post-warp geometry (warp sits before the physics layer), so a misbehaving shader surfaces as chips and console warnings, not a quietly ruined garment. warp itself draws nothing from the seeded stream — it's seeded only if the reporter calls random/snoise2.
humanize amount — hand-stitched imperfection
humanize 0.3 [
repeat 4 [ fd 20 rt 90 ]
]
humanize offsets each penetration by a small amount (the argument, in mm, clamped 0–2) so the work reads as hand-embroidered rather than machine-perfect. The details matter for embroidery specifically:
- Coherent, not white, noise. A human's error is correlated — the hand drifts, so consecutive stitches err in similar directions.
humanizesamples seededsnoise2slowly at each point's own coordinates, giving smooth wander. Naive per-pointrandomwould read as damage, not handwork. - Seeded, like everything else. It draws from the seeded field, so the same seed reproduces the same imperfections. Re-running doesn't reshuffle the human-ness.
- Forks, like
scatter/shuffle. It draws exactly one value from the main stream (to seed its coherent field), so dropping ahumanizeblock into a design shifts everything downstream by exactly one draw — not by however many stitches were inside. Editing the contents of ahumanizeblock never reshuffles the rest of the piece.
snaptogrid … — grid quantizing
snaptogrid 2 [
repeat 4 [ fd 20 rt 90 ]
]
snaptogrid quantizes each penetration to a lattice — a cross-stitch / pixel-grid aesthetic. Its one defining property is frame-invariance: a grid is a property of the fabric, not the motif, so the lattice is evaluated in fixed hoop space, outside any enclosing transform. Stamp the same motif at four places with translate and all four snap to one shared lattice — their stitches register across the whole piece. scale 2 [ snaptogrid 1 [ … ] ] does not stretch the grid to 2 mm; the lattice stays 1 mm and the scaled motif lands on different nodes. The grid origin and rotation are hoop-space values, never mapped by the surrounding transform.
It overloads by arity (like scatter/range/slice), with the full form as the escape hatch:
| Form | Grid |
|---|---|
snaptogrid cell [ … ] |
square lattice, pitch cell, origin (0, 0), axis-aligned |
snaptogrid cellx celly [ … ] |
rectangular lattice |
snaptogrid cellx celly ox oy [ … ] |
…with an origin offset |
snaptogrid cellx celly ox oy ang [ … ] |
…rotated ang (turtle degrees) — isometric / diagonal grids |
snaptogrid is pure and drawless — rounding consumes no RNG, so its determinism doesn't even depend on the seed. Two cautions: snapping can push adjacent penetrations onto the same node (a zero-length stitch) — these merge with the existing tiny-stitch warning, so pick a cell size compatible with your stitchlen. And after-split effects deliberately skip satin columns (quantizing or jittering a precise satin rail wrecks the column) — the column sews unaffected, with a one-time warning.
Effect paths — warppath / humanizepath / snappath
Like transforms, each effect has a pure-function companion that maps a point list, so effects compose with scatter/voronoi/offsetpath data, not just imperative drawing. The block form is exactly "run the block, mapping emitted points through the same function," and the two are pinned identical:
| Function | Returns |
|---|---|
warppath(path, @fn) |
new path, every point mapped through the reporter |
humanizepath(path, amount) |
new path, seeded coherent jitter (forks, like the block) |
snappath(path, cell …) |
new path, every point snapped to the fixed lattice (same arity overloads) |
let coast = humanizepath(resample(cell, 2.0), 0.3)
sewpath(coast)
let pts = snappath(scatter(8), 2) // Poisson points, quantized to a 2 mm grid
for p in pts [ up setpos(p) down arc 360 0.6 trim ]
@name references and the effect names (warp, humanize, snaptogrid) are Core built-ins — they can't be redefined.
(See the bundled warp, humanize and snaptogrid examples.)
Thread & stitch quality
| Command | Effect |
|---|---|
stitchlen mm (stitchlength) |
running-stitch length, clamped to 0.4–12 mm (default 2.5) |
satin mm |
zigzag column of this width; penetration spacing set by density. satin 0 returns to running stitch. Widths over ~8 mm tend to snag (you'll get a warning) |
density mm |
satin penetration spacing, 0.25–5 mm (default 0.4) |
bean n |
bold line: each stitch sewn n times (forced odd, max 9). bean 1 off |
estitch mm |
blanket stitch: prongs of this length on the left of travel, spaced by stitchlen. estitch 0 off |
color n |
switch to thread n (emits a DST colour-change stop) |
stop |
shorthand for "next colour" |
trim |
cut the thread here (long travels also get one automatically — see autotrim) |
lock mm |
tie-in/tie-off securing: 4 micro back-stitches are sewn automatically wherever the thread starts or ends (design start/end, colour changes, trims, jumps ≥ 4 mm) so runs can't unravel. Size 0.3–1.5 mm (default 0.7); lock 0 disables |
Fills
fillangle 30
up setxy -26 -15 down
beginfill
repeat 6 [ fd 30 rt 60 ]
endfill
| Command | Effect |
|---|---|
beginfill … endfill |
moves between them trace a boundary instead of sewing; endfill sews a tatami fill of the enclosed area. A pen-up move (up … down) starts a new ring — inner rings become holes (even-odd rule) |
fillangle deg |
direction of the fill rows (default 0) |
fillspacing mm |
row spacing, 0.25–5 mm (default 0.4) |
filllen mm |
fill stitch length, 1–7 mm. By default the fill follows stitchlen; set filllen to override, filllen 0 to follow again. Rows are brick-offset so penetrations don't line up |
Professional embroidery & fabric physics
Geometry alone doesn't survive the sewing machine: thread tension pulls fabric inward, stitches sink into the material, tight curves crowd the needle, and layered stitching turns into a bulletproof patch. These commands compensate for the physics. They are opt-in — without them, programs sew exactly as written.
The quickest route is a fabric preset:
fabric "knit ; pull comp 0.5, auto underlay, lighter satin, density limit 1.2
| Fabric | Pull comp | Coverage limit | Notes |
|---|---|---|---|
"woven |
0.2 mm | 3.5 layers | the baseline |
"knit |
0.5 mm | 3.0 layers | satin density floored at 0.45 mm |
"stretch |
0.6 mm | 2.8 layers | satin density floored at 0.5 mm |
"denim / "canvas |
0.15 mm | 4.0 layers | stable, tolerates dense stitching |
"fleece |
0.3 mm | 2.6 layers | doubled underlay, suggests a topping |
Explicit commands after fabric override the preset.
Pull compensation — pullcomp mm
Thread tension shrinks stitching along the stitch axis: a 4 mm satin column sews ~3.6 mm wide. pullcomp (0–1.5 mm) widens satin columns and extends every fill row at both ends, so shapes sew out at their digitized size and borders actually meet their fills.
Underlay — underlay, fillunderlay
Underlay is stabilising stitching sewn automatically underneath the visible layer — the single biggest difference between hobby and professional digitizing. It anchors the fabric to the backing, stops shifting, and lifts the topping out of the material. Underlay is sewn in correct machine order (before the topping), shown thinner in the preview, and identical to normal stitches in exports.
| Command | Modes |
|---|---|
underlay "auto |
for satin columns: "center (spine, out and back), "edge (runs offset ±30% width), "zigzag (open zigzag at 60% width + return run), "off. "auto picks by width: < 1.5 mm none, < 4 mm center, wider zigzag |
fillunderlay "auto |
for fills: "tatami (sparse cross-grain pass at fillangle + 90, inset 0.6 mm), "edge (run tracing the boundary inset 0.5 mm), "off. "auto = tatami, plus the edge run on areas over 100 mm² |
A satin column is buffered while you draw it and sewn — underlay first, then the zigzag — when it ends (pen up, mode change, colour change, trim, fill, or end of program). The turtle's position and heading are unaffected.
Short stitches on curves — shortstitch 0/1
On a tight satin curve the inner edge receives the same number of penetrations as the outer edge in a fraction of the space — they bunch up, break thread, and chew the fabric. NeedleScript detects local curvature (chord length ÷ turn angle) and pulls alternate inner-edge stitches in to 60% width. On by default; shortstitch 0 disables. If a column is wider than the curve's radius you get a warning — that geometry can't sew cleanly at any setting.
Local density — maxdensity n + heatmap
The physical quantity that matters is thread coverage: millimetres of thread per mm² of fabric, expressed in layers — one layer is a clean satin column or tatami fill. Past ~2.5–3.5 layers (fabric-dependent) embroidery stops being fabric: needles deflect, thread breaks, the patch puckers. Every run computes a 1 mm coverage grid (deliberate tie-off micro stitches are excluded so thread ends don't read as false hotspots). Hotspots above the limit produce warnings with coordinates and the source lines that caused them, and repeated penetrations in the same hole (≥ 5 within 0.15 mm — fabric-cutting territory) are flagged separately. The stage has a heatmap toggle (orange from ~1.2 layers, red from 3); the stats row shows the peak. maxdensity n tunes the threshold (default 3.5), maxdensity 0 silences it. Some constructions legitimately run hot — a satin border over a fill edge measures ~4 layers — and the right move is to raise the limit knowingly, as the bundled patch example does.
Stitch history — closed-loop generation
That same coverage grid can be read back mid-program, so a design can respond to what's already been sewn — adaptive density, stippling toward a target, avoidance, growth that respects what's there. Five pure reporters (glued-call only, shadowable):
| Call | Returns |
|---|---|
coverat(p) · coverat(p, r) |
coverage at p in layers (the heatmap unit) — point, or averaged over radius r mm |
countat(p) |
penetration count in the 1 mm cell at p |
nearestsewn(p) |
the closest prior penetration as [x, y], or [] if none yet |
sewnwithin(p, r) |
a list of prior penetrations within r mm of p |
stitchedpoints() |
a deep-copied snapshot of every penetration so far, as a path |
seed 7
repeat 4000 [ // a stipple that self-levels
let p = [random(80) - 40, random(80) - 40]
if vlen(p) < 46 and coverat(p) < 1.5 [ // only sew where it isn't full yet
up setpos(p) down arc 360 0.5 trim
]
]
The contract that keeps closed-loop generation deterministic: the reporters draw nothing from the random stream and emit nothing — they're reads, so branching on them is still a function of (seed, source) and "same seed → same design" holds. They see committed penetrations in sewing order (a buffered satin column isn't visible until it flushes on pen-up / trim / mode change; tie-off locks are excluded, so the numbers match the heatmap exactly). coverat/countat are O(1) cell lookups and nearestsewn/sewnwithin are grid-bucketed O(local), so proximity logic never scans the whole history. Query points are local-frame and mapped through the current transform (so coverat(pos()) works in any frame); returned points are hoop-space fabric facts. A loop that runs until a coverage condition can run forever if the target is unreachable — give it a hard cap (repeat N [ … if done [ break ] ], not while); the op-limit error hints when a feedback loop may not be terminating. The bundled stipple example shows the pattern, and a warp reporter that reads coverat becomes a reactive shader.
Automatic trims — autotrim mm
Travels of 7 mm or more (configurable 3–30, autotrim 0 off) automatically get a trim before the jump, so connector threads don't dangle and snag on the garment. Trims are never inserted when nothing has been sewn since the last cut.
Control flow
| Syntax | Meaning |
|---|---|
repeat n [ … ] |
loop n times; repcount is the 1-based counter of the innermost repeat |
while cond [ … ] |
loop while the condition is true (non-zero) |
for i = from to to [ … ] |
counted loop, inclusive of to; the step defaults to 1 |
for i = from to to step s [ … ] |
…with an explicit (possibly negative) step: for i = 10 to 1 step -2 [ … ] |
for "i from to step [ … ] |
the classic spelling; the step is required, read the counter with :i |
for x in xs [ … ] |
iterate the elements of a list; the loop variable doesn't leak |
break |
end the innermost enclosing loop immediately |
continue |
skip to the next iteration of the innermost enclosing loop |
if cond [ … ] |
run the block if the condition is non-zero |
if cond [ … ] else if cond2 [ … ] else [ … ] |
chains of alternatives, any depth |
The loop counter is read as a plain name (i) or classic style (:i) and doesn't leak after the loop. to and step end the bound expressions naturally, so for i = 1 to n * 2 [ … ] needs no parentheses. (step is a reserved word — pick another name for variables and procedures.)
for ring = 1 to 6 [
arc 360 ring * 4
]
Leaving loops early — break and continue
break and continue work in all loop forms — repeat, while, both for spellings, and for … in — and through any nesting of if/else blocks. continue skips the rest of the current iteration: repcount advances normally, a while re-evaluates its condition, a for applies the step (negative steps included), a for … in moves to the next element. With true as a literal, while true [ … break ] is the idiomatic search loop:
repeat 30 [ // walk until we leave the cell
seth(snoise2(xcor / 11, ycor / 11) * 360)
fd 1.5
if !inpath(pos(), cell) [ break ]
]
The four control-transfer words, from smallest to largest jump:
| Word | Leaves | Notes |
|---|---|---|
continue |
current iteration | innermost loop only |
break |
innermost loop | outer loops unaffected; the outer repcount becomes visible again |
exit / bare return |
current procedure | unwinds any loops inside it |
output e / return e |
current procedure, with a value | likewise |
break and continue are lexical, checked at parse time: they must be written inside a loop body in the same procedure. A break inside a helper procedure can't end a loop in its caller — the parse error says so and points you at return/exit, which leave the procedure instead. (They're reserved words now — a program defining to break … end gets a loud error with a rename hint.) Loop control is invisible to the stitch machine: a buffered satin column survives a break and flushes on the next pen or mode change as always.
Procedures
def leaf(size) [
repeat 2 [
repeat 30 [ fd size rt 3 ]
rt 90
]
]
repeat 8 [ leaf(1.2) rt 45 ]
def name(a, b) [ … ]defines a procedure; the body is a bracket block like every other block. Parameters are local and read as plain names (size) or classic style (:size).- The classic form
to name :a :b … endis equivalent and remains valid. - Calls work both ways:
leaf(1.2)orleaf 1.2— see Call syntax for the one rule that separates them. - Procedures may be called before they're defined in the source (signatures are pre-scanned).
- Recursion works; depth is limited to 200 calls.
return(or classicexit) leaves the current procedure immediately.- Names can't collide: built-in words can't be shadowed (
def while() [ … ]is a parse error), a procedure and a variable can't share a name, and parameters can't reuse a procedure or built-in name. Loud and early beats clever.
Reporters — procedures that return values
return expr (classic: output expr, alias op) returns a value from a procedure, which can then be used anywhere an expression is expected:
def spiral_r(i) [
return 2 * pow(1.1, i)
]
def clamp(v, lo, hi) [
return min(hi, max(lo, v))
]
for i = 1 to 40 [ fd spiral_r(i) rt 25 ]
- A procedure used as a value must reach
return/output, or you get a friendly error. returnandoutput/exitare only valid inside a procedure.- Reporters can recurse:
def fact(n) [ if n < 2 [ return 1 ] return n * fact(n - 1) ].
Variables
| Syntax | Meaning |
|---|---|
let x = expr |
declare a variable: a global at the top level, a local inside a procedure |
x = expr |
assign: updates a local if one is in scope, otherwise writes a global |
x += e · x -= e · x *= e · x /= e |
compound assignment: x += 2 is x = x + 2 |
make "x expr |
the classic spelling of assignment — same store, same rules |
local "x expr |
the classic spelling of an in-procedure let |
Variables are read as plain names (fd x) or classic style (fd :x) — both resolve identically.
Scoping rules:
- Assignment (
x = …/make) updates an existing local (a parameter,let, orlocal) if one with that name is in scope; otherwise it writes a global. One mental model for both spellings. letof a name that's already declared in the same scope, or that collides with a built-in or procedure, is a parse error with a did-you-mean.- Plain
x = 1without a priorletis allowed (Logomakesemantics — friendly for one-liners). localat the top level is an error — usemakeor a top-levelletthere.- Reading a declared-but-never-assigned variable (e.g. only assigned inside an
ifthat didn't run) is a runtime error: "never assigned on this path".
def wobble(len) [
let pace = len / 10
pace *= 2 // updates the local, not a global
repeat 10 [ fd pace rt random(10) - 5 ]
]
Expressions
Operator precedence, loosest to tightest:
orand- comparisons
< > = == <= >= !=(return1/0; equality compares with a 1e-9 tolerance —=and==are the same operator) + -* / %- unary
-, prefix functions (not/!,sin, …) - numbers,
true/false, variables,( … ), calls
and / or short-circuit, so guards like i > 0 and 10 / i > 2 are safe. not (spelled ! if you prefer) is a prefix function and binds tightly — write !(a = 1) when negating a comparison. % is the same operation as mod: floor modulo, the result takes the sign of the divisor — -7 % 3 is 2 here, not -1 as in C or JavaScript.
Call syntax with parentheses
Any function, command or procedure can be called with parentheses and commas — when the ( is glued to the name:
fd(10) // call: fd with one argument
fd (10) // classic: fd, argument is the grouped expression (10)
setxy(random(20), random 20) // styles mix freely inside argument slots
xcor() // zero-argument calls are fine
min(3, 4) · min 3 4 // identical
That one space is the entire rule: glued ( = argument list, spaced ( = Logo grouping, so every existing program means what it always meant. Argument counts are checked against the callee's signature, and a trailing comma is allowed.
Classic prefix arguments are written without commas or parentheses: setxy random 20 random 20. Use parentheses whenever you want to be explicit about grouping:
seth ( noise2 xcor / 16 ycor / 16 ) * 720
Why parens pay off: classic multi-argument calls parse each argument as a full expression, so a trailing operator is absorbed into the last argument —
distance 0 0 < 47meansdistance 0 (0 < 47). Single-argument functions bind tightly instead (random 64 - 32is(random 64) - 32), so you have to know each function's arity and which rule it follows. Call parens give every callable one rule:bloom clamp 2.5 + random 3 2.5 5 :kind ; classic — correct, but you must count arities to read it bloom(clamp(2.5 + random(3), 2.5, 5), kind) // modern — the parens are the structure
Functions
| Function | Returns |
|---|---|
random n |
a number in 0…n — reproducible, driven by the seed |
noise x · noise2 x y |
smooth seeded value noise in 0…1. Sample it slowly (divide coordinates by 10–20) for organic drift; same seed → same field |
sin deg · cos deg |
trigonometry in degrees |
sqrt n · abs n · round n · floor n · ceil n |
the usual suspects (sqrt of a negative is an error) |
min a b · max a b · pow a b |
minimum, maximum, power (a non-finite pow result is an error) |
mod a b |
floor modulo — always returns a value with the sign of b. The % operator is the same operation |
atan x y |
the heading of the vector (x, y): 0 = north, clockwise — so atan 1 0 is 90 |
towards x y |
heading from the needle to the point (x, y) — seth towards 0 0 aims home |
distance x y |
distance from the needle to the point (x, y) |
Classic multi-argument calls parse each argument as a full expression, so a trailing operator is absorbed into the last argument:
distance 0 0 < 47meansdistance 0 (0 < 47). Parenthesise when you mean the comparison —(distance 0 0) < 47— or use call parens, where it can't happen:distance(0, 0) < 47.
Reporters (no arguments)
| Word | Value |
|---|---|
xcor · ycor |
the needle's position |
heading |
the needle's heading in degrees |
repcount |
1-based counter of the innermost repeat |
Lists
A second value type alongside numbers: ordered, nestable, ragged lists of numbers (and other lists). A point is [x, y], a path is a list of points, a palette is a list of thread numbers. Lists live entirely in the program — they never reach the stitch stream.
let palette = [2, 3, 5, 7] // literal; nesting and trailing commas allowed
let path = [] // empty list
print palette[0] // 2 — indexing is 0-based
print palette[-1] // 7 — negatives count from the end
palette[1] = 4 // index assignment (+= -= *= /= work too)
let [x, y] = pos() // destructuring (fixed arity, flat)
for p in path [ // iterate elements; length is captured at
setpos(p) // loop entry, elements are read live
]
Reference semantics, like Python/JS. Assignment shares the list; mutate through any alias and every alias sees it. copy(xs) makes an independent deep copy:
let a = [1, 2, 3]
let b = a // same list
b[0] = 9
print a // [9, 2, 3]
let c = copy(a) // deep copy — c is independent
The [ rule. Brackets already delimit blocks; position decides the meaning. After a header with a space (repeat 4 [ … ]) or glued to a number or :var (repeat 4[…], repeat :n[…]) a [ is a block — classic programs are untouched. At the start of an expression it's a list literal. Glued to a bare name, ) or ] it's an index: xs[0], pos()[1], grid[i][j]. The one sharp edge: repeat n[ fd 10 ] with a modern bare name reads as indexing — the error tells you to add the space.
Loud over convenient. A non-integer index, an out-of-range index (either direction), a list in a condition (if xs [ … ] → use len(xs) > 0), a list in arithmetic ([1, 2] + 1), or a list fed to a scalar command (fd [1, 2]) are all errors that name the operation and the line — a wrong index in embroidery is a wrong stitch. Equality is the exception: =/== compare lists deeply (with the usual 1e-9 tolerance) and a number never equals a list (that's 0, not an error).
List functions
All list functions are call-syntax only: len(xs), never len xs (this is what lets range and slice take optional arguments).
| Function | Returns / effect |
|---|---|
range(n) · range(a, b) · range(a, b, s) |
new list [0…n-1] / [a…b-1] / stepped — 0-based, end-exclusive, like Python |
filled(n, v) |
new list of n deep copies of v |
len(xs) · islist(v) |
element count · 1/0 |
first(xs) · last(xs) |
xs[0] · xs[-1] (the Logo heritage names) |
append(xs, v) · prepend(xs, v) |
mutates: adds v at the end / front (statement) |
insertat(xs, i, v) |
mutates: inserts at index i (0…len allowed) |
removeat(xs, i) |
mutates: removes index i and returns the removed value |
concat(a, b) |
new list (shallow — elements are shared references) |
slice(xs, a) · slice(xs, a, b) |
new list, Python semantics incl. negative bounds, clamped |
reverse(xs) · sort(xs) |
new lists (pure on purpose — they compose in expressions); sort is numbers-only, ascending, stable |
copy(xs) |
deep copy |
indexof(xs, v) · contains(xs, v) |
first index of v (deep, tolerant compare) or −1 · 1/0 |
sum(xs) · mean(xs) · minof(xs) · maxof(xs) |
aggregates, numbers only; sum([]) is 0, the rest error on an empty list |
pick(xs) |
random element — seeded, exactly one RNG draw |
shuffle(xs) |
new shuffled list — seeded, exactly one main-stream draw (it forks a child RNG, see Randomness & determinism): same seed, same order, forever |
pos() |
the needle's position as [xcor, ycor] |
setpos(p) |
command: like setxy p[0] p[1] — makes record/replay symmetric: append(path, pos()) … setpos(p) |
push/popare taken. They save and restore the turtle state (see Movement) and keep that meaning. To grow a list, useappend(xs, v)— thepusharity error will remind you.
print formats lists as [1, 2, 3] (nested as [[0, 1], [2, 3]], capped at 64 elements with … +n more). List builtin names are resolved only at call position, so classic programs that use names like :len for parameters keep working, and a def of the same name shadows the builtin.
Generative math
Lists made the data representable; the generative-math builtins make it generatable. Three conventions, stated once and used everywhere: a point is [x, y], a path is a list of points, a region is a closed path (the closing segment is implicit). Every function below speaks that vocabulary, so outputs of one feed inputs of the next — scatter → voronoi → offsetpath → resample → sewpath compose without glue code. All of them are call-syntax only, like the list functions.
seed 4
let tiles = voronoi(scatter(9)) // Poisson-disc points → Voronoi cells
for cell in tiles [
for ring in offsetpath(cell, -0.9) [ // inset each cell (may vanish — loop skips)
sewpath(resample(ring, 2.2)) // even 2.2 mm stitches along the ring
]
trim
]
(See the bundled shatter example for the full version with flow-field hatching.)
Scalars
| Function | Returns |
|---|---|
lerp(a, b, t) |
a + (b − a)·t, t unclamped |
remap(v, inlo, inhi, outlo, outhi) |
linear remap, unclamped |
clamp(v, lo, hi) |
min(hi, max(lo, v)) |
smoothstep(e0, e1, x) |
Hermite ease 0…1 |
gauss(mu, sigma) |
seeded normal (Box-Muller, exactly 2 draws) |
Noise
| Function | Returns |
|---|---|
snoise2(x, y) · snoise3(x, y, z) |
seeded simplex noise in −1…1 (industry convention; legacy noise/noise2 keep 0…1). Same seed, same field, forever. The z axis is for variation, not space: snoise3(x/14, y/14, motif * 50) gives each motif its own field |
fbm2(x, y, octaves) |
fractal sum of snoise2: lacunarity 2.0, gain 0.5, octaves 1–8 (clamped with a warning), normalised to ≈ −1…1 |
Vectors (points)
One angle rule: everything heading-like uses turtle degrees (0 = north, clockwise positive), matching seth, atan, towards.
| Function | Returns |
|---|---|
vadd(a, b) · vsub(a, b) |
new point |
vscale(a, s) · vlerp(a, b, t) |
new point |
vdot(a, b) · vlen(a) · vdist(a, b) |
number |
vnorm(a) |
unit vector; the zero vector is an error, not [0, 0] — a silent default heading is a stealth bug |
vrot(a, deg) |
rotated clockwise for positive deg (matches rt) |
vheading(a) |
turtle heading of the vector (≡ atan a[0] a[1]) |
vfromheading(deg, len) |
the inverse — vfromheading(heading, 1) is the needle's direction |
There is no operator broadcasting: [1, 2] + [3, 4] stays a loud error, now with hints (use vadd(a, b) for element-wise, concat(a, b) to join). The reason is audience-specific: in Python that expression is concatenation, and silently giving it NumPy semantics is the kind of bug that sews before it's noticed.
Paths & curves
| Function | Returns |
|---|---|
pathlen(path) |
total polyline length |
resample(path, mm) |
new path whose segments are each exactly mm long (the last may be shorter), first & last preserved — the bridge between math-space curves and physical stitch spacing |
chaikin(path, n) |
corner-cut smoothing, n iterations 1–6 |
catmull(points, mm) |
Catmull-Rom spline through the control points, resampled |
bezier(p0, c0, c1, p1, mm) |
cubic Bézier, resampled |
centroid(path) · bbox(path) |
point · [minx, miny, maxx, maxy] |
xlate(path, dx, dy) |
new path, translated — the functional twin of translate |
xrotate(path, deg) · xrotate(path, deg, cx, cy) |
new path, rotated clockwise (optional pivot) — twin of rotate/rotateabout |
xscale(path, s) · xscale(path, sx, sy) |
new path, scaled uniformly or per-axis — twin of scale/scalexy |
xmirror(path, deg) |
new path, reflected across heading deg — twin of mirror |
sewpath(path) |
command: exactly for p in path [ setpos(p) ] — pen state, stitch mode, satin and auto-split all apply as if hand-walked |
Generators (seeded)
| Function | Returns |
|---|---|
scatter(mindist) · scatter(mindist, region) |
Poisson-disc (Bridson) points — over the sewable 47 mm field, or inside the region polygon. Capped at 20,000 points |
voronoi(points) · voronoi(points, region) |
one cell (a region) per input point, in input order, clipped to the sewable disc or the given region |
triangulate(points) |
Delaunay triangles: a list of 3-point regions |
hull(points) |
convex hull as a region, counter-clockwise |
relax(points, n) |
n rounds of Lloyd's relaxation (each point moves to its Voronoi cell's centroid) — evens out spacing for stippling |
Geometry ops
Backed by Clipper2 on ×1000 integer coordinates (µm precision) — results are platform-stable.
| Function | Returns |
|---|---|
offsetpath(region, mm) |
list of regions — positive inflates, negative shrinks. Shrinking may split a shape into several or into none (an empty list, not an error — loops over it naturally do nothing). Round joins |
clippaths(a, b, "op) |
boolean of two regions; op ∈ "union "intersect "difference "xor; returns a list of regions |
inpath(p, region) |
1/0, even-odd rule (consistent with fills) |
Library names may be shadowed
Built-in words come in two tiers. Core — movement, stitching, control flow, everything that predates the generative-math release — can't be redefined (hard error, unchanged). Library — every list, generative-math and stitch-history function — can: your own def clamp(v, lo, hi) [ … ] wins for the whole program, with a one-time console note (note: "clamp" shadows a built-in library function (since v3) — rename to silence). This is what lets the language keep growing a standard library without breaking existing programs that innocently used the same names.
Randomness & determinism
Every run is deterministic: random, gauss, noise, snoise2/3, pick, shuffle and scatter are all driven by a seed (default 42). Reseed with:
seed 7
The same seed always reproduces the same design — change the seed, change the piece. This matters for embroidery: what you previewed is exactly what the machine sews. The test suite enforces it mechanically: Math.random is stubbed to throw during every engine test, so nondeterminism can't sneak in through a dependency.
Draw accounting follows the fork convention, so editing one part of a design doesn't reshuffle the rest:
- Fixed-cost functions draw from the main stream:
random1 draw,pick1,gauss2. - Variable-cost generators fork:
scatterandshuffledraw exactly one value from the main stream and use it to seed a child RNG for all internal work. (voronoiandrelaxdraw nothing.)
Result: inserting a scatter(6) shifts a later random 10 by exactly one draw — the same as inserting a random. Draw costs are part of each function's contract and are pinned by tests, as are golden output values per seed: same seed + same engine version ⇒ identical output, and an algorithm change that alters output is a major-version event.
Debugging
| Tool | What it does |
|---|---|
print expr |
log a value to the console |
print "label expr |
…with a label: print "radius :r → radius: 1.5 |
mark |
drop a numbered pin on the preview at the needle's position. Pins appear as playback reaches them and are never exported to the machine or counted in stats |
assert cond |
stop with an error (and line number) if the condition is false — great for geometric invariants (assert (distance 0 0) < 47) |
| Playback scrubber | scrub the design stitch by stitch; the source line being sewn is highlighted in the editor and shown in the playback bar |
| Did-you-mean | typos in commands, variables, and procedure names suggest the closest match across every namespace, labelled by kind: Unknown command "stichlen" — did you mean the command "stitchlen"? |
| Warnings | non-fatal issues surface as chips and console lines: clamped values, merged tiny stitches, unclosed fills, hoop overflow, excessive density |
Safety limits
NeedleScript guards both your browser and your machine:
| Limit | Value |
|---|---|
| Max stitches per design | 60,000 |
| Max interpreter operations | 2,000,000 (catches infinite while/recursion; list element reads and writes count too) |
| Max call depth | 200 |
Max repeat / for iterations |
200,000 |
| Max list length | 100,000 elements |
| Max total live list cells | 1,000,000 |
| Max list nesting depth | 16 |
Max scatter output |
20,000 points |
Max voronoi / triangulate / hull / relax input |
10,000 points |
Max offsetpath / clippaths input |
50,000 vertices per call |
| Stitch length | clamped to 0.4–12 mm |
| Sub-0.4 mm moves | merged into neighbours (too short to sew safely), with a warning |
DST export
Download .DST produces a standard Tajima file: 3-byte ternary delta records, moves longer than 12.1 mm split automatically, colour changes as stop records, trims as triple jumps, and a correct 512-byte header (label, stitch/colour counts, extents). Load it onto any machine or into commercial software for final checks.
Using the engine as a library
The engine is published to npm as needlescript — an ESM-only, DOM-free package:
npm install needlescriptimport { run, designStats, toDST } from 'needlescript';
const result = run('repeat 36 [ fd 4 rt 10 ]', { seed: 7 });
// result.events — stitch/jump/color/trim/mark stream ({ t, x, y, c, line, u })
// result.warnings — non-fatal issues (clamps, density hotspots, hoop overflow…)
// result.printed — output of print
// result.locks — number of tie-in/tie-off locks added
// result.density — local density grid, peak, and hotspot list
const stats = designStats(result.events); // counts, bounding box, max stitch…
const bytes = toDST(result.events, 'rose'); // Uint8Array, ready to saveAlso exported: tokenize, parse, toPES, toEXP, toSVG, applyLocks, applyAutoTrim, densityMap, makeRNG, makeNoise, fork, gauss, suggest, the command tables (BUILTIN_ARITY, QWORD_BUILTINS, FABRICS, FUNC_ARITY, ALIASES, RESERVED, ZERO_FUNCS, LIST_FUNCS, LIST_CMDS, GEN_FUNCS, GEN_CMDS, LIBRARY_FUNCS), LIMITS, and NeedlescriptError (which carries the source line in slLine).
The engine's only runtime dependencies are three exactly-pinned libraries that each do something genuinely hard: simplex-noise (seeded noise tables), delaunator (Delaunay triangulation) and clipper2-ts (polygon offsetting & booleans). Everything else — lerp through catmull, even Bridson's Poisson-disc — is hand-rolled, because owning the code is cheaper than auditing a dependency for determinism. None of them touch Math.random (the test suite proves it).
Tests
npm test~3,000 lines of Vitest suites in src/lib/__tests__/ cover the tokenizer, parser, interpreter, language features (loops, reporters, locals, noise, arc, push/pop, debugging commands), the modern syntax (modern-syntax.test.ts asserts every modern form produces event streams identical to its classic twin), the professional layer (underlay, pull compensation, short-stitch, density analysis, autotrim, fabric presets), locks, stats, DST encoding, and the SVG importer. The bundled examples are tested to run and fit the hoop. When in doubt about a behaviour, the tests are the spec.
License
MIT Fredi Bach