diff --git a/SVG_SPEC.md b/SVG_SPEC.md new file mode 100644 index 00000000..f4b9ee65 --- /dev/null +++ b/SVG_SPEC.md @@ -0,0 +1,123 @@ +# RoboJS SVG Spec — Bit Allocation + +## Overview + +128 bits from MD5 hash → deterministic robot avatar rendered as SVG. + +Single color scheme per robot. SVG templates with fill/outline/highlight driven by hash bits. + +## Bit Allocation (128 bits total) + +### Color Scheme (16 bits) +| Bits | Field | Range | Description | +|------|-------|-------|-------------| +| 0-7 | `hue` | 0-255 | Base hue (mapped to 0-360°) | +| 8-11 | `saturation` | 0-15 | Saturation modifier (60-100%) | +| 12-15 | `lightness` | 0-15 | Lightness modifier (40-65%) | + +**Derived colors (computed, not stored):** +- **Fill:** `hsl(hue, sat%, light%)` +- **Outline:** same hue, 15% saturation, 20% lightness (dark, nearly black-brown) +- **Highlight:** same hue, sat-10%, light+20% (lighter accent) +- **Accent color** (eyes, teeth, details): see bits 80-95 + +### Part Selection — v1 (20 bits, 10 variations each) +| Bits | Field | Range | Description | +|------|-------|-------|-------------| +| 16-19 | `headShape` | 0-9 | Head template (10 shapes) | +| 20-23 | `bodyShape` | 0-9 | Body template | +| 24-27 | `eyeShape` | 0-9 | Eye template | +| 28-31 | `mouthShape` | 0-9 | Mouth template | +| 32-35 | `accessoryShape` | 0-9 | Accessory template | + +### Part Selection — v2 expansion (use full 4 bits = 16 variations) +Same bit positions, but `0-15` range when we have 16 templates per part. + +### Accent / Secondary Color (16 bits) +| Bits | Field | Range | Description | +|------|-------|-------|-------------| +| 80-87 | `accentHue` | 0-255 | Accent hue (eyes, teeth, antenna tips) | +| 88-91 | `accentSat` | 0-15 | Accent saturation | +| 92-95 | `accentLight` | 0-15 | Accent lightness (biased brighter: 55-85%) | + +### Surface Details (20 bits) +| Bits | Field | Range | Description | +|------|-------|-------|-------------| +| 36-39 | `rivetStyle` | 0-15 | Rivet/bolt pattern on head | +| 40-43 | `panelLines` | 0-15 | Panel line pattern on body | +| 44-47 | `eyeSize` | 0-15 | Eye scale modifier (80-120%) | +| 48-51 | `mouthWidth` | 0-15 | Mouth scale modifier | +| 52-55 | `accPlacement` | 0-15 | Accessory position offset | + +### Transforms (16 bits) +| Bits | Field | Range | Description | +|------|-------|-------|-------------| +| 56-59 | `headTilt` | 0-15 | Slight head rotation (-8° to +7°) | +| 60-63 | `eyeAsymmetry` | 0-15 | Left/right eye size difference | +| 64-67 | `bodyWidth` | 0-15 | Body scale X (90-110%) | +| 68-71 | `accRotation` | 0-15 | Accessory rotation | + +### Reserved (24 bits) +| Bits | Field | Description | +|------|-------|-------------| +| 72-79 | `reserved1` | Future use (antenna style? background?) | +| 96-103 | `reserved2` | Future use | +| 104-111 | `reserved3` | Future use | +| 112-119 | `reserved4` | Future use | +| 120-127 | `reserved5` | Future use | + +## SVG Template Structure + +Each part is an SVG `` element with CSS classes: + +```svg + + + + + + +``` + +At render time, CSS variables inject colors: +```css +:root { + --fill: hsl(120, 80%, 50%); + --outline: hsl(120, 15%, 20%); + --highlight: hsl(120, 70%, 70%); + --accent: hsl(45, 90%, 65%); +} +.fill { fill: var(--fill); } +.outline { fill: var(--outline); stroke: var(--outline); } +.highlight { fill: var(--highlight); } +.accent { fill: var(--accent); } +``` + +## Rendering Pipeline + +1. Hash input string → MD5 (128 bits) +2. Extract bit fields per allocation table +3. Compute color scheme from hue/sat/light bits +4. Select part templates from shape bits +5. Apply transforms (tilt, scale, asymmetry) +6. Compose SVG: body → head → mouth → eyes → accessory (back to front) +7. Inject CSS color variables +8. Output as SVG string or render to canvas via `` or inline SVG + +## Backward Compatibility + +v1 maintains same part selection as current sprite sheet (10 variations per part). +`getBuckets()` output can map to v1 SVG parts for visual parity. + +## File Structure + +``` +svg/ + parts/ + heads/ # head-0.svg through head-9.svg (v1) or head-15.svg (v2) + bodies/ + eyes/ + mouths/ + accessories/ + render.js # SVG composition + color injection +``` diff --git a/package.json b/package.json index f3edc148..8a1a39af 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "set1.png" ], "scripts": { + "build": "node scripts/build-svg.js", "test": "vitest run", "test:watch": "vitest", "lint": "eslint src/", @@ -36,7 +37,9 @@ }, "homepage": "https://github.com/monteslu/robojs#readme", "dependencies": { - "blueimp-md5": "^2.19.0" + "blueimp-md5": "^2.19.0", + "sharp": "^0.34.5", + "svgo": "^4.0.0" }, "devDependencies": { "eslint": "^9.0.0", diff --git a/scripts/build-svg.js b/scripts/build-svg.js new file mode 100644 index 00000000..e287cfd4 --- /dev/null +++ b/scripts/build-svg.js @@ -0,0 +1,285 @@ +#!/usr/bin/env node +/** + * Build script: reads all 50 SVG parts and outputs: + * dist/robojs-svg.js — renderer + loader (small, ~5KB) + * dist/robojs-parts.json — all 50 SVG parts data + * dist/robojs-svg.min.js — single file bundle (large, for CDN/offline) + * dist/demo.html — interactive demo + * + * Usage: node scripts/build-svg.js + */ + +import { readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const partsDir = join(root, 'svg', 'parts-traced'); +const distDir = join(root, 'dist'); + +mkdirSync(distDir, { recursive: true }); + +const PART_TYPES = [ + { key: 'body', dir: 'bodies', prefix: 'body' }, + { key: 'head', dir: 'heads', prefix: 'head' }, + { key: 'eye', dir: 'eyes', prefix: 'eye' }, + { key: 'mouth', dir: 'mouths', prefix: 'mouth' }, + { key: 'accessory', dir: 'accessories', prefix: 'accessory' }, +]; + +function extractInner(svg) { + svg = svg.replace(/<\?xml[^?]*\?>\s*/g, ''); + const match = svg.match(/]*>([\s\S]*)<\/svg>/); + return match ? match[1].trim() : svg; +} + +// Read all parts +const parts = {}; +for (const { key, dir, prefix } of PART_TYPES) { + parts[key] = []; + for (let i = 0; i < 10; i++) { + const file = join(partsDir, dir, `${prefix}-${i}.svg`); + const raw = readFileSync(file, 'utf-8'); + parts[key].push(extractInner(raw)); + } +} + +// Write parts JSON +writeFileSync(join(distDir, 'robojs-parts.json'), JSON.stringify(parts)); +console.log('Built dist/robojs-parts.json (' + (Buffer.byteLength(JSON.stringify(parts)) / 1024).toFixed(1) + ' KB)'); + +// Update demo.html +const demoHtml = ` + + + RoboJS SVG Demo + + + +

🤖 RoboJS SVG

+

One script, deterministic robot avatars. Type anything:

+ +
+

Gallery

+ + + + + +`; + +writeFileSync(join(distDir, 'demo.html'), demoHtml); + +// Renderer JS — UMD for browser then RoboJS.init().then(() => { div.innerHTML = RoboJS.renderSvg("name"); }) +// Usage (Node): const RoboJS = require('./robojs-svg.js'); await RoboJS.init('./robojs-parts.json'); RoboJS.renderSvg("name"); +(function(root, factory) { + if (typeof define === "function" && define.amd) define([], factory); + else if (typeof module === "object" && module.exports) module.exports = factory(); + else root.RoboJS = factory(); +}(typeof self !== "undefined" ? self : this, function() { +"use strict"; + +function md5(str) { + function safeAdd(x, y) { var lsw = (x & 0xffff) + (y & 0xffff); return (((x >> 16) + (y >> 16) + (lsw >> 16)) << 16) | (lsw & 0xffff); } + function bitRotateLeft(num, cnt) { return (num << cnt) | (num >>> (32 - cnt)); } + function md5cmn(q, a, b, x, s, t) { return safeAdd(bitRotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b); } + function md5ff(a, b, c, d, x, s, t) { return md5cmn((b & c) | (~b & d), a, b, x, s, t); } + function md5gg(a, b, c, d, x, s, t) { return md5cmn((b & d) | (c & ~d), a, b, x, s, t); } + function md5hh(a, b, c, d, x, s, t) { return md5cmn(b ^ c ^ d, a, b, x, s, t); } + function md5ii(a, b, c, d, x, s, t) { return md5cmn(c ^ (b | ~d), a, b, x, s, t); } + function binlMD5(x, len) { + x[len >> 5] |= 0x80 << (len % 32); + x[((len + 64) >>> 9 << 4) + 14] = len; + var a = 1732584193, b = -271733879, c = -1732584194, d = 271733878; + for (var i = 0; i < x.length; i += 16) { + var oa = a, ob = b, oc = c, od = d; + a = md5ff(a, b, c, d, x[i], 7, -680876936); d = md5ff(d, a, b, c, x[i+1], 12, -389564586); + c = md5ff(c, d, a, b, x[i+2], 17, 606105819); b = md5ff(b, c, d, a, x[i+3], 22, -1044525330); + a = md5ff(a, b, c, d, x[i+4], 7, -176418897); d = md5ff(d, a, b, c, x[i+5], 12, 1200080426); + c = md5ff(c, d, a, b, x[i+6], 17, -1473231341); b = md5ff(b, c, d, a, x[i+7], 22, -45705983); + a = md5ff(a, b, c, d, x[i+8], 7, 1770035416); d = md5ff(d, a, b, c, x[i+9], 12, -1958414417); + c = md5ff(c, d, a, b, x[i+10], 17, -42063); b = md5ff(b, c, d, a, x[i+11], 22, -1990404162); + a = md5ff(a, b, c, d, x[i+12], 7, 1804603682); d = md5ff(d, a, b, c, x[i+13], 12, -40341101); + c = md5ff(c, d, a, b, x[i+14], 17, -1502002290); b = md5ff(b, c, d, a, x[i+15], 22, 1236535329); + a = md5gg(a, b, c, d, x[i+1], 5, -165796510); d = md5gg(d, a, b, c, x[i+6], 9, -1069501632); + c = md5gg(c, d, a, b, x[i+11], 14, 643717713); b = md5gg(b, c, d, a, x[i], 20, -373897302); + a = md5gg(a, b, c, d, x[i+5], 5, -701558691); d = md5gg(d, a, b, c, x[i+10], 9, 38016083); + c = md5gg(c, d, a, b, x[i+15], 14, -660478335); b = md5gg(b, c, d, a, x[i+4], 20, -405537848); + a = md5gg(a, b, c, d, x[i+9], 5, 568446438); d = md5gg(d, a, b, c, x[i+14], 9, -1019803690); + c = md5gg(c, d, a, b, x[i+3], 14, -187363961); b = md5gg(b, c, d, a, x[i+8], 20, 1163531501); + a = md5gg(a, b, c, d, x[i+13], 5, -1444681467); d = md5gg(d, a, b, c, x[i+2], 9, -51403784); + c = md5gg(c, d, a, b, x[i+7], 14, 1735328473); b = md5gg(b, c, d, a, x[i+12], 20, -1926607734); + a = md5hh(a, b, c, d, x[i+5], 4, -378558); d = md5hh(d, a, b, c, x[i+8], 11, -2022574463); + c = md5hh(c, d, a, b, x[i+11], 16, 1839030562); b = md5hh(b, c, d, a, x[i+14], 23, -35309556); + a = md5hh(a, b, c, d, x[i+1], 4, -1530992060); d = md5hh(d, a, b, c, x[i+4], 11, 1272893353); + c = md5hh(c, d, a, b, x[i+7], 16, -155497632); b = md5hh(b, c, d, a, x[i+10], 23, -1094730640); + a = md5hh(a, b, c, d, x[i+13], 4, 681279174); d = md5hh(d, a, b, c, x[i], 11, -358537222); + c = md5hh(c, d, a, b, x[i+3], 16, -722521979); b = md5hh(b, c, d, a, x[i+6], 23, 76029189); + a = md5hh(a, b, c, d, x[i+9], 4, -640364487); d = md5hh(d, a, b, c, x[i+12], 11, -421815835); + c = md5hh(c, d, a, b, x[i+15], 16, 530742520); b = md5hh(b, c, d, a, x[i+2], 23, -995338651); + a = md5ii(a, b, c, d, x[i], 6, -198630844); d = md5ii(d, a, b, c, x[i+7], 10, 1126891415); + c = md5ii(c, d, a, b, x[i+14], 15, -1416354905); b = md5ii(b, c, d, a, x[i+5], 21, -57434055); + a = md5ii(a, b, c, d, x[i+12], 6, 1700485571); d = md5ii(d, a, b, c, x[i+3], 10, -1894986606); + c = md5ii(c, d, a, b, x[i+10], 15, -1051523); b = md5ii(b, c, d, a, x[i+1], 21, -2054922799); + a = md5ii(a, b, c, d, x[i+8], 6, 1873313359); d = md5ii(d, a, b, c, x[i+15], 10, -30611744); + c = md5ii(c, d, a, b, x[i+6], 15, -1560198380); b = md5ii(b, c, d, a, x[i+13], 21, 1309151649); + a = md5ii(a, b, c, d, x[i+4], 6, -145523070); d = md5ii(d, a, b, c, x[i+11], 10, -1120210379); + c = md5ii(c, d, a, b, x[i+2], 15, 718787259); b = md5ii(b, c, d, a, x[i+9], 21, -343485551); + a = safeAdd(a, oa); b = safeAdd(b, ob); c = safeAdd(c, oc); d = safeAdd(d, od); + } + return [a, b, c, d]; + } + function rstrMD5(s) { + var bin = binlMD5(str2binl(s), s.length * 8); + var out = ""; + for (var i = 0; i < bin.length * 32; i += 8) out += String.fromCharCode((bin[i >> 5] >>> (i % 32)) & 0xff); + return out; + } + function str2binl(str) { + var bin = []; + for (var i = 0; i < str.length * 8; i += 8) bin[i >> 5] |= (str.charCodeAt(i / 8) & 0xff) << (i % 32); + return bin; + } + function rstr2hex(input) { + var hex = "0123456789abcdef", output = ""; + for (var i = 0; i < input.length; i++) { var x = input.charCodeAt(i); output += hex.charAt((x >>> 4) & 0x0f) + hex.charAt(x & 0x0f); } + return output; + } + function rstrMD5str(s) { + var utf8 = unescape(encodeURIComponent(s)); + return rstr2hex(rstrMD5(utf8)); + } + return rstrMD5str(str); +} + +var COLORS = [ + {h:198,s:75,l:51},{h:26,s:40,l:39},{h:129,s:53,l:46},{h:216,s:3,l:66},{h:32,s:92,l:54}, + {h:331,s:100,l:64},{h:301,s:57,l:36},{h:358,s:85,l:52},{h:240,s:4,l:95},{h:56,s:94,l:58} +]; +var SOURCE_HUES = {body:129,head:129,eye:198,mouth:198,accessory:198}; +var PARTS = null; + +function hexToBytes(hex) { + var bytes = []; + for (var i = 0; i < hex.length; i += 2) bytes.push(parseInt(hex.substring(i, i + 2), 16)); + return bytes; +} + +function getBuckets(hash) { + var clean = hash.replace(/-/g, ""); + var bytes = hexToBytes(clean); + var buckets = []; + for (var i = 0; i < bytes.length; i += 2) buckets.push(((bytes[i] << 8) + bytes[i + 1]) % 10); + return buckets; +} + +function renderBuckets(buckets) { + if (!PARTS) throw new Error("RoboJS: call init() first or pass parts to setParts()"); + var bodyIdx = buckets[0], headIdx = buckets[1], eyeIdx = buckets[2]; + var mouthIdx = buckets[3], accIdx = buckets[4]; + var bhColor = buckets[5], emColor = buckets[6], accColor = buckets[7]; + var bhHue = (COLORS[bhColor] || COLORS[0]).h; + var emHue = (COLORS[emColor] || COLORS[0]).h; + var accHue = (COLORS[accColor] || COLORS[0]).h; + var fid = 0; + function hf(src, tgt) { + var id = "hue" + (fid++); + return { id: id, def: '' }; + } + var bf = hf(SOURCE_HUES.body, bhHue), hef = hf(SOURCE_HUES.head, bhHue); + var ef = hf(SOURCE_HUES.eye, emHue), mf = hf(SOURCE_HUES.mouth, emHue); + var af = hf(SOURCE_HUES.accessory, accHue); + var defs = "" + bf.def + hef.def + ef.def + mf.def + af.def + ""; + var layers = [ + [PARTS.body[bodyIdx], bf.id], [PARTS.head[headIdx], hef.id], + [PARTS.mouth[mouthIdx], mf.id], [PARTS.eye[eyeIdx], ef.id], + [PARTS.accessory[accIdx], af.id] + ]; + var inner = ""; + for (var i = 0; i < layers.length; i++) { + if (layers[i][0]) inner += '' + layers[i][0] + ""; + } + return '' + defs + inner + ""; +} + +function renderSvgHash(hash) { + if (!hash || hash.length < 32) hash = md5(hash || ""); + return renderBuckets(getBuckets(hash)); +} + +function renderSvg(input) { + return renderBuckets(getBuckets(md5(input || ""))); +} + +function setParts(p) { PARTS = p; } + +function init(partsUrl) { + if (PARTS) return Promise.resolve(); + if (!partsUrl) partsUrl = "robojs-parts.json"; + if (typeof process !== "undefined" && typeof require !== "undefined") { + // Node.js — use fs + var fs = require("fs"); + var path = require("path"); + var resolved = path.resolve(partsUrl); + PARTS = JSON.parse(fs.readFileSync(resolved, "utf-8")); + return Promise.resolve(); + } else if (typeof fetch !== "undefined") { + return fetch(partsUrl).then(function(r) { return r.json(); }).then(function(p) { PARTS = p; }); + } + return Promise.reject(new Error("Cannot load parts: no fetch or require")); +} + +return { + init: init, + setParts: setParts, + renderSvg: renderSvg, + renderSvgHash: renderSvgHash, + renderBuckets: renderBuckets, + getBuckets: getBuckets, + COLORS: COLORS, + md5: md5 +}; + +})); +`; + +// Write as .cjs (works in Node with "type":"module" packages + browser + +