q
File size: 23,741 Bytes
89dc30e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63158fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89dc30e
63158fe
89dc30e
63158fe
 
89dc30e
63158fe
 
 
89dc30e
63158fe
89dc30e
63158fe
89dc30e
63158fe
 
 
 
 
89dc30e
63158fe
 
89dc30e
63158fe
 
 
 
 
 
 
 
 
89dc30e
63158fe
89dc30e
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
// holo-orb.js β€” THE canonical living Q orb (shared by the standalone Q chat and the messenger; the messenger's
// holo-q-orb-live.mjs re-exports this file). mountOrb(canvas, opts?) β†’ { stop, fallback, mode, ... } β€” the API is
// a superset of the original 54-line WebGL orb (opts optional, {stop,fallback} preserved), so existing callers
// are unaffected while every surface gains the enhanced renderer. Original preserved as holo-orb.js.pre-converge.bak.
//
// PRIMARY: a NATIVE-WebGPU raymarched volume rendered on a dedicated Web Worker via OffscreenCanvas. The render
// loop lives on the worker, PHYSICALLY off the main thread, so the orb stays glass-smooth and NEVER freezes even
// while the host's main thread is busy (React, Q inference, message churn). It binds the real GPU adapter
// (powerPreference:"high-performance", no fallback) β†’ 100% native WebGPU, and renders at native DPR Γ— SSAA for a
// crisp, hyper-real look. Fully self-contained: the worker is spawned from a Blob URL with inline WGSL β€” no deps,
// no import map, no extra files to serve, so it works in every environment (dev SPA and the real app alike).
//
// FALLBACK: the original self-contained WebGL2 wireframe icosphere (kept verbatim below), then a CSS/SVG orb.
// The gate is fail-closed and probe-before-transfer: the worker confirms a GPU adapter BEFORE the canvas is
// transferred, so a probe failure leaves the canvas reusable for the WebGL2 floor.
//
//   mountOrb(canvas) β†’ { stop(), fallback:boolean, mode }

// ─────────────────────────────────────────────────────────────────────────────────────────────────────────
// WGSL β€” fullscreen-triangle vertex + a raymarched, noise-displaced SDF sphere with the OS brand spectrum
// (OKLAB-interpolated), a triangular lattice shell, thin-film iridescence, an inner living nebula, ACES filmic
// tonemap and temporal dither. Idle-animated (breath + spin + shimmer) so it's alive at rest with ZERO main-
// thread involvement. Adapted from the OS orb (usr/lib/holo/voice/holo-voice-orb-gpu.mjs).
// ─────────────────────────────────────────────────────────────────────────────────────────────────────────
const WGSL = `
struct U { res: vec2f, time: f32, lvl: f32 };
@group(0) @binding(0) var<uniform> u: U;

const STOPS = array<vec3f, 8>(
  vec3f(1.0, 0.231, 0.42), vec3f(1.0, 0.62, 0.173), vec3f(1.0, 0.886, 0.29), vec3f(0.275, 0.878, 0.541),
  vec3f(0.169, 0.831, 1.0), vec3f(0.357, 0.549, 1.0), vec3f(0.78, 0.482, 1.0), vec3f(1.0, 0.231, 0.42));

@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
  var p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
  return vec4f(p[vi], 0.0, 1.0);
}
fn hash(p3i: vec3f) -> f32 { var p3 = fract(p3i * 0.1031); p3 = p3 + dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
fn vnoise(x: vec3f) -> f32 {
  let i = floor(x); let f = fract(x); let w = f * f * (3.0 - 2.0 * f);
  let n000 = hash(i + vec3f(0.0,0.0,0.0)); let n100 = hash(i + vec3f(1.0,0.0,0.0));
  let n010 = hash(i + vec3f(0.0,1.0,0.0)); let n110 = hash(i + vec3f(1.0,1.0,0.0));
  let n001 = hash(i + vec3f(0.0,0.0,1.0)); let n101 = hash(i + vec3f(1.0,0.0,1.0));
  let n011 = hash(i + vec3f(0.0,1.0,1.0)); let n111 = hash(i + vec3f(1.0,1.0,1.0));
  let x00 = mix(n000,n100,w.x); let x10 = mix(n010,n110,w.x); let x01 = mix(n001,n101,w.x); let x11 = mix(n011,n111,w.x);
  return mix(mix(x00,x10,w.y), mix(x01,x11,w.y), w.z) * 2.0 - 1.0;
}
fn fbm(p0: vec3f) -> f32 { var p = p0; var a = 0.5; var s = 0.0; for (var i = 0; i < 5; i = i + 1) { s = s + a * vnoise(p); p = p * 1.9; a = a * 0.5; } return s; }
fn srgb2lin(c: vec3f) -> vec3f { return select(c/12.92, pow((c+0.055)/1.055, vec3f(2.4)), c > vec3f(0.04045)); }
fn lin2srgb(c: vec3f) -> vec3f { let x = max(c, vec3f(0.0)); return select(x*12.92, 1.055*pow(x, vec3f(1.0/2.4))-0.055, x > vec3f(0.0031308)); }
fn lin2oklab(c: vec3f) -> vec3f {
  let l = 0.4122214708*c.r + 0.5363325363*c.g + 0.0514459929*c.b;
  let m = 0.2119034982*c.r + 0.6806995451*c.g + 0.1073969566*c.b;
  let s = 0.0883024619*c.r + 0.2817188376*c.g + 0.6299787005*c.b;
  let l_ = pow(max(l,0.0),1.0/3.0); let m_ = pow(max(m,0.0),1.0/3.0); let s_ = pow(max(s,0.0),1.0/3.0);
  return vec3f(0.2104542553*l_+0.7936177850*m_-0.0040720468*s_, 1.9779984951*l_-2.4285922050*m_+0.4505937099*s_, 0.0259040371*l_+0.7827717662*m_-0.8086757660*s_);
}
fn oklab2srgb(c: vec3f) -> vec3f {
  let l_ = c.x+0.3963377774*c.y+0.2158037573*c.z; let m_ = c.x-0.1055613458*c.y-0.0638541728*c.z; let s_ = c.x-0.0894841775*c.y-1.2914855480*c.z;
  let l = l_*l_*l_; let m = m_*m_*m_; let s = s_*s_*s_;
  let lin = vec3f(4.0767416621*l-3.3077115913*m+0.2309699292*s, -1.2684380046*l+2.6097574011*m-0.3413193965*s, -0.0041960863*l-0.7034186147*m+1.7076147010*s);
  return lin2srgb(lin);
}
fn spec(t0: f32) -> vec3f { let t = fract(t0) * 7.0; let k = clamp(i32(floor(t)), 0, 6); let a = lin2oklab(srgb2lin(STOPS[k])); let b = lin2oklab(srgb2lin(STOPS[k+1])); return oklab2srgb(mix(a, b, fract(t))); }
fn sdf(p: vec3f) -> f32 {
  let R = 0.82 + u.lvl * 0.05 + sin(u.time * 0.6) * 0.012;
  let warp = vec3f(u.time*0.05, u.time*0.06, u.time*0.07);
  let disp = fbm(p * 1.7 + warp) * (0.07 + u.lvl * 0.22);
  return length(p) - R - disp;
}
fn nrm(p: vec3f) -> vec3f { let e = vec2f(0.0012, 0.0); return normalize(vec3f(sdf(p+e.xyy)-sdf(p-e.xyy), sdf(p+e.yxy)-sdf(p-e.yxy), sdf(p+e.yyx)-sdf(p-e.yyx))); }
fn gline(x: f32) -> f32 { return smoothstep(0.42, 0.5, abs(fract(x) - 0.5)); }
fn ign(p: vec2f) -> f32 { return fract(52.9829189 * fract(dot(p, vec2f(0.06711056, 0.00583715)))); }
fn aces(x: vec3f) -> vec3f { let a=2.51; let b=0.03; let c=2.43; let d=0.59; let e=0.14; return clamp((x*(a*x+b))/(x*(c*x+d)+e), vec3f(0.0), vec3f(1.0)); }

@fragment fn fs(@builtin(position) fc: vec4f) -> @location(0) vec4f {
  let uv = (fc.xy - 0.5 * u.res) / u.res.y;
  let ro = vec3f(0.0, 0.0, 3.6);
  let rd = normalize(vec3f(uv.x, -uv.y, -1.5));
  let spin = u.time / 7.0 * (1.0 + u.lvl * 1.4);
  var t = 0.0; var glow = 0.0; var neb = 0.0; var hit = false; var hp = vec3f(0.0);
  var omega = 1.2; var prevD = 1e9; var stepLen = 0.0;
  for (var i = 0; i < 96; i = i + 1) {
    let p = ro + rd * t; let d = sdf(p);
    if (omega > 1.0 && (d + prevD) < stepLen) { t = t - stepLen; omega = 1.0; prevD = 1e9; continue; }
    prevD = d;
    glow = glow + 0.012 / (1.0 + d * d * 42.0);
    if (d < 0.0) { neb = neb + (0.5 + 0.5 * fbm(p * 2.7 + vec3f(u.time * 0.09))) * 0.05; }
    if (d < 0.0015) { hit = true; hp = p; break; }
    stepLen = max(d * omega, 0.004); t = t + stepLen;
    if (t > 6.0) { break; }
  }
  var col = vec3f(0.0); var alpha = 0.0;
  if (hit) {
    let n = nrm(hp);
    let lon = atan2(n.x, n.z) / 6.2831853 + 0.5;
    let lat = acos(clamp(n.y, -1.0, 1.0)) / 3.14159265;
    let hue = lon + spin + 0.18 * n.y;
    let base = spec(hue);
    let fres = pow(1.0 - max(dot(n, -rd), 0.0), 2.5);
    let ld = normalize(vec3f(-0.4, 0.7, 0.5));
    let diff = 0.5 + 0.5 * max(dot(n, ld), 0.0);
    let irid = spec(hue + fres * 0.30);
    let bodyHue = mix(base, irid, fres * 0.45);
    let A = lon * 18.0; let B = lat * 11.0; let pf = sin(lat * 3.14159265);
    let g = max(gline(B), max(gline(A + B * 0.5), gline(A - B * 0.5))) * pf;
    let face = bodyHue * (0.28 + 0.30 * diff);
    let dofs = 0.020 * (0.5 + fres);
    let edgeRGB = vec3f(spec(hue - dofs).r, spec(hue).g, spec(hue + dofs).b);
    let edge = (edgeRGB * 1.7 + vec3f(0.22, 0.22, 0.32)) * (0.7 + 0.6 * fres);
    col = mix(face, edge, g) + bodyHue * fres * 0.55;
    col = col * (0.9 + u.lvl * 0.45);
    alpha = max(g, 0.34 + 0.45 * fres);
  }
  let ncol = spec(spin + 0.55 + neb);
  col = col + ncol * neb * (0.7 + u.lvl * 0.9);
  let gcol = spec(spin + 0.25);
  col = col + gcol * glow * (0.6 + u.lvl * 1.0);
  alpha = max(alpha, clamp((glow + neb * 0.6) * 1.2, 0.0, 1.0));
  col = aces(col * 1.18);
  col = col + (ign(fc.xy + u.time * 60.0) - 0.5) * (1.5 / 255.0);
  col = clamp(col, vec3f(0.0), vec3f(1.0));
  return vec4f(col * alpha, alpha);
}`;

// ── the worker body (classic worker, spawned from a Blob URL). WGSL is injected as a JS string literal so the
// whole thing is self-contained β€” nothing extra is fetched, so it runs in any serve environment. ──
const WORKER_BODY = [
  "const WGSL = __WGSL__;",
  "let dev=null, ctx=null, pipeline=null, bind=null, ubuf=null, uf=null, raf=0, running=false, dead=false, canvas=null;",
  "let dpr=1, ss=1, cssW=64, cssH=64;",
  "let mode=0, extLevel=-1, cur=0.15, dbg=false, fc=0;",   // mode: 0 idle Β· 1 listening Β· 2 thinking Β· 3 speaking. cur = eased base level; extLevel = live speech amplitude (0..1) or -1. dbg = optional level readback.
  "const NOW=function(){return (typeof performance!=='undefined')?performance.now():Date.now();};",
  "const RAF=(typeof requestAnimationFrame==='function')?requestAnimationFrame:function(f){return setTimeout(function(){f(NOW());},16);};",
  "const CAF=(typeof cancelAnimationFrame==='function')?cancelAnimationFrame:clearTimeout;",
  "function resize(){var w=Math.max(1,Math.round(cssW*dpr*ss)),h=Math.max(1,Math.round(cssH*dpr*ss)); if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;}}",
  // Q-state-reactive level: idle = calm breath; listening = alert; thinking = brighter + faster (higher base +
  // quicker oscillation β†’ spin & glow rise, they scale with lvl in the WGSL); speaking = pulse to live amplitude
  // (extLevel) or a synthetic speech cadence. cur eases between modes so transitions read as intent, not a snap.",
  "function frame(){ if(!running||dead) return; var t=NOW()/1000; var base, osc;",
  "  if(mode===2){ base=0.50; osc=0.12*Math.sin(t*3.4); }",
  "  else if(mode===1){ base=0.24; osc=0.05*Math.sin(t*2.0); }",
  "  else if(mode===3){ base=0.32; osc=(extLevel>=0?0.0:0.30*Math.abs(Math.sin(t*6.5))); }",
  "  else { base=0.15; osc=0.10*Math.sin(t*1.1); }",
  "  var live=(extLevel>=0&&(mode===1||mode===3))?extLevel*0.6:0.0;",   // REAL audio amplitude swells the orb (listening to you Β· speaking as Q)
  "  cur+=(base-cur)*0.06; var lvl=Math.max(0.0, cur+osc+live);",
  "  if(dbg&&((fc++%15)===0)){ try{ self.postMessage({t:'lvl', v:lvl, mode:mode}); }catch(e){} }",
  "  uf[0]=canvas.width; uf[1]=canvas.height; uf[2]=t; uf[3]=lvl; dev.queue.writeBuffer(ubuf,0,uf); var view; try{ view=ctx.getCurrentTexture().createView(); }catch(e){ raf=RAF(frame); return; } var enc=dev.createCommandEncoder(); var pass=enc.beginRenderPass({colorAttachments:[{view:view,clearValue:{r:0,g:0,b:0,a:0},loadOp:'clear',storeOp:'store'}]}); pass.setPipeline(pipeline); pass.setBindGroup(0,bind); pass.draw(3); pass.end(); dev.queue.submit([enc.finish()]); raf=RAF(frame); }",
  "self.onmessage=async function(e){ var m=e.data||{}; try{",
  "  if(m.t==='probe'){ try{ if(!navigator.gpu) throw new Error('no gpu'); var a=await navigator.gpu.requestAdapter({powerPreference:'high-performance'}); if(!a) throw new Error('no adapter'); var d=await a.requestDevice(); if(d.destroy) d.destroy(); self.postMessage({t:'probe-ok'}); }catch(err){ self.postMessage({t:'fail',err:'probe: '+String(err&&err.message||err)}); } return; }",
  "  if(m.t==='init'){ try{",
  "    canvas=m.canvas; dpr=m.dpr||1; ss=m.ss||1; cssW=m.cssW||64; cssH=m.cssH||64; dbg=!!m.debug;",
  "    var a=await navigator.gpu.requestAdapter({powerPreference:'high-performance'}); if(!a) throw new Error('no adapter'); dev=await a.requestDevice(); if(dev.lost) dev.lost.then(function(){dead=true;});",
  "    ctx=canvas.getContext('webgpu'); if(!ctx) throw new Error('no webgpu ctx'); var fmt=navigator.gpu.getPreferredCanvasFormat(); ctx.configure({device:dev,format:fmt,alphaMode:'premultiplied'});",
  "    uf=new Float32Array(4); ubuf=dev.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});",
  "    dev.pushErrorScope('validation'); var mod=dev.createShaderModule({code:WGSL});",
  "    pipeline=dev.createRenderPipeline({layout:'auto',vertex:{module:mod,entryPoint:'vs'},fragment:{module:mod,entryPoint:'fs',targets:[{format:fmt,blend:{color:{srcFactor:'one',dstFactor:'one-minus-src-alpha'},alpha:{srcFactor:'one',dstFactor:'one-minus-src-alpha'}}}]},primitive:{topology:'triangle-list'}});",
  "    var perr=await dev.popErrorScope(); if(perr) throw new Error('wgsl: '+perr.message);",
  "    bind=dev.createBindGroup({layout:pipeline.getBindGroupLayout(0),entries:[{binding:0,resource:{buffer:ubuf}}]});",
  "    resize(); running=true; raf=RAF(frame); self.postMessage({t:'ready'});",
  "  }catch(err){ self.postMessage({t:'fail',err:String(err&&err.message||err)}); } return; }",
  "  if(!canvas) return;",
  "  if(m.t==='size'){ cssW=m.cssW||cssW; cssH=m.cssH||cssH; dpr=m.dpr||dpr; resize(); }",
  "  else if(m.t==='sig'){ mode=m.mode||0; extLevel=(typeof m.level==='number'?m.level:-1); }",
  "  else if(m.t==='stop'){ running=false; if(raf){ CAF(raf); raf=0; } try{ if(dev&&dev.destroy) dev.destroy(); }catch(e){} }",
  "}catch(err){ try{ self.postMessage({t:'fail',err:String(err&&err.message||err)}); }catch(e2){} } };",
].join("\n");

const WORKER_SRC = WORKER_BODY.replace("__WGSL__", JSON.stringify(WGSL));

// mount the WebGPU worker orb. Returns a handle (sync) that upgrades async; on any failure it falls back to the
// WebGL2 wireframe on the SAME canvas (probe-before-transfer keeps the canvas reusable). Returns null only when
// the platform can't do OffscreenCanvas/Worker/WebGPU at all β†’ caller uses WebGL2 directly.
function mountGpuOrb(canvas, opts) {
  opts = opts || {};
  const ok = typeof navigator !== "undefined" && navigator.gpu &&
    typeof OffscreenCanvas !== "undefined" && typeof Worker !== "undefined" &&
    canvas && canvas.transferControlToOffscreen;
  if (!ok) return null;

  let worker = null, url = null, stopped = false, transferred = false, fellBack = null, timer = 0, ro = null, pendingSig = null, onQState = null;
  const handle = { fallback: false, mode: "webgpu-worker",
    stop() {
      stopped = true; if (timer) { clearTimeout(timer); timer = 0; }
      try { if (ro) ro.disconnect(); } catch (e) {}
      try { if (typeof window !== "undefined" && onQState) window.removeEventListener("holo-q-state", onQState); } catch (e) {}
      try { if (worker) { worker.postMessage({ t: "stop" }); worker.terminate(); } } catch (e) {}
      try { if (url) URL.revokeObjectURL(url); } catch (e) {}
      if (fellBack && fellBack.stop) { try { fellBack.stop(); } catch (e) {} }
    } };

  function toWebgl() {
    if (timer) { clearTimeout(timer); timer = 0; }
    try { if (worker) worker.terminate(); } catch (e) {}
    if (stopped || transferred || fellBack) return;   // transferred β†’ canvas burned, can't reuse (rare, post-probe only)
    fellBack = mountWebglOrb(canvas);                  // same untouched canvas β†’ the proven WebGL2 wireframe
    handle.mode = (fellBack && !fellBack.fallback) ? "webgl" : "none";
  }

  try { url = URL.createObjectURL(new Blob([WORKER_SRC], { type: "text/javascript" })); worker = new Worker(url); }
  catch (e) { return mountWebglOrb(canvas); }

  const dpr = () => Math.min((typeof window !== "undefined" && window.devicePixelRatio) || 1, 2);
  const cw = () => canvas.clientWidth || 64, ch = () => canvas.clientHeight || 64;

  worker.onmessage = (e) => {
    const m = e.data || {};
    if (m.t === "probe-ok") {
      if (stopped) return;
      let off; try { off = canvas.transferControlToOffscreen(); transferred = true; }
      catch (err) { toWebgl(); return; }
      try { worker.postMessage({ t: "init", canvas: off, dpr: dpr(), ss: 1.5, cssW: cw(), cssH: ch(), debug: !!opts.debug }, [off]); }
      catch (err) { toWebgl(); }
    } else if (m.t === "ready") { if (timer) { clearTimeout(timer); timer = 0; } if (pendingSig) { forwardSig(pendingSig.mode, pendingSig.level); pendingSig = null; } }   // painting on the worker
    else if (m.t === "lvl") { handle.level = m.v; handle.stateMode = m.mode; if (typeof handle.onLevel === "function") { try { handle.onLevel(m.v, m.mode); } catch (e) {} } }   // optional debug readback (opts.debug)
    else if (m.t === "fail") { toWebgl(); }
  };
  worker.onerror = () => toWebgl();
  timer = setTimeout(toWebgl, 4500);
  try { worker.postMessage({ t: "probe" }); } catch (e) { toWebgl(); }

  if (typeof ResizeObserver !== "undefined") {
    ro = new ResizeObserver(() => { if (transferred && worker && !stopped) { try { worker.postMessage({ t: "size", cssW: cw(), cssH: ch(), dpr: dpr() }); } catch (e) {} } });
    try { ro.observe(canvas); } catch (e) {}
  }

  // ── Q-state reactivity: forward the global `holo-q-state` events to the worker (the messenger dispatches them
  // when Q is thinking/listening/speaking), so the orb visibly REACTS instead of only idling. Buffered until the
  // worker is live (pendingSig), and torn down with the orb (stop() removes the listener). ──
  const MODE_MAP = { idle: 0, listening: 1, thinking: 2, speaking: 3 };
  function forwardSig(modeNum, level) {
    if (worker && transferred && !stopped) { try { worker.postMessage({ t: "sig", mode: modeNum, level: level }); } catch (e) {} }
    else pendingSig = { mode: modeNum, level: level };
  }
  handle.signal = function (s) { s = s || {}; forwardSig(MODE_MAP[s.mode] || 0, (typeof s.level === "number") ? s.level : -1); };
  onQState = (e) => handle.signal((e && e.detail) || {});
  if (typeof window !== "undefined") { try { window.addEventListener("holo-q-state", onQState); } catch (e) {} }
  return handle;
}

export function mountOrb(canvas, opts) {
  const gpu = mountGpuOrb(canvas, opts); // native-WebGPU worker orb (off the main thread) β€” the hero
  if (gpu) return gpu;
  return mountWebglOrb(canvas);          // no WebGPU/Worker/OffscreenCanvas β†’ the WebGL2 wireframe floor
}

// ─────────────────────────────────────────────────────────────────────────────────────────────────────────
// FALLBACK β€” the original self-contained WebGL2 wireframe icosphere (VERBATIM from hf-space-q-chat/core/
// holo-orb.js), so where WebGPU/worker isn't available the messenger's Q orb still animates as it always has.
// ─────────────────────────────────────────────────────────────────────────────────────────────────────────
const SPECTRUM = [[1,.231,.42],[1,.62,.173],[1,.886,.29],[.275,.878,.541],[.169,.831,1],[.357,.549,1],[.78,.482,1],[1,.231,.42]];
function hueAt(t){ t=(t%1+1)%1; const n=SPECTRUM.length-1, f=t*n, i=Math.floor(f), k=f-i, a=SPECTRUM[i], b=SPECTRUM[Math.min(i+1,n)]; return [a[0]+(b[0]-a[0])*k, a[1]+(b[1]-a[1])*k, a[2]+(b[2]-a[2])*k]; }
function norm(v){ const l=Math.hypot(v[0],v[1],v[2])||1; return [v[0]/l,v[1]/l,v[2]/l]; }
function icosphere(sub){
  const t=(1+Math.sqrt(5))/2;
  let V=[[-1,t,0],[1,t,0],[-1,-t,0],[1,-t,0],[0,-1,t],[0,1,t],[0,-1,-t],[0,1,-t],[t,0,-1],[t,0,1],[-t,0,-1],[-t,0,1]].map(norm);
  let F=[[0,11,5],[0,5,1],[0,1,7],[0,7,10],[0,10,11],[1,5,9],[5,11,4],[11,10,2],[10,7,6],[7,1,8],[3,9,4],[3,4,2],[3,2,6],[3,6,8],[3,8,9],[4,9,5],[2,4,11],[6,2,10],[8,6,7],[9,8,1]];
  const cache=new Map();
  const mid=(a,b)=>{ const key=a<b?a+"_"+b:b+"_"+a; if(cache.has(key))return cache.get(key); const m=norm([(V[a][0]+V[b][0])/2,(V[a][1]+V[b][1])/2,(V[a][2]+V[b][2])/2]); V.push(m); const i=V.length-1; cache.set(key,i); return i; };
  for(let s=0;s<sub;s++){ const nf=[]; for(const [a,b,c] of F){ const ab=mid(a,b),bc=mid(b,c),ca=mid(c,a); nf.push([a,ab,ca],[b,bc,ab],[c,ca,bc],[ab,bc,ca]); } F=nf; }
  const seen=new Set(), E=[];
  for(const [a,b,c] of F) for(const [x,y] of [[a,b],[b,c],[c,a]]){ const k=x<y?x+"_"+y:y+"_"+x; if(!seen.has(k)){ seen.add(k); E.push(x,y); } }
  return { V, E };
}
function mat4Perspective(fovy, aspect, near, far){ const f=1/Math.tan(fovy/2), nf=1/(near-far); return new Float32Array([f/aspect,0,0,0, 0,f,0,0, 0,0,(far+near)*nf,-1, 0,0,2*far*near*nf,0]); }

function mountWebglOrb(canvas){
  let gl; try { gl = canvas.getContext("webgl2", { alpha:true, antialias:true, premultipliedAlpha:false }); } catch(e){}
  if(!gl) return { fallback:true, mode:"none", stop(){} };
  const { V, E } = icosphere(3);
  const pos=new Float32Array(E.length*3), col=new Float32Array(E.length*3);
  for(let i=0;i<E.length;i++){ const p=V[E[i]]; pos[i*3]=p[0]; pos[i*3+1]=p[1]; pos[i*3+2]=p[2]; const h=hueAt(Math.atan2(p[2],p[0])/(2*Math.PI)+0.5); col[i*3]=h[0]; col[i*3+1]=h[1]; col[i*3+2]=h[2]; }
  const vs=`#version 300 es
    in vec3 aPos; in vec3 aCol; uniform mat4 uProj; uniform float uT; out vec3 vCol; out float vD;
    void main(){
      float a=uT*0.9, ca=cos(a), sa=sin(a);
      vec3 p=vec3(ca*aPos.x+sa*aPos.z, aPos.y, -sa*aPos.x+ca*aPos.z);
      float ax=uT*0.556, cx=cos(ax), sx=sin(ax);
      p=vec3(p.x, cx*p.y - sx*p.z, sx*p.y + cx*p.z);
      float br=1.0 + 0.05*sin(uT*1.3+p.y*3.0) + 0.035*sin(uT*0.7+p.x*4.0);
      p*=br; vD=p.z; p.z-=3.15;
      gl_Position=uProj*vec4(p,1.0); vCol=aCol;
    }`;
  const fs=`#version 300 es
    precision highp float; in vec3 vCol; in float vD; out vec4 o;
    void main(){ float d=0.72+0.28*smoothstep(-1.0,1.0,vD); o=vec4(vCol*d, 0.92); }`;
  const sh=(t,s)=>{ const o=gl.createShader(t); gl.shaderSource(o,s); gl.compileShader(o); if(!gl.getShaderParameter(o,gl.COMPILE_STATUS)){ console.error("[orb] shader:", gl.getShaderInfoLog(o)); } return o; };
  const prog=gl.createProgram(); gl.attachShader(prog,sh(gl.VERTEX_SHADER,vs)); gl.attachShader(prog,sh(gl.FRAGMENT_SHADER,fs)); gl.linkProgram(prog);
  if(!gl.getProgramParameter(prog,gl.LINK_STATUS)){ console.error("[orb] link:", gl.getProgramInfoLog(prog)); return { fallback:true, mode:"none", stop(){} }; }
  gl.useProgram(prog);
  const mkBuf=(data,loc)=>{ const b=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,b); gl.bufferData(gl.ARRAY_BUFFER,data,gl.STATIC_DRAW); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc,3,gl.FLOAT,false,0,0); };
  mkBuf(pos, gl.getAttribLocation(prog,"aPos")); mkBuf(col, gl.getAttribLocation(prog,"aCol"));
  const uProj=gl.getUniformLocation(prog,"uProj"), uT=gl.getUniformLocation(prog,"uT");
  gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE); gl.lineWidth(1);
  let raf=0, t0=performance.now(), stopped=false;
  function resize(){ const dpr=Math.min(window.devicePixelRatio||1, 2.5); const w=Math.max(2, canvas.clientWidth), h=Math.max(2, canvas.clientHeight); const W=Math.round(w*dpr), H=Math.round(h*dpr); if(canvas.width!==W||canvas.height!==H){ canvas.width=W; canvas.height=H; } gl.viewport(0,0,canvas.width,canvas.height); gl.uniformMatrix4fv(uProj,false, mat4Perspective(45*Math.PI/180, canvas.width/canvas.height, 0.1, 10)); }
  function frame(){ if(stopped) return; resize(); const t=(performance.now()-t0)/1000; gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT); gl.uniform1f(uT,t); gl.drawArrays(gl.LINES,0,E.length); raf=requestAnimationFrame(frame); }
  frame();
  return { stop(){ stopped=true; cancelAnimationFrame(raf); }, fallback:false, mode:"webgl" };
}

export default mountOrb;