@unoverse-platform/studio 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/screens/AtomsView.tsx +40 -2
  3. package/screens/ComponentsView.tsx +112 -21
  4. package/screens/NativeAppHost.tsx +8 -4
  5. package/screens/TemplatesView.tsx +51 -21
  6. package/screens/states.ts +27 -4
  7. package/vendor/sdk/appEngine.tsx +20 -4
  8. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -2
  9. package/vendor/sdk/lib/core/actions.ts +10 -6
  10. package/vendor/sdk/lib/core/client.ts +39 -12
  11. package/vendor/sdk/lib/core/index.ts +21 -0
  12. package/vendor/sdk/lib/core/refs.ts +117 -0
  13. package/vendor/sdk/lib/core/store.ts +65 -38
  14. package/vendor/sdk/lib/core/templates.ts +270 -0
  15. package/vendor/sdk/lib/core/types.ts +27 -7
  16. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +131 -0
  17. package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
  18. package/vendor/sdk/lib/orb/engine/core.ts +167 -0
  19. package/vendor/sdk/lib/orb/engine/lattice.ts +205 -0
  20. package/vendor/sdk/lib/orb/engine/morph.ts +138 -0
  21. package/vendor/sdk/lib/orb/engine/orbits.ts +80 -0
  22. package/vendor/sdk/lib/orb/engine/profiles.ts +162 -0
  23. package/vendor/sdk/lib/orb/engine/registry.ts +31 -0
  24. package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
  25. package/vendor/sdk/lib/orb/engine/rings.ts +239 -0
  26. package/vendor/sdk/lib/orb/engine/types.ts +14 -0
  27. package/vendor/sdk/lib/orb/engine/web.ts +98 -0
  28. package/vendor/sdk/lib/orb/index.ts +20 -0
  29. package/vendor/sdk/lib/orb/presets.ts +181 -0
  30. package/vendor/sdk/lib/orb/theme.ts +83 -0
  31. package/vendor/sdk/lib/orb/types.ts +84 -0
  32. package/vendor/sdk/lib/primitives.tsx +116 -2
  33. package/vendor/sdk/lib/render.tsx +32 -8
  34. package/vendor/sdk/lib/style.ts +6 -2
  35. package/vendor/sdk/lib/template.tsx +86 -224
  36. package/vendor/skills/unoverse-create/SKILL.md +18 -18
  37. package/vendor/skills/unoverse-create/references/agent-skill.md +12 -12
  38. package/vendor/skills/unoverse-create/references/block.md +5 -5
  39. package/vendor/skills/unoverse-create/references/component.md +150 -75
  40. package/vendor/skills/unoverse-create/references/node.md +1 -1
  41. package/vendor/skills/unoverse-create/references/template.md +142 -82
  42. package/vendor/skills/unoverse-create/references/workflow.md +15 -15
@@ -0,0 +1,205 @@
1
+ // The sphere-lattice modes: globe (searching), rubik (solving) and
2
+ // wave (listening). All draw a lat/long dot field with mode-specific
3
+ // motion, then hand off to the shared z-sorted painter.
4
+
5
+ import type { Dot, ModeDraw } from './types';
6
+ import { angleDelta, hashD, makeProj, paint, radiusScale } from './core';
7
+
8
+ // --- the shared solver heartbeat (rubik) ------------------------------
9
+ // Rapid eased moves scramble, then replay in reverse (palindrome) so
10
+ // everything clicks back to solved, rests, repeats.
11
+
12
+ interface Move {
13
+ axis: 0 | 1 | 2;
14
+ lo: number;
15
+ hi: number;
16
+ ang: number;
17
+ }
18
+
19
+ function solveCycle(time: number, count: number, slotDur: number, rest: number) {
20
+ const cyc = 2 * count * slotDur + rest;
21
+ const tc = time % cyc;
22
+ const amount = new Array<number>(count).fill(0);
23
+ let active = -1;
24
+ if (tc < 2 * count * slotDur) {
25
+ const slot = Math.floor(tc / slotDur);
26
+ const p = (tc - slot * slotDur) / slotDur;
27
+ const cl = Math.min(1, p / 0.7);
28
+ const ep = 1 - (1 - cl) ** 3; // machine ease-out
29
+ if (slot < count) {
30
+ for (let i = 0; i < slot; i++) amount[i] = 1;
31
+ amount[slot] = ep;
32
+ active = slot;
33
+ } else {
34
+ const u = 2 * count - 1 - slot;
35
+ for (let i = 0; i < u; i++) amount[i] = 1;
36
+ amount[u] = 1 - ep;
37
+ active = u;
38
+ }
39
+ }
40
+ return { amount, active };
41
+ }
42
+
43
+ function applyMoves(
44
+ pt3: [number, number, number],
45
+ moves: Move[],
46
+ sc: { amount: number[]; active: number }
47
+ ): [number, number, number, boolean] {
48
+ let [x, y, z] = pt3;
49
+ let inActive = false;
50
+ for (let i = 0; i < moves.length; i++) {
51
+ if (sc.amount[i] <= 0) continue;
52
+ const mv = moves[i];
53
+ const coord = mv.axis === 0 ? x : mv.axis === 1 ? y : z;
54
+ if (coord < mv.lo || coord >= mv.hi) continue;
55
+ if (i === sc.active) inActive = true;
56
+ const a = mv.ang * sc.amount[i];
57
+ const ca = Math.cos(a);
58
+ const sa = Math.sin(a);
59
+ if (mv.axis === 0) {
60
+ const y2 = y * ca - z * sa;
61
+ z = y * sa + z * ca;
62
+ y = y2;
63
+ } else if (mv.axis === 1) {
64
+ const x2 = x * ca + z * sa;
65
+ z = -x * sa + z * ca;
66
+ x = x2;
67
+ } else {
68
+ const x2 = x * ca - y * sa;
69
+ y = x * sa + y * ca;
70
+ x = x2;
71
+ }
72
+ }
73
+ return [x, y, z, inActive];
74
+ }
75
+
76
+ function makeMoves(count: number): Move[] {
77
+ const moves: Move[] = [];
78
+ for (let i = 0; i < count; i++) {
79
+ const axis = Math.min(2, Math.floor(hashD(i, 2.3) * 3)) as 0 | 1 | 2;
80
+ const lo = -1.0 + 0.5 * Math.min(3, Math.floor(hashD(i, 5.9) * 4));
81
+ const dir = hashD(i, 7.7) < 0.5 ? 1 : -1;
82
+ moves.push({ axis, lo, hi: lo + 0.5, ang: (dir * Math.PI) / 2 });
83
+ }
84
+ return moves;
85
+ }
86
+
87
+ // --- Globe: lat/long field, a scan meridian sweeps — searching --------
88
+
89
+ export const drawGlobe: ModeDraw = (ctx, size, t, dark, o) => {
90
+ const spin = 0.5;
91
+ const cx = size / 2;
92
+ const cy = size / 2;
93
+ const radius = (size / 2) * 0.82;
94
+ const tilt = 0.4 + 0.06 * Math.sin(t * 0.35);
95
+ const pt = makeProj(t * spin, tilt, cx, cy, radius);
96
+ // scan sweeps relative to the spin; scanMul scales that relative rate
97
+ const scan = t * (spin + (1.7 - spin) * (o.scanMul ?? 1));
98
+ const rs = radiusScale(size, o.rsPow ?? 0.6);
99
+ const dimBase = o.dimBase ?? 1;
100
+
101
+ const dots: Dot[] = [];
102
+ const latRings = o.latRings ?? 17;
103
+ const lonDensity = o.lonDensity ?? 44;
104
+ for (let li = 0; li <= latRings; li++) {
105
+ const lat = -Math.PI / 2 + (li / latRings) * Math.PI;
106
+ const cosLat = Math.cos(lat);
107
+ const sinLat = Math.sin(lat);
108
+ const lonCount = Math.max(1, Math.round(Math.abs(cosLat) * lonDensity));
109
+ for (let lj = 0; lj < lonCount; lj++) {
110
+ const lon = (lj / lonCount) * 2 * Math.PI;
111
+ const [px, py, z] = pt(cosLat * Math.cos(lon), sinLat, cosLat * Math.sin(lon));
112
+ const depth = (z + 1) / 2;
113
+ // the scan: a moving meridian read as a size ripple, not a shine
114
+ const d = angleDelta(lon + t * spin, scan);
115
+ const boost = Math.exp(-(d * d) / 0.18) * Math.max(0, z);
116
+ dots.push({
117
+ x: px,
118
+ y: py,
119
+ z,
120
+ r: ((o.rBase ?? 0.6) + (o.rDepth ?? 1.7) * depth + (o.rBoost ?? 1) * boost) * rs,
121
+ white: (o.inkFar ?? 0.62) - (o.inkSpan ?? 0.54) * depth,
122
+ // dimBase < 1 fades un-scanned dots so the meridian reads clearly
123
+ a: dimBase + (1 - dimBase) * Math.min(1, boost)
124
+ });
125
+ }
126
+ }
127
+ paint(ctx, dots, dark, o.rMin);
128
+ };
129
+
130
+ // --- Rubik: bands twist in quarter turns, scramble → solve — solving --
131
+
132
+ export const drawRubik: ModeDraw = (ctx, size, t, dark, o) => {
133
+ const cx = size / 2;
134
+ const cy = size / 2;
135
+ const R = (size / 2) * 0.82;
136
+ const pt = makeProj(t * 0.55, 0.35 + 0.1 * Math.sin(t * 0.9), cx, cy, R);
137
+ const rs = radiusScale(size, o.rsPow ?? 0.6);
138
+ const moveCount = o.moveCount ?? 14;
139
+ const moves = makeMoves(moveCount);
140
+ const sc = solveCycle(t, moveCount, 0.42, 1.2);
141
+
142
+ const dots: Dot[] = [];
143
+ const latRings = o.latRings ?? 15;
144
+ const lonDensity = o.lonDensity ?? 40;
145
+ for (let li = 0; li <= latRings; li++) {
146
+ const lat = -Math.PI / 2 + (li / latRings) * Math.PI;
147
+ const cosLat = Math.cos(lat);
148
+ const sinLat = Math.sin(lat);
149
+ const lonCount = Math.max(1, Math.round(Math.abs(cosLat) * lonDensity));
150
+ for (let lj = 0; lj < lonCount; lj++) {
151
+ const lon = (lj / lonCount) * 2 * Math.PI;
152
+ const [x, y, z, inActive] = applyMoves([cosLat * Math.cos(lon), sinLat, cosLat * Math.sin(lon)], moves, sc);
153
+ const [px, py, zr] = pt(x, y, z);
154
+ const depth = (zr + 1) / 2;
155
+ // the band being turned inks a touch darker — the "hand"
156
+ dots.push({
157
+ x: px,
158
+ y: py,
159
+ z: zr,
160
+ r: ((o.rBase ?? 0.6) + (o.rDepth ?? 1.7) * depth + (inActive ? (o.rActive ?? 0.3) : 0)) * rs,
161
+ white: (o.inkFar ?? 0.62) - (o.inkSpan ?? 0.54) * depth - (inActive ? 0.14 : 0)
162
+ });
163
+ }
164
+ }
165
+ paint(ctx, dots, dark, o.rMin);
166
+ };
167
+
168
+ // --- Wave: a waveform rolls through the rings — listening -------------
169
+
170
+ export const drawWave: ModeDraw = (ctx, size, t, dark, o) => {
171
+ const cx = size / 2;
172
+ const cy = size / 2;
173
+ // 0.76 base × 1.15 — the undulation pulls the sphere inward, so wave read
174
+ // ~15% smaller than the other lattice modes; scaled up to match them
175
+ const R = (size / 2) * 0.874;
176
+ const pt = makeProj(t * 0.18, 0.38, cx, cy, 1);
177
+ const rs = radiusScale(size, o.rsPow ?? 0.6);
178
+
179
+ const dots: Dot[] = [];
180
+ const rings = o.rings ?? 15;
181
+ const lonDensity = o.lonDensity ?? 40;
182
+ for (let ri = 0; ri <= rings; ri++) {
183
+ const lat = -Math.PI / 2 + (ri / rings) * Math.PI;
184
+ const cosLat = Math.cos(lat);
185
+ const sinLat = Math.sin(lat);
186
+ // two waves, different tempi — organic, never quite repeating
187
+ const w = 0.62 * Math.sin(t * 2.1 - ri * 0.52) + 0.38 * Math.sin(t * 1.27 + ri * 0.83);
188
+ const rr = R * (0.88 + 0.105 * w);
189
+ const lonCount = Math.max(1, Math.round(Math.abs(cosLat) * lonDensity));
190
+ for (let lj = 0; lj < lonCount; lj++) {
191
+ const lon = (lj / lonCount) * 2 * Math.PI;
192
+ const [px, py, z] = pt(cosLat * Math.cos(lon) * rr, sinLat * rr, cosLat * Math.sin(lon) * rr);
193
+ const depth = (z / R + 1) / 2;
194
+ const crest = Math.max(0, w);
195
+ dots.push({
196
+ x: px,
197
+ y: py,
198
+ z,
199
+ r: ((o.rBase ?? 0.6) + (o.rDepth ?? 1.7) * depth) * (1 + 0.4 * crest) * rs,
200
+ white: 0.66 - 0.56 * depth - 0.1 * crest
201
+ });
202
+ }
203
+ }
204
+ paint(ctx, dots, dark, o.rMin);
205
+ };
@@ -0,0 +1,138 @@
1
+ // Morph: a dotted outline cycling circle → triangle → square → circle —
2
+ // the "shaping" state. Each shape is a continuous closed path
3
+ // parameterised by arc length (top-centre start, clockwise). Every
4
+ // frame the engine blends the two neighbouring paths, then lays the
5
+ // dots EVENLY along the blended outline — spacing stays uniform at
6
+ // every instant of the morph, holds and transitions alike. Plain
7
+ // circle fills only: no canvas/SVG filters, fully cross-browser.
8
+
9
+ import type { Dot, ModeDraw } from './types';
10
+ import { paint } from './core';
11
+
12
+ type Path = (f: number) => [number, number];
13
+
14
+ function smoothE(x: number): number {
15
+ return x * x * (3 - 2 * x);
16
+ }
17
+
18
+ function polyPath(verts: ReadonlyArray<readonly [number, number]>): Path {
19
+ const V = verts.length;
20
+ const L: number[] = [];
21
+ let total = 0;
22
+ for (let i = 0; i < V; i++) {
23
+ const a = verts[i];
24
+ const b = verts[(i + 1) % V];
25
+ const l = Math.hypot(b[0] - a[0], b[1] - a[1]);
26
+ L.push(l);
27
+ total += l;
28
+ }
29
+ return (f) => {
30
+ let target = f * total;
31
+ let i = 0;
32
+ while (target > L[i] && i < V - 1) {
33
+ target -= L[i];
34
+ i++;
35
+ }
36
+ const a = verts[i];
37
+ const b = verts[(i + 1) % V];
38
+ const ff = L[i] ? Math.min(1, target / L[i]) : 0;
39
+ return [a[0] + (b[0] - a[0]) * ff, a[1] + (b[1] - a[1]) * ff];
40
+ };
41
+ }
42
+
43
+ const CIRCLE: Path = (f) => {
44
+ const a = -Math.PI / 2 + f * 2 * Math.PI;
45
+ return [Math.cos(a) * 0.24, Math.sin(a) * 0.24];
46
+ };
47
+ const TRIANGLE = polyPath([
48
+ [0.0, -0.26],
49
+ [0.24, 0.16],
50
+ [-0.24, 0.16]
51
+ ]);
52
+ // 5-vertex walk so the path STARTS at top-centre like the other shapes
53
+ const SQUARE = polyPath([
54
+ [0, -0.2],
55
+ [0.2, -0.2],
56
+ [0.2, 0.2],
57
+ [-0.2, 0.2],
58
+ [-0.2, -0.2]
59
+ ]);
60
+ const CYCLE: Path[] = [CIRCLE, TRIANGLE, SQUARE];
61
+
62
+ // low floor keeps sparse outlines possible while never degenerating
63
+ function morphN(d: number): number {
64
+ return Math.max(6, Math.round(34 * d));
65
+ }
66
+
67
+ const HOLD = 1.4;
68
+ const MORPH = 0.9;
69
+ const SEG = HOLD + MORPH;
70
+
71
+ // This state was tuned in inkform, which paints it through a blur +
72
+ // threshold "goo" filter; we draw plain circles instead, since `ctx.filter`
73
+ // and SVG filter refs are not safe to rely on across Chrome / Safari /
74
+ // Firefox. The dot GEOMETRY is identical either way — the threshold just
75
+ // yields a hard edge where a plain fill has an antialiased one, so these
76
+ // dots read a touch softer than inkform's. Don't "correct" for that by
77
+ // shrinking the radius: it makes the mark genuinely smaller than the tuning.
78
+
79
+ export const drawMorph: ModeDraw = (ctx, size, t, dark, o) => {
80
+ const K = CYCLE.length;
81
+ const tc = t % (SEG * K);
82
+ const k = Math.floor(tc / SEG);
83
+ const local = tc - k * SEG;
84
+ const m = local > HOLD ? smoothE((local - HOLD) / MORPH) : 0;
85
+ const sprd = o.spread ?? 1;
86
+
87
+ // blend the two shape PATHS at m, then measure the blended outline
88
+ const pA = CYCLE[k];
89
+ const pB = CYCLE[(k + 1) % K];
90
+ const M = 160;
91
+ const pts: Array<[number, number]> = [];
92
+ for (let i = 0; i < M; i++) {
93
+ const f = i / M;
94
+ const a = pA(f);
95
+ const b = pB(f);
96
+ pts.push([(a[0] + (b[0] - a[0]) * m) * sprd, (a[1] + (b[1] - a[1]) * m) * sprd]);
97
+ }
98
+ const L: number[] = [];
99
+ let total = 0;
100
+ for (let i = 0; i < M; i++) {
101
+ const a = pts[i];
102
+ const b = pts[(i + 1) % M];
103
+ const l = Math.hypot(b[0] - a[0], b[1] - a[1]);
104
+ L.push(l);
105
+ total += l;
106
+ }
107
+
108
+ // dot radius depends ONLY on rDot (the size knob); the count sets the
109
+ // gaps. Formed shapes breathe a little (uniform pulse).
110
+ const n = morphN(o.iconD ?? 1);
111
+ const re = (o.rDot ?? 0.021) * 1.35 * sprd;
112
+ const pulse = 1 + 0.02 * Math.sin(local * 3.1);
113
+
114
+ const dots: Dot[] = [];
115
+ const c2 = size / 2;
116
+ let seg = 0;
117
+ let acc = 0;
118
+ for (let k2 = 0; k2 < n; k2++) {
119
+ const target = (k2 / n) * total;
120
+ while (acc + L[seg] < target && seg < M - 1) {
121
+ acc += L[seg];
122
+ seg++;
123
+ }
124
+ const a = pts[seg];
125
+ const b = pts[(seg + 1) % M];
126
+ const f = L[seg] ? Math.min(1, (target - acc) / L[seg]) : 0;
127
+ const x = (a[0] + (b[0] - a[0]) * f) * pulse;
128
+ const y = (a[1] + (b[1] - a[1]) * f) * pulse;
129
+ dots.push({
130
+ x: c2 + x * size,
131
+ y: c2 + y * size,
132
+ z: 0,
133
+ r: Math.max(0.35, re * size),
134
+ white: 0.1
135
+ });
136
+ }
137
+ paint(ctx, dots, dark, o.rMin);
138
+ };
@@ -0,0 +1,80 @@
1
+ // Orbits: particles on tilted orbits — the "working" state. No nucleus
2
+ // (the tuned preset runs coreless): just ghost paths and the particles
3
+ // doing the work.
4
+
5
+ import type { Dot, ModeDraw } from './types';
6
+ import { hashD, makeProj, paint, radiusScale } from './core';
7
+
8
+ export const drawOrbits: ModeDraw = (ctx, size, t, dark, o) => {
9
+ const cx = size / 2;
10
+ const cy = size / 2;
11
+ const R = (size / 2) * 0.82;
12
+ const pt = makeProj(t * 0.12, 0.3, cx, cy, 1);
13
+ const rs = radiusScale(size, o.rsPow ?? 0.6);
14
+
15
+ const dots: Dot[] = [];
16
+ const orbitN = o.orbitN ?? 12;
17
+ const ghostN = o.ghostN ?? 40;
18
+ const particles = o.particles ?? 3;
19
+
20
+ // orbits: each a tilted circle — a ghost path + running particles
21
+ for (let orb = 0; orb < orbitN; orb++) {
22
+ const h1 = hashD(orb, 1.7);
23
+ const h2 = hashD(orb, 5.2);
24
+ const h3 = hashD(orb, 8.9);
25
+ const ro = R * (0.45 + 0.52 * h1);
26
+ const th = h1 * 2 * Math.PI;
27
+ const phi = Math.acos(2 * h2 - 1);
28
+ // orbit plane basis (u, v ⟂ normal n)
29
+ const nx = Math.sin(phi) * Math.cos(th);
30
+ const ny = Math.cos(phi);
31
+ const nz = Math.sin(phi) * Math.sin(th);
32
+ let ux = -ny;
33
+ let uy = nx;
34
+ const uz = 0;
35
+ const ul = Math.max(1e-6, Math.sqrt(ux * ux + uy * uy));
36
+ ux /= ul;
37
+ uy /= ul;
38
+ const vx = ny * uz - nz * uy;
39
+ const vy = nz * ux - nx * uz;
40
+ const vz = nx * uy - ny * ux;
41
+ const speed = (0.25 + 0.55 * h3) * (h3 > 0.5 ? 1 : -1);
42
+
43
+ // ghost path
44
+ for (let k = 0; k < ghostN; k++) {
45
+ const a = (k / ghostN) * 2 * Math.PI;
46
+ const [px, py, z] = pt(
47
+ (ux * Math.cos(a) + vx * Math.sin(a)) * ro,
48
+ (uy * Math.cos(a) + vy * Math.sin(a)) * ro,
49
+ (uz * Math.cos(a) + vz * Math.sin(a)) * ro
50
+ );
51
+ const depth = (z / ro + 1) / 2;
52
+ dots.push({
53
+ x: px,
54
+ y: py,
55
+ z,
56
+ r: (o.ghostR ?? 0.9) * rs,
57
+ white: 0.72,
58
+ a: (o.ghostA ?? 0.5) * (0.4 + 0.6 * depth)
59
+ });
60
+ }
61
+ // the particles doing the work
62
+ for (let m = 0; m < particles; m++) {
63
+ const a = t * speed + (m / particles) * 2 * Math.PI + h2 * 6;
64
+ const [px, py, z] = pt(
65
+ (ux * Math.cos(a) + vx * Math.sin(a)) * ro,
66
+ (uy * Math.cos(a) + vy * Math.sin(a)) * ro,
67
+ (uz * Math.cos(a) + vz * Math.sin(a)) * ro
68
+ );
69
+ const depth = (z / ro + 1) / 2;
70
+ dots.push({
71
+ x: px,
72
+ y: py,
73
+ z,
74
+ r: ((o.partR ?? 1.2) + (o.partRDepth ?? 1.6) * depth) * rs,
75
+ white: 0.3 - 0.22 * depth
76
+ });
77
+ }
78
+ }
79
+ paint(ctx, dots, dark, o.rMin);
80
+ };
@@ -0,0 +1,162 @@
1
+ // Density profiles + the multiplier machinery that scales them. The base
2
+ // rows are inkform's `fine` profiles; each shipped preset (state × size)
3
+ // applies count / radius multipliers on top, resolved once per mount.
4
+
5
+ export interface ModeOpts {
6
+ [key: string]: number | undefined;
7
+ }
8
+
9
+ // 2-D lattices (rings × dots-per-ring) come in pairs — each side takes
10
+ // √scale so the TOTAL dot count scales by `scale`; flat lists scale
11
+ // linearly. `iconD` sets the morph outline's sampling density.
12
+ const COUNT_PAIRS: ReadonlyArray<readonly [string, string]> = [
13
+ ['latRings', 'lonDensity'],
14
+ ['rings', 'lonDensity'],
15
+ ['lanes', 'segs']
16
+ ];
17
+ const COUNT_KEYS = ['orbitN', 'ghostN', 'nodeN', 'strandN', 'signals'] as const;
18
+ const ICON_DENSITY_KEYS = ['iconD'] as const;
19
+
20
+ // Every key that sets a dot's rendered radius — scaling all of them keeps
21
+ // a dot's near/far falloff intact while shrinking or growing the mark.
22
+ const RADIUS_KEYS = [
23
+ 'rBase',
24
+ 'rDepth',
25
+ 'rActive',
26
+ 'rDot',
27
+ 'ghostR',
28
+ 'partR',
29
+ 'partRDepth',
30
+ 'nodeR',
31
+ 'nodeRDepth'
32
+ ] as const;
33
+
34
+ export function scaleCounts(opts: ModeOpts, scale: number): ModeOpts {
35
+ const out: ModeOpts = { ...opts };
36
+ const done = new Set<string>();
37
+ const rt = Math.sqrt(scale);
38
+ for (const [a, b] of COUNT_PAIRS) {
39
+ const va = out[a];
40
+ const vb = out[b];
41
+ if (va != null && vb != null && !done.has(a) && !done.has(b)) {
42
+ out[a] = Math.max(2, Math.round(va * rt));
43
+ out[b] = Math.max(2, Math.round(vb * rt));
44
+ done.add(a);
45
+ done.add(b);
46
+ }
47
+ }
48
+ for (const k of COUNT_KEYS) {
49
+ const v = out[k];
50
+ // 0 means the mode opted out of that layer entirely (ring has no ghost
51
+ // sphere) — scaling must not resurrect it as a single stray dot
52
+ if (v != null && v !== 0 && !done.has(k)) out[k] = Math.max(1, Math.round(v * scale));
53
+ }
54
+ for (const k of ICON_DENSITY_KEYS) {
55
+ const v = out[k];
56
+ if (v != null) out[k] = Math.max(0.02, v * scale);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ export function scaleRadii(opts: ModeOpts, scale: number): ModeOpts {
62
+ const out: ModeOpts = { ...opts };
63
+ for (const k of RADIUS_KEYS) {
64
+ const v = out[k];
65
+ if (v != null) out[k] = v * scale;
66
+ }
67
+ // remember the multiplier itself — spacing-derived radii (the morph
68
+ // outline) use it, since they aren't based on any single radius key
69
+ out.rSizeMul = (out.rSizeMul ?? 1) * scale;
70
+ return out;
71
+ }
72
+
73
+ /** Base (fine) profiles per mode, before preset multipliers. */
74
+ export const BASE_PROFILES: Record<string, ModeOpts> = {
75
+ globe: {
76
+ latRings: 17,
77
+ lonDensity: 44,
78
+ rBase: 0.6,
79
+ rDepth: 1.7,
80
+ rBoost: 1.0,
81
+ inkFar: 0.62,
82
+ inkSpan: 0.54,
83
+ rsPow: 0.6,
84
+ rMin: 0.3
85
+ },
86
+ orbits: {
87
+ orbitN: 12,
88
+ ghostN: 40,
89
+ ghostR: 0.9,
90
+ ghostA: 0.5,
91
+ particles: 3,
92
+ partR: 1.2,
93
+ partRDepth: 1.6,
94
+ rsPow: 0.6,
95
+ rMin: 0.3
96
+ },
97
+ rubik: {
98
+ latRings: 15,
99
+ lonDensity: 40,
100
+ moveCount: 14,
101
+ rBase: 0.6,
102
+ rDepth: 1.7,
103
+ rActive: 0.3,
104
+ inkFar: 0.62,
105
+ inkSpan: 0.54,
106
+ rsPow: 0.6,
107
+ rMin: 0.3
108
+ },
109
+ wave: {
110
+ rings: 15,
111
+ lonDensity: 40,
112
+ rBase: 0.6,
113
+ rDepth: 1.7,
114
+ rsPow: 0.6,
115
+ rMin: 0.3
116
+ },
117
+ web: {
118
+ nodeN: 30,
119
+ thr: 0.72,
120
+ signals: 5,
121
+ nodeR: 1.4,
122
+ nodeRDepth: 1.8,
123
+ lineW: 0.8,
124
+ rsPow: 0.6,
125
+ rMin: 0.3
126
+ },
127
+ braid: {
128
+ strandN: 52,
129
+ turns: 3.0,
130
+ ghostN: 150,
131
+ rBase: 1.2,
132
+ rDepth: 1.8,
133
+ rsPow: 0.6,
134
+ rMin: 0.3
135
+ },
136
+ ribbon: {
137
+ lanes: 5,
138
+ segs: 88,
139
+ ghostN: 150,
140
+ rBase: 1.1,
141
+ rDepth: 1.7,
142
+ rsPow: 0.6,
143
+ rMin: 0.3
144
+ },
145
+ // ring shares ribbon's painter; faceOn cancels the camera tilt and moves
146
+ // the undulation onto the radius, and there is no ghost sphere behind it
147
+ ring: {
148
+ lanes: 5,
149
+ segs: 88,
150
+ ghostN: 0,
151
+ faceOn: 1,
152
+ rBase: 1.1,
153
+ rDepth: 1.7,
154
+ rsPow: 0.6,
155
+ rMin: 0.3
156
+ },
157
+ morph: {
158
+ rDot: 0.021,
159
+ iconD: 1,
160
+ rMin: 0.25
161
+ }
162
+ };
@@ -0,0 +1,31 @@
1
+ // Mode key → frame painter. Kept separate from the presets so tree
2
+ // shaking can in principle drop unused modes in custom builds.
3
+
4
+ import type { ModeKey } from '../presets';
5
+ import type { ModeDraw } from './types';
6
+ import { drawBraid } from './braid';
7
+ import { drawGlobe, drawRubik, drawWave } from './lattice';
8
+ import { drawMorph } from './morph';
9
+ import { drawOrbits } from './orbits';
10
+ import { drawRibbon } from './ribbon';
11
+ import { drawWeb } from './web';
12
+ // LOCAL (Unoverse): the annular modes — see ./rings.
13
+ import { drawHalo, drawRipple, drawShimmer } from './rings';
14
+
15
+ export const MODE_DRAWS: Record<ModeKey, ModeDraw> = {
16
+ halo: drawHalo,
17
+ sonar: drawRipple,
18
+ // pulse shares ripple's painter — the `dir` profile flag mirrors it outward
19
+ pulse: drawRipple,
20
+ shimmer: drawShimmer,
21
+ orbits: drawOrbits,
22
+ globe: drawGlobe,
23
+ rubik: drawRubik,
24
+ wave: drawWave,
25
+ web: drawWeb,
26
+ braid: drawBraid,
27
+ ribbon: drawRibbon,
28
+ // ring shares ribbon's painter — the `faceOn` profile flag switches it
29
+ ring: drawRibbon,
30
+ morph: drawMorph
31
+ };