ICOGRAPHICS

ico

The same tools, without the interface

Every page here works in your browser tab — good for one texture, poor for four hundred. ico is the same code with the interface taken off: call it on this page from a headless browser, or install it and run it from a shell. Batch a directory, add a step to a build, or let an agent do it.

In a browser, with nothing installed

This page carries the CLI. Point a headless browser at it and the tools are one function call — bytes in, bytes out, with no file input to fill and no download to catch.

await page.goto('https://icosahedra.com/cli/');
const out = await page.evaluate(`ico.sdf('${pngBase64}', { chan: 3, size: 256 })`);

out.file     // the PNG, base64 — the run wrote one file
out.ok       // false when it failed; out.exitCode is 0, 1 usage, 2 runtime
out.stderr   // what the shell would have printed
out.json     // the --json summary, parsed, when you asked for one

One call per tool, an option per flag:

ico.normals(depth, { all: true, convention: 'dx', smooth: 2 })
ico.sdf(shape, { chan: 3, size: 256, spread: 8 })
ico.font(ttf, { mode: 'msdf', em: 48, charset: 'latin' })
ico.dxt({ a: albedo, b: rough }, { preset: 'ue-orm', psnr: true })
ico.bake({ high, low: { 'rock.obj': low } }, { all: true })
ico.mesh(fbx, { scale: 0.1, flipV: true })
ico.anim({ clip: walkFbx, skeleton: heroMesh }, { loop: true, rootMotion: 'xz,yaw' })

ico.run('sdf in.png --chan 3', { 'in.png': png })   // anything the seven miss

An option becomes its flag — atlasMax: 4096 is --atlas-max 4096, psnr: true is --psnr, simd: false is --no-simd, and an array is whatever that flag takes (eq: [3, 2.5, 2, 1.5, 1] is one comma-separated value, map: […] is --map again). An option a tool does not have comes back naming every option it does have, and await ico.spec('sdf') is the same table as data, so a caller with no documentation in front of it can ask instead of guessing. So every flag on this page is reachable, and ico.run takes the command line itself when that is easier.

Input is base64, a data: URL, a Uint8Array or an ArrayBuffer. Pass { 'logo.png': bytes } instead of bare bytes when the name matters — ico dxt names its DDS after the source, ico mesh its .mesh after the file. Files come back as base64, which survives page.evaluate; { binary: true } gives Uint8Arrays, which is what you want when you have imported the module.

Your own page or worker can import it directly instead, from any origin: const { sdf } = await import('https://icosahedra.com/api/ico.js'). The first call to each tool fetches that tool's wasm (up to 760 KB, for bake) and keeps it for the life of the page; ico.preload() fetches all nine binaries, about 2.3 MB, ahead of time. The work runs on the calling thread, so a large atlas holds the tab still while it goes — put it in a worker if that matters.

Or install it, if you have a shell

curl -fsSL https://icosahedra.com/cli/install.sh -o install.sh
sh install.sh ./ico
./ico/ico.sh --help

Faster than the browser route, scriptable, and the only way to work on files in place. 50 files, about 2.5 MB. No build step, no package manager, no repository to clone: this site serves the source as it is, so installing is copying. Read install.sh first if you would rather not run a script you have not seen — it fetches manifest.txt and the files it lists, and nothing else.

Needs JavaScriptCore, which ships with macOS, or Node 22.7+. The launcher uses whichever it finds. Put it on your path with ln -s "$PWD/ico/ico.sh" /usr/local/bin/ico.

The tools

ico normals ico.normals() Normal Map Generator

A height or depth image becomes normal, height and cavity-AO maps. Five-band detail EQ, levels, unsharp, GL or DirectX green, 8- or 16-bit out.

ico normals height.png --all --out-dir out/
ico normals rock.png --smooth 2 --eq 3,2.5,2,1.5,1 --convention dx
ico normals arguments
ArgumentDefaultMeaning
<depth.png>requiredheight or depth image; a 16-bit PNG keeps full precision, anything else is 8-bit luminance
--out-dir <dir>.where the default names land
--normal <path>—write the 8-bit normal map here
--normal16 <path>—write the 16-bit normal map here (recomputed from the float normals, like the page)
--height <path>—write the 8-bit processed height view here
--height16 <path>—write the input depth as a 16-bit grey PNG (needs 16-bit input)
--ao <path>—write the cavity AO map here
--alloffwrite normal, height and AO (and the 16-bit height when the input has it) under the page's names
--black <n>0black point, % (0–99)
--white <n>100white point, % (1–100)
--gamma <n>1height gamma (0.25–4)
--sharpen <n>0unsharp amount (0–2)
--smooth <n>1gaussian smoothing sigma, px (0–8)
--[no-]invertoffdark pixels become high
--power <n>1overall height, as the page's ×gain (0–100)
--eq <list>2,2,2,2,2five band gains, finest to low (×2 is the page's reference) (0–100)
--convention <name>glOpenGL +Y or DirectX −Y (gl | dx)
--[no-]ditheron4×4 Bayer dither on the normal map
--ao-intensity <n>1cavity AO strength (0–4)
--[no-]simdonuse heightfield-simd.wasm where the engine runs it
--jsonoffprint the summary as JSON on stdout
--quietoffno progress on stderr
ico dxt ico.dxt() BCn Texture Exporter

Pack up to four sources into RGBA channels and compress to BC1, BC3, BC4, BC5 or BC7. Writes a DDS with mips and an sRGB tag; --psnr decodes it back and measures the damage.

ico dxt -a albedo.png --preset passthrough --psnr
ico dxt -a ao.png -b rough.png -c metal.png --preset ue-orm
ico dxt arguments
ArgumentDefaultMeaning
-a <png>—source slot A
-b <png>—source slot B
-c <png>—source slot C
-d <png>—source slot D
--preset <name>customchannel layout, format and colour space in one (custom | ue-orm | unity-mask | bc5-normal | passthrough)
--map <spec>—one output channel, e.g. R=A.LUMA or G=B.R,invert,black=12,white=240,gamma=1.7 or A=ONE. Repeatable; overrides the preset
--format <name>bc7block format (bc1 | bc3 | bc4 | bc5 | bc7)
--quality <name>highencoder tier (fast | high)
--[no-]srgbofftag the DXGI format sRGB and filter mips in linear light (BC1, BC3 and BC7 only)
--[no-]mipsonwrite the whole mip chain
--width <n>—output width; the first slot in use sets it by default (1–16384)
--height <n>—output height (1–16384)
--dds <path>—output DDS (default <source>-<format>.dds in --out-dir)
--png <path>—also write the packed RGBA as a PNG
--out-dir <dir>.where the default names land
--psnroffdecode the base level back and report PSNR over the channels the format stores
--list-presetsoffprint the preset table and exit
--[no-]simdonuse bcn-simd.wasm where the engine runs it
--jsonoffprint the summary as JSON on stdout
--quietoffno progress on stderr
ico sdf ico.sdf() SDF Generator

A shape image becomes a 1-channel distance field, a 2-channel convex-corner field, or a 3-channel MSDF that keeps corners sharp under magnification.

ico sdf logo.png --chan 3 --size 256 --spread 8
ico sdf arguments
ArgumentDefaultMeaning
<input.png>requiredthe shape: alpha when it has any, otherwise luminance
--out <path>—output PNG (default sdf-<chan>ch-<w>x<h>.png in --out-dir)
--out-dir <dir>.where the default name lands
--chan <n>11 standard, 2 convex corners (Green 2007), 3 MSDF (1–3)
--size <n>64output size on the long edge, in texels (8–4096)
--spread <n>16distance range in output pixels (1–256)
--pad <n>0padding in output pixels; negative crops in
--threshold <n>128alpha or luminance cutoff (1–254)
--sign <name>insidewhich side is positive (inside | outside)
--jsonoffprint the summary as JSON on stdout
--quietoffno progress on stderr
ico font ico.font() Font Atlas Generator

A TTF, OTF or TTC becomes an SDF, MSDF or MTSDF glyph atlas with metrics and kerning, plus msdf-atlas-gen JSON that most engine importers already read.

ico font Inter.ttf --mode msdf --em 48
ico font NotoSans.ttf --charset latin,greek,cyrillic --atlas-max 4096
ico font arguments
ArgumentDefaultMeaning
<font.ttf>requiredTrueType, OpenType or a TrueType collection (WOFF is not supported)
--out-dir <dir>.where the default names land
--png <path>—atlas PNG (default <font>-<mode>-<em>px.png)
--json <path>—layout JSON (default <font>-<mode>-<em>px.json)
--font <path>—venus .font bytes (SDF mode only)
--alloffalso write the .font when the mode is sdf
--mode <name>msdf1, 3 or 4 channels (sdf | msdf | mtsdf)
--em <n>48glyph size, pixels per em (4–512)
--px-range <n>8distance range in texels (the shader's pxRange) (1–64)
--padding <n>1texels between glyph boxes (0–16)
--atlas-max <n>2048largest atlas edge (64–16384)
--[no-]potoffpower-of-two atlas height (otherwise a multiple of 32)
--outline <name>curvesevaluate the outline exactly, or rasterize it first (curves | raster)
--raster-factor <name>8raster upscale, and the curve path's fallback (4 | 8 | 12 | 16)
--y-origin <name>bottomatlas bounds origin (bottom | top)
--charset <ids>latincomma-separated presets; ASCII is always included
--chars <text>extra characters, literally
--ranges <spec>extra code points, e.g. "U+2190-21FF,20AC"
--face <n>0face index in a collection (0–255)
--infooffprint the face, its metrics and the charset coverage, and stop
--list-charsetsoffprint the charset presets and exit
--json-summaryoffprint the run summary as JSON on stdout
--quietoffno progress on stderr
ico bake ico.bake() Texture Baker

A high-poly and a low-poly mesh (OBJ, glTF, GLB, FBX or a venus .mesh) become normal, height, ambient occlusion, thickness and bent-normal maps on the low mesh's UVs, with MikkTSpace tangents and an optional cage. In a browser the meshes go in as { high, low, cage }.

ico bake rock_high.obj rock_low.obj --all --size 2048
ico bake statue.glb statue_low.glb --cage statue_cage.obj --convention dx --bits 16
ico bake arguments
ArgumentDefaultMeaning
<high>requiredthe detailed mesh: OBJ, glTF, GLB, FBX or venus .mesh
<low>requiredthe mesh the maps are for, with UVs; names the outputs
--cage <file>—cage mesh: the low mesh's triangles, in order, pushed out
--out-dir <dir>.where <low>-<map>.png land
--normaloffbake the normal map (the one baked when no map is named)
--heightoffbake the height map: -back to +front as 0 to 1
--aooffbake ambient occlusion
--thicknessoffbake thickness: occlusion cast inward
--bentoffbake the bent normal: the mean unoccluded direction
--alloffbake every map
--size <n|WxH>1024output size in texels, up to 8192
--aa <name>1samples per texel axis (1 | 2 | 4)
--padding <n>12texels the islands are dilated by (0–256)
--bits <name>8PNG bit depth (8 | 16)
--[no-]ditheronordered 4×4 dither for 8-bit output
--front <n>0.02how far outside the low surface a ray starts
--back <n>0.02how far inside the low surface a ray still finds the high mesh
--unitsofffront, back, --ao-bias and --ao-limit in mesh units, not fractions of the low mesh's diagonal
--space <name>tangentnormal and bent-normal space (tangent | object)
--convention <name>gltangent-space green: up (OpenGL) or down (DirectX) (gl | dx)
--[no-]smooth-projectiononcast along normals averaged across hard edges
--[no-]conservativeonbake every texel a UV triangle touches, not only covered centres
--ignore-backfaceoffrays pass through high triangles facing away from them
--ao-rays <n>64hemisphere rays per sample, for AO, thickness and bent normals (1–4096)
--ao-uniformoffuniform directions instead of cosine-weighted
--ao-cone <n>162full angle the rays spread over, in degrees (1–180)
--ao-bias <n>0ray origin offset along the normal
--ao-limit <n>0.1longest occluding ray; 0 for the whole scene
--ao-atten <list>1,0,0an occluder at distance d weighs 1 / (a0 + a1 d + a2 d²), at most 1
--ao-allow-100offlet a texel reach black (off keeps one ray's worth of light)
--seed <n>0sampling seed; 0 derives one from the other settings (0–4294967295)
--jsonoffprint the summary as JSON on stdout
--quietoffno progress or mesh warnings on stderr
ico mesh ico.mesh() Mesh Importer

An FBX, OBJ, glTF or GLB becomes a venus .mesh: the engine's vertex format, MikkTSpace tangents, bounds, and the skeleton of an FBX skin. The bytes are venus-mesh-tool's for the same file and settings. In a browser the mesh goes in as bytes or { 'dog.fbx': bytes }.

ico mesh dog.fbx --scale 0.1
ico mesh door.fbx --axes "-y z -x" --scale 0.01 --flip-winding --flip-v
ico mesh arguments
ArgumentDefaultMeaning
<file>requiredthe mesh: OBJ, FBX 7.x (binary or ASCII), glTF (buffers inside) or GLB
--out <file>—where the .mesh lands (default <file>.mesh in the current directory)
--scale <n>1uniform scale on positions (Unreal FBX: 0.01)
--axes <x y z>x y za signed permutation of the file's axes: venus.x, .y, .z in turn; "-y z -x" for Unreal's FBX
--flip-yzoffthe "x z -y" preset (Z-up to Y-up), venus-mesh-tool's flip_yz
--flip-windingoffreverse every triangle (generated normals follow)
--flip-voffv → 1 − v on every UV set: OBJ and FBX store V up, venus samples V down; glTF stores V down already
--[no-]generate-normalsonflat normals where the file has none
--mesh <name|index>0which FBX geometry, by index or by name
--bake-transformoffapply the owner model's world transform to an FBX geometry (its model chain with pivots and pre/post rotation, then the geometric transform)
--jsonoffprint the summary as JSON on stdout
--quietoffno mesh warnings on stderr
ico anim ico.anim() Animation Importer

An FBX take becomes a venus .anim clip for a skeleton — a venus .mesh's bones, matched by name, or the clip file's own skin — with the page's ops: trim, loop (the loop frame read off the take's ends, or a crossfade), mirror, additive, root motion and events; every run prints where the take cycles. The bytes are venus-mesh-tool anim's for the same file and settings. In a browser the inputs go in as { clip, skeleton }.

ico anim walk.fbx --skeleton hero.mesh --loop
ico anim walk.fbx --skeleton hero.mesh --loop --range 0.1:1.2 --root-motion xz,yaw --event 0.25:footstep_l
ico anim arguments
ArgumentDefaultMeaning
<clip>requiredthe animation: an FBX 7.x (binary or ASCII) with takes, with or without its skin
--skeleton <rig.mesh|self>selfthe venus .mesh whose bones the clip is written for (matched by name), or self: the clip file's own skin
--out <file>—where the .anim lands (default <name>.anim in the current directory)
--stack <name|index>0which take, by index or by name
--fps <file|n>filethe clip's frame rate: the file's, or a whole number; the report says what the engine's lerp misses between frames at it
--loopoffthe clip is a cycle: the file says so, and its loop frame lands on a frame at any rate (see --loop-frame and --loop-blend)
--loop-frame <auto|append|never>autowhere the loop frame sits: auto reads it off the take's ends (the last frame when it repeats the first within half its step, else one file frame later); append and never force it
--range <A:B>—seconds of the take to keep, both ends inclusive; frame 0 lands on A (a split is two runs)
--loop-blend <n>0seconds of the end crossfaded onto the start: the clip ends that much sooner, pose and motion running on across the wrap
--mirroroffreflect across the YZ plane and swap the Left/Right bone pairs
--additive <off|first|bind>offwrite deltas over the first frame or the bind pose, for an additive layer
--root-motion <xz,y,yaw>—take the root's planar travel, height and/or yaw out of its track into the file's root_motion section
--root-bone <name>—the bone the root motion is taken from (default: the skeleton's first root)
--name <path>—the clip's name in the file (default: the file's stem, and the take's name when there are several)
--event <T:NAME[:VALUE]>—an event at T seconds of the clip on the track NAME, carrying the whole number VALUE (default 0); say it again for another
--scale <n>1the rig's scale: the SAME value the mesh was imported with (Unreal FBX: 0.01)
--axes <x y z>x y zthe rig's axes: the SAME signed permutation the mesh was imported with
--flip-yzoffthe "x z -y" preset (Z-up to Y-up), venus-mesh-tool's flip_yz
--jsonoffprint the summary as JSON on stdout
--quietoffno warnings on stderr

Every argument each tool takes is below, with its default. The same tables come back from ico <tool> --help, and /llms.txt carries them as plain text for agents. In a browser an argument is an option — --atlas-max 4096 is atlasMax: 4096, --no-simd is simd: false. Exit codes: 0 success, 1 usage error, 2 runtime failure.

What you get is what the page gives you

Same code

Each tool imports the page's own processing modules and loads the same wasm binary. Nothing is copied or reimplemented, so the two cannot drift apart.

Same defaults

A flag you do not pass is the page's control value. A default run reproduces what the page's download button writes.

Checked, not claimed

The test suite runs each tool and compares the bytes against the same job driven through the page's modules. The distance field, the normal and AO maps, the DDS, the font atlas and the .mesh come out identical.

Still private

Nothing is uploaded by either route. The pages work inside the tab; the CLI works on your machine. The only request the installer makes is to this site.

Three things do differ, on purpose: PNG files are compressed by our own deflate rather than a browser's, so the pixels match but the bytes of the container do not; resizing a source in ico dxt uses our filter instead of the browser's canvas; and the AI depth pass in the Normal Map Generator is a WebGPU runtime with no offline equivalent — feed the CLI a depth image instead. The README has the details.

What it reads

PNG, in any bit depth or colour type, as long as it is not interlaced — 16-bit input keeps its full precision in ico normals. Fonts are TTF, OTF or TTC. Meshes are OBJ, binary FBX 7.x, glTF with its buffers inside, or GLB. There is no JPEG or WebP decoder off the browser, so convert those first.

If you are an agent

Use one of the two routes above rather than driving the tool pages. Those pages are a file input, a canvas and a worker: their result leaves as a browser download, which is awkward to collect and tells you nothing when it fails. ico.run on this page hands you the bytes and an exit code in the same call, and needs no shell; the install is the better one when you have a shell, and carries a skill file per tool under cli/skills/. /llms.txt is the short version of this page for agents.