added more complex cybersigilism generator

This commit is contained in:
2026-05-16 18:16:49 +02:00
parent 2dc224abc4
commit b9aa93912c
3 changed files with 243 additions and 104 deletions
+197 -74
View File
@@ -1,110 +1,233 @@
/*
* cybersigil — modular neo-tribal sigil builder.
* cybersigil — chaotic neo-tribal sigil generator.
*
* Cybersigilism's whole trick is hard vertical symmetry: author only the
* right half, mirror it about a central spine. We keep a small library of
* hand-drawn glyph blocks (blade / hook / thorn / loop / dagger / antenna /
* fang / comet), each authored in local coords with the spine at x=0 growing
* into +x. `buildCybersigil` randomly stacks 35 of them down the Y axis,
* wraps the column in two <g>s (identity + scale(-1 1)) for the mirror, and
* emits an inert SVG string.
* Cybersigilism's signature is dense, fractal, barbed linework with hard
* vertical symmetry. We grow it procedurally: a wavering central spine spawns
* recursive curved limbs, each scattering barbs and thin filament shadows and
* occasionally terminating in a small hand-drawn motif. The whole right-leaning
* tangle is mirrored about x=0 (scale(-1 1)); a spine that wobbles in +x while
* its mirror wobbles in x weaves the two halves into one symmetric growth.
*
* Carving: every <path> carries pathLength="1" so the CSS draw-on animation
* (stroke-dasharray/dashoffset in global.css, scoped to `.cybersigil`) needs
* no JS length measurement. Each path gets an inline `--i` so strokes carve
* top-to-bottom in sequence.
*
* Output is decorative and self-generated (no user input) — safe to inject
* via innerHTML / dangerouslySetInnerHTML. Inert under every non-cybersigil
* theme because all styling is `.cybersigil`-scoped.
* Strokes carry pathLength="1" so the CSS draw-on ("carve") animation needs no
* JS length measurement; an inline `--i` staggers the carve into waves.
* Output is decorative and self-generated (no user input) — safe to inject via
* innerHTML. Inert under non-cybersigil themes (all styling `.cybersigil`-scoped).
*/
export interface Glyph {
/** max extent from the spine on +x */
w: number;
/** vertical run of the block */
h: number;
/** path data, spine at x=0, y in [0,h] */
/** path data, local coords, anchored near origin */
d: string;
}
// Right-half building blocks. Each starts/ends near the spine (x≈0) so the
// standalone spine line knits the column into one continuous growth.
// Small hand-drawn motifs used as limb-tip flourishes — the deliberate,
// "carved on purpose" punctuation amid the procedural chaos.
export const GLYPHS: readonly Glyph[] = [
// blade — long sweep that hooks back, two cast-off barbs
{ w: 30, h: 36, d: 'M0 2 Q30 8 25 22 Q21 33 0 35 M5 11 L15 5 M19 17 L28 13' },
// hook — tight inward claw with a tip spur
{ w: 26, h: 29, d: 'M0 5 Q24 3 24 16 Q24 27 7 28 Q0 28 3 21 M0 5 L6 0' },
// thorn cluster — spine run throwing three barbs
{ w: 17, h: 30, d: 'M0 0 L0 30 M0 6 L15 2 M0 15 L17 11 M0 24 L12 22' },
// loop — teardrop eye off the spine
{ w: 29, h: 30, d: 'M0 4 Q28 1 28 17 Q28 30 12 29 Q4 29 6 21 Q9 16 16 18 M0 4 L0 30' },
// dagger — crossbar over a descending blade
{ w: 19, h: 34, d: 'M0 0 L0 34 M0 9 L19 9 M0 9 L14 3 M0 22 L11 27' },
// antenna — rising spike with a split tip
{ w: 25, h: 30, d: 'M0 24 Q7 12 19 4 M19 4 L15 0 M19 4 L25 2 M0 24 L0 30' },
// fang — bold curved fang with an inner notch
{ w: 23, h: 38, d: 'M0 0 Q21 9 22 24 Q22 35 11 37 M0 0 L0 38 M8 14 Q15 18 15 27' },
// comet — node trailing two barbs
{ w: 30, h: 32, d: 'M0 8 Q17 3 23 12 Q27 19 20 24 Q13 30 6 25 M23 12 L30 9 M20 24 L27 30 M0 0 L0 32' },
{ w: 18, h: 22, d: 'M0 0 Q16 5 14 14 Q12 21 0 22 M4 7 L10 3' },
{ w: 16, h: 16, d: 'M0 8 Q14 0 15 8 Q14 16 0 8 M8 2 L8 14' },
{ w: 14, h: 24, d: 'M0 0 L0 24 M0 6 L12 2 M0 14 L13 10 M0 21 L9 19' },
{ w: 20, h: 18, d: 'M0 9 Q10 -2 19 4 M19 4 L15 0 M19 4 L20 9 M0 9 L4 16' },
{ w: 15, h: 20, d: 'M0 0 Q14 6 13 13 Q12 19 0 19 M7 8 Q11 11 11 16' },
] as const;
export interface SigilOptions {
/** how many blocks to stack; default random 35 */
/** rough number of primary branch nodes; default random 610 */
count?: number;
/** injectable RNG (0..1); default Math.random */
rng?: () => number;
}
const PAD = 6;
/** blocks knit slightly so the spine reads unbroken */
const OVERLAP = 4;
const H = 200; // internal canvas height (viewBox scales it to fit)
const PAD = 14;
const MAX_PATHS = 110; // safety ceiling; real density is bounded by the params
function pickIndices(n: number, total: number, rng: () => number): number[] {
const pool = Array.from({ length: total }, (_, i) => i);
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return pool.slice(0, n);
}
type Pt = [number, number];
/**
* Build a single mirrored sigil and return it as an SVG markup string.
* Random per call unless a deterministic `rng` is supplied.
*/
export function buildCybersigil(opts: SigilOptions = {}): string {
const rng = opts.rng ?? Math.random;
const count = opts.count ?? 3 + Math.floor(rng() * 3); // 35
const chosen = pickIndices(count, GLYPHS.length, rng).map((i) => GLYPHS[i]);
const rnd = (a: number, b: number) => a + rng() * (b - a);
const pick = <T>(a: readonly T[]): T => a[Math.floor(rng() * a.length)];
const n = (v: number) => {
const r = Math.round(v * 10) / 10;
return Object.is(r, -0) ? '0' : String(r);
};
let y = 0;
let maxW = 0;
let bottom = 0;
const groups: string[] = [];
const parts: string[] = [];
let strokeCount = 0;
let maxX = 24;
chosen.forEach((g, idx) => {
groups.push(
`<g transform="translate(0 ${y})"><path d="${g.d}" pathLength="1" style="--i:${idx + 1}"/></g>`,
const track = (x: number) => {
const ax = Math.abs(x);
if (ax > maxX) maxX = ax;
};
const emit = (d: string, cls: string) => {
if (strokeCount >= MAX_PATHS) return;
parts.push(
`<path class="${cls}" d="${d}" pathLength="1" style="--i:${strokeCount % 16}"/>`,
);
bottom = Math.max(bottom, y + g.h);
maxW = Math.max(maxW, g.w);
y += g.h - OVERLAP;
});
strokeCount++;
};
const half = groups.join('');
const spine = `<line class="cs-sig-spine" x1="0" y1="0" x2="0" y2="${bottom}" pathLength="1" style="--i:0"/>`;
// Catmull-Rom → cubic Bézier through an ordered point list (organic sweep).
const spline = (pts: Pt[]): string => {
if (pts.length < 2) return '';
let d = `M${n(pts[0][0])} ${n(pts[0][1])}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] ?? pts[i];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2] ?? p2;
const c1: Pt = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
const c2: Pt = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
d += `C${n(c1[0])} ${n(c1[1])} ${n(c2[0])} ${n(c2[1])} ${n(p2[0])} ${n(p2[1])}`;
track(c1[0]);
track(c2[0]);
track(p2[0]);
}
return d;
};
const minX = -(maxW + PAD);
const vbW = 2 * (maxW + PAD);
const minY = -PAD;
const vbH = bottom + 2 * PAD;
// A short spike, optionally kinked into a fang.
const barb = (base: Pt, ang: number, len: number) => {
const tip: Pt = [base[0] + Math.cos(ang) * len, base[1] + Math.sin(ang) * len];
if (tip[0] < -3) tip[0] = -3;
track(base[0]);
track(tip[0]);
if (rng() < 0.45) {
const mid: Pt = [
(base[0] + tip[0]) / 2 + Math.cos(ang + Math.PI / 2) * len * 0.3,
(base[1] + tip[1]) / 2 + Math.sin(ang + Math.PI / 2) * len * 0.3,
];
emit(
`M${n(base[0])} ${n(base[1])}Q${n(mid[0])} ${n(mid[1])} ${n(tip[0])} ${n(tip[1])}`,
'cs-sig-barb',
);
} else {
emit(`M${n(base[0])} ${n(base[1])}L${n(tip[0])} ${n(tip[1])}`, 'cs-sig-barb');
}
};
const ornament = (at: Pt, ang: number) => {
const g = pick(GLYPHS);
const s = rnd(0.45, 0.85);
const deg = (ang * 180) / Math.PI + rnd(-25, 25);
track(at[0] + g.w * s);
parts.push(
`<g transform="translate(${n(at[0])} ${n(at[1])}) rotate(${n(deg)}) scale(${n(s)})">` +
`<path class="cs-sig-orn" d="${g.d}" pathLength="1" style="--i:${strokeCount % 16}"/></g>`,
);
strokeCount++;
};
// Recursive limb: a curved sweep out from (ox,oy) along `ang`, hooking back,
// scattering barbs, shedding a filament shadow, branching, sometimes tipped
// with a motif.
const limb = (ox: number, oy: number, ang: number, scale: number, depth: number) => {
if (strokeCount >= MAX_PATHS) return;
const L = scale * rnd(34, 64);
const dx = Math.cos(ang) * L;
const dy = Math.sin(ang) * L;
const peak: Pt = [ox + dx, oy + dy];
const mid: Pt = [
ox + dx * 0.45 + Math.cos(ang + Math.PI / 2) * rnd(-10, 14),
oy + dy * 0.45 + Math.sin(ang + Math.PI / 2) * rnd(-10, 14),
];
// hook back toward the spine
const hook: Pt = [
peak[0] - Math.cos(ang) * L * rnd(0.3, 0.55),
peak[1] + Math.sin(ang + 0.7) * L * rnd(0.25, 0.5),
];
const tail: Pt = [Math.max(-2, hook[0] - L * rnd(0.2, 0.4)), hook[1] + rnd(-6, 10)];
const pts: Pt[] = [[ox, oy], mid, peak, hook, tail];
emit(spline(pts), 'cs-sig-main');
// terminal spike off the outermost point
barb(peak, ang + rnd(-0.5, 0.5), scale * rnd(8, 18));
// filament shadow trailing the main sweep
if (rng() < 0.4) {
const off = rnd(2, 6);
emit(
spline(
pts.map(
([x, y]) =>
[x + Math.cos(ang + Math.PI / 2) * off, y + Math.sin(ang + Math.PI / 2) * off] as Pt,
),
),
'cs-sig-fil',
);
}
// barb scatter along the chord
const nb = 1 + Math.floor(rng() * 2);
for (let k = 0; k < nb; k++) {
const t = (k + 1) / (nb + 1);
const seg = t < 0.5 ? [pts[0], pts[2], t * 2] : [pts[2], pts[4], (t - 0.5) * 2];
const a = seg[0] as Pt;
const b = seg[1] as Pt;
const tt = seg[2] as number;
const base: Pt = [a[0] + (b[0] - a[0]) * tt, a[1] + (b[1] - a[1]) * tt];
const side = k % 2 ? 1 : -1;
barb(base, ang + side * rnd(0.6, 1.3), scale * rnd(6, 16));
}
// recurse — one child curls off the mid/peak region
if (depth > 0 && rng() < 0.55) {
const from = rng() < 0.5 ? mid : peak;
limb(
from[0],
from[1],
ang + (rng() < 0.5 ? 1 : -1) * rnd(0.5, 1.2),
scale * rnd(0.42, 0.6),
depth - 1,
);
}
// motif flourish at a terminal tip
if (depth === 0 && rng() < 0.3) ornament(peak, ang);
};
// ── Wavering spine: a curve from top to bottom, gently bowing in +x.
const spineNodes = 5 + Math.floor(rng() * 3);
const spinePts: Pt[] = [];
for (let i = 0; i <= spineNodes; i++) {
const y = (H * i) / spineNodes;
const x = i === 0 || i === spineNodes ? 0 : rnd(0, 11);
spinePts.push([x, y]);
}
emit(spline(spinePts), 'cs-sig-main');
// ── Branch nodes ride the spine and throw limbs outward. Nodes are
// inset from the very ends and spread the full height so growth flows
// down the whole trunk rather than clumping at the top.
const nodes = opts.count ?? 7 + Math.floor(rng() * 3); // 79
for (let i = 0; i < nodes; i++) {
const t = 0.08 + (0.86 * (i + rnd(-0.25, 0.25))) / (nodes - 1);
const tc = Math.max(0.05, Math.min(0.95, t));
const si = Math.min(spinePts.length - 2, Math.floor(tc * (spinePts.length - 1)));
const sf = tc * (spinePts.length - 1) - si;
const a = spinePts[si];
const b = spinePts[si + 1];
const node: Pt = [a[0] + (b[0] - a[0]) * sf, a[1] + (b[1] - a[1]) * sf];
// later nodes lean downward so the lower trunk fills out
const bias = -0.25 + tc * 0.7;
const limbs = 1 + Math.floor(rng() * 2);
for (let l = 0; l < limbs; l++) {
const ang = bias + rnd(-0.55, 0.55);
limb(node[0], node[1], ang, rnd(0.65, 1.05), 1);
}
// the odd bare barb straight off the spine keeps the trunk prickly
if (rng() < 0.55) barb(node, bias + rnd(-0.3, 0.3), rnd(7, 14));
}
const half = parts.join('');
const minX = -(maxX + PAD);
const vbW = 2 * (maxX + PAD);
return (
`<svg class="cs-sigil" viewBox="${minX} ${minY} ${vbW} ${vbH}" ` +
`<svg class="cs-sigil" viewBox="${n(minX)} ${-PAD} ${n(vbW)} ${H + 2 * PAD}" ` +
`preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false" ` +
`xmlns="http://www.w3.org/2000/svg">` +
spine +
`<g class="cs-sig-half">${half}</g>` +
`<g class="cs-sig-half" transform="scale(-1 1)">${half}</g>` +
`</svg>`