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 normalsico.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 dxico normals arguments Argument Default Meaning <depth.png>required height 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 --alloff write normal, height and AO (and the 16-bit height when the input has it) under the page's names --black <n>0 black point, % (0–99) --white <n>100 white point, % (1–100) --gamma <n>1 height gamma (0.25–4) --sharpen <n>0 unsharp amount (0–2) --smooth <n>1 gaussian smoothing sigma, px (0–8) --[no-]invertoff dark pixels become high --power <n>1 overall height, as the page's ×gain (0–100) --eq <list>2,2,2,2,2 five band gains, finest to low (×2 is the page's reference) (0–100) --convention <name>gl OpenGL +Y or DirectX −Y (gl | dx) --[no-]ditheron 4×4 Bayer dither on the normal map --ao-intensity <n>1 cavity AO strength (0–4) --[no-]simdon use heightfield-simd.wasm where the engine runs it --jsonoff print the summary as JSON on stdout --quietoff no progress on stderr ico dxtico.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;
--psnrdecodes 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-ormico dxt arguments Argument Default Meaning -a <png>— source slot A -b <png>— source slot B -c <png>— source slot C -d <png>— source slot D --preset <name>custom channel 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>bc7 block format (bc1 | bc3 | bc4 | bc5 | bc7) --quality <name>high encoder tier (fast | high) --[no-]srgboff tag the DXGI format sRGB and filter mips in linear light (BC1, BC3 and BC7 only) --[no-]mipson write 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 --psnroff decode the base level back and report PSNR over the channels the format stores --list-presetsoff print the preset table and exit --[no-]simdon use bcn-simd.wasm where the engine runs it --jsonoff print the summary as JSON on stdout --quietoff no progress on stderr ico sdfico.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 8ico sdf arguments Argument Default Meaning <input.png>required the 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>1 1 standard, 2 convex corners (Green 2007), 3 MSDF (1–3) --size <n>64 output size on the long edge, in texels (8–4096) --spread <n>16 distance range in output pixels (1–256) --pad <n>0 padding in output pixels; negative crops in --threshold <n>128 alpha or luminance cutoff (1–254) --sign <name>inside which side is positive (inside | outside) --jsonoff print the summary as JSON on stdout --quietoff no progress on stderr ico fontico.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 4096ico font arguments Argument Default Meaning <font.ttf>required TrueType, 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) --alloff also write the .font when the mode is sdf --mode <name>msdf 1, 3 or 4 channels (sdf | msdf | mtsdf) --em <n>48 glyph size, pixels per em (4–512) --px-range <n>8 distance range in texels (the shader's pxRange) (1–64) --padding <n>1 texels between glyph boxes (0–16) --atlas-max <n>2048 largest atlas edge (64–16384) --[no-]potoff power-of-two atlas height (otherwise a multiple of 32) --outline <name>curves evaluate the outline exactly, or rasterize it first (curves | raster) --raster-factor <name>8 raster upscale, and the curve path's fallback (4 | 8 | 12 | 16) --y-origin <name>bottom atlas bounds origin (bottom | top) --charset <ids>latin comma-separated presets; ASCII is always included --chars <text>extra characters, literally --ranges <spec>extra code points, e.g. "U+2190-21FF,20AC" --face <n>0 face index in a collection (0–255) --infooff print the face, its metrics and the charset coverage, and stop --list-charsetsoff print the charset presets and exit --json-summaryoff print the run summary as JSON on stdout --quietoff no progress on stderr ico bakeico.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 16ico bake arguments Argument Default Meaning <high>required the detailed mesh: OBJ, glTF, GLB, FBX or venus .mesh <low>required the 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 --normaloff bake the normal map (the one baked when no map is named) --heightoff bake the height map: -back to +front as 0 to 1 --aooff bake ambient occlusion --thicknessoff bake thickness: occlusion cast inward --bentoff bake the bent normal: the mean unoccluded direction --alloff bake every map --size <n|WxH>1024 output size in texels, up to 8192 --aa <name>1 samples per texel axis (1 | 2 | 4) --padding <n>12 texels the islands are dilated by (0–256) --bits <name>8 PNG bit depth (8 | 16) --[no-]ditheron ordered 4×4 dither for 8-bit output --front <n>0.02 how far outside the low surface a ray starts --back <n>0.02 how far inside the low surface a ray still finds the high mesh --unitsoff front, back, --ao-bias and --ao-limit in mesh units, not fractions of the low mesh's diagonal --space <name>tangent normal and bent-normal space (tangent | object) --convention <name>gl tangent-space green: up (OpenGL) or down (DirectX) (gl | dx) --[no-]smooth-projectionon cast along normals averaged across hard edges --[no-]conservativeon bake every texel a UV triangle touches, not only covered centres --ignore-backfaceoff rays pass through high triangles facing away from them --ao-rays <n>64 hemisphere rays per sample, for AO, thickness and bent normals (1–4096) --ao-uniformoff uniform directions instead of cosine-weighted --ao-cone <n>162 full angle the rays spread over, in degrees (1–180) --ao-bias <n>0 ray origin offset along the normal --ao-limit <n>0.1 longest occluding ray; 0 for the whole scene --ao-atten <list>1,0,0 an occluder at distance d weighs 1 / (a0 + a1 d + a2 d²), at most 1 --ao-allow-100off let a texel reach black (off keeps one ray's worth of light) --seed <n>0 sampling seed; 0 derives one from the other settings (0–4294967295) --jsonoff print the summary as JSON on stdout --quietoff no progress or mesh warnings on stderr ico meshico.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 arevenus-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-vico mesh arguments Argument Default Meaning <file>required the 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>1 uniform scale on positions (Unreal FBX: 0.01) --axes <x y z>x y z a signed permutation of the file's axes: venus.x, .y, .z in turn; "-y z -x" for Unreal's FBX --flip-yzoff the "x z -y" preset (Z-up to Y-up), venus-mesh-tool's flip_yz --flip-windingoff reverse every triangle (generated normals follow) --flip-voff v → 1 − v on every UV set: OBJ and FBX store V up, venus samples V down; glTF stores V down already --[no-]generate-normalson flat normals where the file has none --mesh <name|index>0 which FBX geometry, by index or by name --bake-transformoff apply the owner model's world transform to an FBX geometry (its model chain with pivots and pre/post rotation, then the geometric transform) --jsonoff print the summary as JSON on stdout --quietoff no mesh warnings on stderr ico animico.anim()Animation Importer-
An FBX take becomes a venus
.animclip 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 arevenus-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_lico anim arguments Argument Default Meaning <clip>required the animation: an FBX 7.x (binary or ASCII) with takes, with or without its skin --skeleton <rig.mesh|self>self the 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>0 which take, by index or by name --fps <file|n>file the clip's frame rate: the file's, or a whole number; the report says what the engine's lerp misses between frames at it --loopoff the 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>auto where 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>0 seconds of the end crossfaded onto the start: the clip ends that much sooner, pose and motion running on across the wrap --mirroroff reflect across the YZ plane and swap the Left/Right bone pairs --additive <off|first|bind>off write 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>1 the rig's scale: the SAME value the mesh was imported with (Unreal FBX: 0.01) --axes <x y z>x y z the rig's axes: the SAME signed permutation the mesh was imported with --flip-yzoff the "x z -y" preset (Z-up to Y-up), venus-mesh-tool's flip_yz --jsonoff print the summary as JSON on stdout --quietoff no 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.