@3driseai/3drise-viewer 0.1.35 → 0.1.38

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.
@@ -0,0 +1,752 @@
1
+ // =============================================================================
2
+ // Harmonic Shell field — the one definition of "where the shell's surface is".
3
+ // =============================================================================
4
+ //
5
+ // The shell is a field, not a mesh: a unit sphere displaced by 24 real spherical
6
+ // harmonics (the `hshSh` polynomial), and, when the shell has a target, a signed
7
+ // distance field the sphere's vertices trace onto:
8
+ // • text / shape a 2D distance field drawn on a canvas, extruded to `depth`
9
+ // • object the object's own 3D distance field, baked once from its geometry
10
+ //
11
+ // Everything that needs to know the shell reads THIS module: the shell's own
12
+ // material (HarmonicShellEffect) and the `inShell` object effect, whose layer
13
+ // asks, per pixel and per vertex, "where am I in the shell?". The uniform objects
14
+ // here are shared: the shell writes them, the effect reads them.
15
+ //
16
+ // Names are prefixed `uHsh…` / `hsh…` so the GLSL can live inside a layered
17
+ // built-in material next to other effects.
18
+ import * as THREE from 'three';
19
+ import { MeshBVH } from 'three-mesh-bvh';
20
+ // ---------------------------------------------------------------- harmonics
21
+ /** Band (l) of each of the 24 real-SH terms in the shader. */
22
+ const SH_BANDS = [1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4];
23
+ function mulberry32(seed) {
24
+ return () => {
25
+ seed |= 0;
26
+ seed = (seed + 0x6d2b79f5) | 0;
27
+ let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
28
+ r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
29
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
30
+ };
31
+ }
32
+ /** Fixed per-term frequencies and phases, so the same config always moves the same way. */
33
+ export function createHarmonicState() {
34
+ const rand = mulberry32(7);
35
+ const f1 = new Float32Array(24), f2 = new Float32Array(24), p1 = new Float32Array(24), p2 = new Float32Array(24);
36
+ for (let i = 0; i < 24; i++) {
37
+ f1[i] = 0.13 + 0.3 * rand();
38
+ f2[i] = 0.07 + 0.2 * rand();
39
+ p1[i] = rand() * Math.PI * 2;
40
+ p2[i] = rand() * Math.PI * 2;
41
+ }
42
+ return { f1, f2, p1, p2, gate: new Float32Array([0, 0, 0, 0, 0]) };
43
+ }
44
+ /**
45
+ * Writes the 24 weights for time T into `out`. Bands above `degree` fade
46
+ * out; the top band leads and lower bands sit at about half strength.
47
+ */
48
+ export function updateHarmonicWeights(state, out, T, degree, dt) {
49
+ const deg = Math.round(Math.min(Math.max(degree, 1), 4));
50
+ const ease = Math.min(1, dt * 2.5);
51
+ let energy = 0;
52
+ for (let l = 1; l <= 4; l++) {
53
+ // l = 1 only shifts the shell sideways, so it stays quiet unless it is the only band
54
+ const target = l > deg ? 0 : l === deg ? 1 : l === 1 ? 0.2 : 0.55;
55
+ state.gate[l] += (target - state.gate[l]) * ease;
56
+ energy += state.gate[l] * state.gate[l] * (2 * l + 1);
57
+ }
58
+ const norm = 1 / Math.sqrt(Math.max(energy / 5, 1e-3));
59
+ for (let i = 0; i < 24; i++) {
60
+ const g = state.gate[SH_BANDS[i]];
61
+ out[i] = g * norm * (0.7 * Math.sin(T * state.f1[i] + state.p1[i]) + 0.3 * Math.sin(T * state.f2[i] + state.p2[i]));
62
+ }
63
+ }
64
+ // ---------------------------------------------------------------- GLSL
65
+ /** The shared uniforms, declared (the shell's own ShaderMaterial; a layered material gets them from the kit). */
66
+ export const HSH_FIELD_DECL = /* glsl */ `
67
+ uniform float uHshW[24];
68
+ uniform float uHshAmplitude; uniform float uHshBound; uniform float uHshSize;
69
+ uniform vec3 uHshColor; uniform vec3 uHshRim; uniform float uHshFilm; uniform float uHshTint; uniform float uHshTime;
70
+ uniform float uHshMorph; uniform float uHshMode;
71
+ uniform sampler2D uHshSdf; uniform vec2 uHshSdfHalf; uniform float uHshDepth;
72
+ uniform sampler3D uHshSdf3; uniform vec3 uHshSdf3Min; uniform vec3 uHshSdf3Size; uniform mat4 uHshSdf3Inv; uniform mat4 uHshSdf3Mat;
73
+ `;
74
+ /**
75
+ * The field, for any shader (after the declarations): the harmonic polynomial, the
76
+ * thin film, and the target distance functions. Include once per stage.
77
+ */
78
+ export const HSH_FIELD_FUNCS = /* glsl */ `
79
+
80
+ // Real spherical harmonics for l = 1..4, as polynomials in the unit vector,
81
+ // each scaled so its peak is about 1. The CPU animates the 24 weights.
82
+ float hshSh(vec3 n){
83
+ float x = n.x, y = n.y, z = n.z;
84
+ float x2 = x * x, y2 = y * y, z2 = z * z;
85
+ float s = 0.0;
86
+ s += uHshW[0] * y + uHshW[1] * z + uHshW[2] * x;
87
+ s += uHshW[3] * 2.0 * x * y + uHshW[4] * 2.0 * y * z + uHshW[5] * (1.5 * z2 - 0.5)
88
+ + uHshW[6] * 2.0 * x * z + uHshW[7] * (x2 - y2);
89
+ s += uHshW[8] * y * (3.0 * x2 - y2)
90
+ + uHshW[9] * 5.2 * x * y * z
91
+ + uHshW[10] * 0.8 * y * (5.0 * z2 - 1.0)
92
+ + uHshW[11] * 0.5 * z * (5.0 * z2 - 3.0)
93
+ + uHshW[12] * 0.8 * x * (5.0 * z2 - 1.0)
94
+ + uHshW[13] * 2.6 * z * (x2 - y2)
95
+ + uHshW[14] * x * (x2 - 3.0 * y2);
96
+ s += uHshW[15] * 4.0 * x * y * (x2 - y2)
97
+ + uHshW[16] * 3.0 * y * z * (3.0 * x2 - y2)
98
+ + uHshW[17] * 1.5 * x * y * (7.0 * z2 - 1.0)
99
+ + uHshW[18] * 0.95 * y * z * (7.0 * z2 - 3.0)
100
+ + uHshW[19] * 0.125 * (35.0 * z2 * z2 - 30.0 * z2 + 3.0)
101
+ + uHshW[20] * 0.95 * x * z * (7.0 * z2 - 3.0)
102
+ + uHshW[21] * 0.78 * (x2 - y2) * (7.0 * z2 - 1.0)
103
+ + uHshW[22] * 3.0 * x * z * (x2 - 3.0 * y2)
104
+ + uHshW[23] * (x2 * (x2 - 3.0 * y2) - y2 * (3.0 * x2 - y2));
105
+ return s;
106
+ }
107
+
108
+ // The resting shell: radius along direction n, in shell-local units.
109
+ float hshRadius(vec3 n, out float field){
110
+ field = hshSh(n);
111
+ return uHshBound * uHshSize * (1.0 + uHshAmplitude * field);
112
+ }
113
+
114
+ // Thin-film interference, as on a soap bubble: a film of index 1.33 and
115
+ // thickness d delays the inner reflection by 2*n*d*cos(theta_t). Each
116
+ // wavelength interferes on its own, which splits white light into bands.
117
+ vec3 hshFilm(float ndv, float field){
118
+ float cosT = sqrt(max(1.0 - (1.0 - ndv * ndv) / (1.33 * 1.33), 0.0));
119
+ float opd = 2.0 * 1.33 * uHshFilm * (1.0 + 0.35 * field) * cosT;
120
+ vec3 film = 0.5 + 0.5 * cos(6.28318530718 * opd / vec3(650.0, 532.0, 450.0));
121
+ float lum = dot(film, vec3(0.299, 0.587, 0.114));
122
+ return mix(film, lum * uHshColor * 1.7 + film * 0.12, uHshTint);
123
+ }
124
+
125
+ // ── the target: signed distance in shell-local units (negative inside) ──
126
+ float hshSdf2(vec2 p){
127
+ vec2 uv = p / (2.0 * uHshSdfHalf) + 0.5;
128
+ vec2 c = clamp(uv, 0.0, 1.0);
129
+ return texture2D(uHshSdf, c).r + length((uv - c) * 2.0 * uHshSdfHalf);
130
+ }
131
+ float hshSdf(vec3 p){
132
+ if (uHshMode > 1.5) {
133
+ // an object's own field, baked in its local space
134
+ vec3 q = (uHshSdf3Inv * vec4(p, 1.0)).xyz;
135
+ vec3 uvw = (q - uHshSdf3Min) / uHshSdf3Size; vec3 c = clamp(uvw, 0.0, 1.0);
136
+ return texture(uHshSdf3, c).r + length((uvw - c) * uHshSdf3Size);
137
+ }
138
+ // text / shape: the 2D field extruded to ±depth
139
+ float d2 = hshSdf2(p.xy); float dz = abs(p.z) - uHshDepth;
140
+ vec2 w = max(vec2(d2, dz), 0.0);
141
+ return min(max(d2, dz), 0.0) + length(w);
142
+ }
143
+ vec3 hshSdfGrad(vec3 p){
144
+ float e = 0.012;
145
+ vec3 g = vec3(hshSdf(p + vec3(e, 0, 0)) - hshSdf(p - vec3(e, 0, 0)),
146
+ hshSdf(p + vec3(0, e, 0)) - hshSdf(p - vec3(0, e, 0)),
147
+ hshSdf(p + vec3(0, 0, e)) - hshSdf(p - vec3(0, 0, e)));
148
+ float l = length(g); return l > 1e-6 ? g / l : vec3(0.0, 0.0, 1.0);
149
+ }
150
+
151
+ // Signed distance from a shell-local point to the shell's CURRENT surface
152
+ // (the resting shell blended with the target by uHshMorph). 'field' is the
153
+ // harmonic value in that direction, for the film.
154
+ // Where a sphere vertex starts its trace: the resting shell point stretched to the
155
+ // target's extent, so a wide word or a long object gets vertices along its whole length
156
+ // instead of everything converging on the part nearest the centre.
157
+ vec3 hshTraceStart(vec3 p0){
158
+ float R0 = uHshBound * uHshSize;
159
+ if (uHshMode > 1.5) {
160
+ vec3 c = uHshSdf3Min + 0.5 * uHshSdf3Size; // the target's centre, in its own space
161
+ vec3 h = uHshSdf3Size * 0.44; // half extents (the baked box is padded 12%)
162
+ vec3 ct = (uHshSdf3Inv * vec4(0.0, 0.0, 0.0, 1.0)).xyz;
163
+ vec3 qt = (uHshSdf3Inv * vec4(p0, 1.0)).xyz;
164
+ qt = c + (qt - ct) * (h / R0);
165
+ return (uHshSdf3Mat * vec4(qt, 1.0)).xyz;
166
+ }
167
+ return p0 * vec3(uHshSdfHalf / R0, max(uHshDepth, 0.05) / R0);
168
+ }
169
+
170
+ float hshDist(vec3 p, out float field){
171
+ float r = length(p); vec3 n = r > 1e-5 ? p / r : vec3(0.0, 1.0, 0.0);
172
+ float dS = r - hshRadius(n, field);
173
+ return uHshMode > 0.5 && uHshMorph > 0.0 ? mix(dS, hshSdf(p), uHshMorph) : dS;
174
+ }
175
+ `;
176
+ /** Declarations + functions: the whole field for a shader that declares nothing itself. */
177
+ export const HSH_FIELD_GLSL = HSH_FIELD_DECL + HSH_FIELD_FUNCS;
178
+ /** The shell's own material: the sphere displaced by the field, traced onto the target when it has one. */
179
+ export const SHELL_VERT = /* glsl */ `
180
+ uniform float uBreath;
181
+ varying vec3 vNormal;
182
+ varying vec3 vViewDir;
183
+ varying float vField;
184
+ varying vec3 vLocal;
185
+ ${HSH_FIELD_GLSL}
186
+
187
+ vec3 shellPoint(vec3 n, out float f){ return n * hshRadius(n, f); }
188
+
189
+ void main(){
190
+ vec3 n = normalize(position);
191
+ float f, f1, f2;
192
+ vec3 p0 = shellPoint(n, f);
193
+
194
+ // normal from two nearby points on the displaced surface
195
+ vec3 up = abs(n.y) > 0.99 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0);
196
+ vec3 t1 = normalize(cross(n, up));
197
+ vec3 t2 = cross(n, t1);
198
+ vec3 p1 = shellPoint(normalize(n + t1 * 0.01), f1);
199
+ vec3 p2 = shellPoint(normalize(n + t2 * 0.01), f2);
200
+ vec3 nrm = normalize(cross(p1 - p0, p2 - p0));
201
+ if (dot(nrm, n) < 0.0) nrm = -nrm;
202
+
203
+ vec3 p = p0; vec3 N = nrm;
204
+ if (uHshMode > 0.5 && uHshMorph > 0.0) {
205
+ // trace the vertex onto the target surface along the field's gradient
206
+ vec3 q = hshTraceStart(p0);
207
+ for (int i = 0; i < 12; i++) { float d = hshSdf(q); q -= d * hshSdfGrad(q) * 0.85; }
208
+ vec3 g = hshSdfGrad(q);
209
+ // the harmonics keep breathing on the target, along its normal
210
+ q += g * uHshBound * uHshAmplitude * uBreath * f;
211
+ p = mix(p0, q, uHshMorph);
212
+ N = normalize(mix(nrm, g, uHshMorph));
213
+ }
214
+
215
+ vLocal = p;
216
+ vField = f;
217
+ vec4 mv = modelViewMatrix * vec4(p, 1.0);
218
+ vNormal = normalize(normalMatrix * N);
219
+ vViewDir = normalize(-mv.xyz);
220
+ gl_Position = projectionMatrix * mv;
221
+ }
222
+ `;
223
+ export const SHELL_FRAG = /* glsl */ `
224
+ uniform float uOpacity;
225
+ uniform float uIntensity;
226
+ uniform float uNodalLines;
227
+ uniform float uWeb;
228
+ varying vec3 vNormal;
229
+ varying vec3 vViewDir;
230
+ varying float vField;
231
+ varying vec3 vLocal;
232
+ ${HSH_FIELD_GLSL}
233
+
234
+ void main(){
235
+ vec3 N = normalize(vNormal);
236
+ vec3 V = normalize(vViewDir);
237
+ float ndv = abs(dot(N, V));
238
+
239
+ // the film is thicker where the harmonic pushes the shell out, so the colour bands follow the shape
240
+ vec3 film = hshFilm(ndv, vField);
241
+
242
+ float fres = pow(1.0 - ndv, 3.0);
243
+ vec3 col = uHshColor * 0.25 + film * (0.2 + 0.8 * fres);
244
+ col = mix(col, uHshRim, fres * 0.45);
245
+
246
+ // nodal lines: where the harmonic field crosses zero
247
+ float nd = abs(vField) / max(fwidth(vField), 1e-4);
248
+ float nodal = (1.0 - smoothstep(0.5, 1.5, nd)) * uNodalLines;
249
+ col += uHshRim * nodal * 1.5;
250
+
251
+ float a = (0.06 + 0.9 * fres + nodal * 0.8) * uOpacity;
252
+ // webbing between letters: pixels that are not on the target fade out as the morph completes
253
+ if (uHshMode > 0.5 && uHshMorph > 0.0) { float d = abs(hshSdf(vLocal)); a *= 1.0 - uHshMorph * smoothstep(uWeb, uWeb * 3.0, d); }
254
+ if (a < 0.003) discard;
255
+ gl_FragColor = vec4(col * uIntensity, a);
256
+ }
257
+ `;
258
+ /** A 1×1×1 texture so the sampler3D is always bound, target or not. */
259
+ function emptySdf3() {
260
+ const t = new THREE.Data3DTexture(new Uint16Array([THREE.DataUtils.toHalfFloat(1)]), 1, 1, 1);
261
+ t.format = THREE.RedFormat;
262
+ t.type = THREE.HalfFloatType;
263
+ t.needsUpdate = true;
264
+ return t;
265
+ }
266
+ function emptySdf2() {
267
+ const t = new THREE.DataTexture(new Uint16Array([THREE.DataUtils.toHalfFloat(1)]), 1, 1, THREE.RedFormat, THREE.HalfFloatType);
268
+ t.needsUpdate = true;
269
+ return t;
270
+ }
271
+ /** Fresh uniform objects for one shell (the shell writes them every frame; effects read them). */
272
+ export function createShellUniforms() {
273
+ return {
274
+ uHshW: { value: new Float32Array(24) },
275
+ uHshAmplitude: { value: 0.22 }, uHshBound: { value: 1 }, uHshSize: { value: 1.35 },
276
+ uHshColor: { value: new THREE.Color('#3a2a6b') }, uHshRim: { value: new THREE.Color('#bfe8ff') },
277
+ uHshFilm: { value: 380 }, uHshTint: { value: 0 }, uHshTime: { value: 0 },
278
+ uHshMorph: { value: 0 }, uHshMode: { value: 0 },
279
+ uHshSdf: { value: emptySdf2() }, uHshSdfHalf: { value: new THREE.Vector2(1, 1) }, uHshDepth: { value: 0.35 },
280
+ uHshSdf3: { value: emptySdf3() }, uHshSdf3Min: { value: new THREE.Vector3() }, uHshSdf3Size: { value: new THREE.Vector3(1, 1, 1) }, uHshSdf3Inv: { value: new THREE.Matrix4() }, uHshSdf3Mat: { value: new THREE.Matrix4() },
281
+ };
282
+ }
283
+ /** The keys an effect copies from a shell into its own uniforms (everything but the time, which it keeps). */
284
+ export const HSH_SHARED_KEYS = [
285
+ 'uHshW', 'uHshAmplitude', 'uHshBound', 'uHshSize', 'uHshColor', 'uHshRim', 'uHshFilm', 'uHshTint',
286
+ 'uHshMorph', 'uHshMode', 'uHshSdf', 'uHshSdfHalf', 'uHshDepth', 'uHshSdf3', 'uHshSdf3Min', 'uHshSdf3Size', 'uHshSdf3Inv', 'uHshSdf3Mat',
287
+ ];
288
+ /** Copy a shell's current values into another uniform set (objects are shared by reference, scalars by value). */
289
+ export function copyShellUniforms(from, to) {
290
+ for (const k of HSH_SHARED_KEYS) {
291
+ const v = from[k].value;
292
+ if (typeof v === 'number')
293
+ to[k].value = v;
294
+ else
295
+ to[k].value = v; // Float32Array, Color, Vector, Matrix4, textures: the shell's own objects
296
+ }
297
+ }
298
+ const shells = new Set();
299
+ export function registerShellField(f) { shells.add(f); }
300
+ export function unregisterShellField(f) { shells.delete(f); }
301
+ export const shellFields = () => shells;
302
+ const _p = new THREE.Vector3();
303
+ /** The shell whose centre is nearest a world point (what `inShell` reacts to), or null when the scene has none. */
304
+ export function nearestShellField(worldPos) {
305
+ let best = null, bd = Infinity;
306
+ for (const s of shells) {
307
+ _p.setFromMatrixPosition(s.root.matrixWorld);
308
+ const d = _p.distanceToSquared(worldPos);
309
+ if (d < bd) {
310
+ bd = d;
311
+ best = s;
312
+ }
313
+ }
314
+ return best;
315
+ }
316
+ // ---------------------------------------------------------------- 2D distance fields (text, shapes)
317
+ export const SHELL_SHAPES = [
318
+ 'star', 'heart', 'ring', 'hexagon', 'triangle', 'cross',
319
+ 'disc', 'square', 'diamond', 'pentagon', 'octagon', 'sparkle', 'hexstar', 'crescent', 'drop', 'bolt',
320
+ 'arrow', 'cloud', 'flower', 'gear', 'shield', 'leaf', 'capsule', 'infinity', 'chevron', 'hourglass',
321
+ 'clover', 'spade', 'sun', 'halfmoon',
322
+ ];
323
+ /** CSS font families the `font` option maps to (the page's own fonts, with system fallbacks). */
324
+ export const SHELL_FONTS = {
325
+ sans: '"Inter", "Inter Variable", system-ui, -apple-system, "Segoe UI", Arial, sans-serif',
326
+ display: '"Syne", "Syne Variable", "Montserrat", "Arial Black", Impact, sans-serif',
327
+ serif: '"Instrument Serif", "Playfair Display", Georgia, "Times New Roman", serif',
328
+ mono: '"DM Mono", ui-monospace, Menlo, Consolas, monospace',
329
+ };
330
+ const SDF_N = 256;
331
+ // Felzenszwalb & Huttenlocher's exact euclidean distance transform, 1D pass.
332
+ function edt1d(f, n, d, v, z) {
333
+ let k = 0;
334
+ v[0] = 0;
335
+ z[0] = -Infinity;
336
+ z[1] = Infinity;
337
+ for (let q = 1; q < n; q++) {
338
+ let s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
339
+ while (s <= z[k]) {
340
+ k--;
341
+ s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
342
+ }
343
+ k++;
344
+ v[k] = q;
345
+ z[k] = s;
346
+ z[k + 1] = Infinity;
347
+ }
348
+ k = 0;
349
+ for (let q = 0; q < n; q++) {
350
+ while (z[k + 1] < q)
351
+ k++;
352
+ d[q] = (q - v[k]) * (q - v[k]) + f[v[k]];
353
+ }
354
+ }
355
+ function edt2d(grid, w, h) {
356
+ const m = Math.max(w, h);
357
+ const f = new Float32Array(m), d = new Float32Array(m), v = new Int32Array(m), z = new Float32Array(m + 1);
358
+ for (let x = 0; x < w; x++) {
359
+ for (let y = 0; y < h; y++)
360
+ f[y] = grid[y * w + x];
361
+ edt1d(f, h, d, v, z);
362
+ for (let y = 0; y < h; y++)
363
+ grid[y * w + x] = d[y];
364
+ }
365
+ for (let y = 0; y < h; y++) {
366
+ for (let x = 0; x < w; x++)
367
+ f[x] = grid[y * w + x];
368
+ edt1d(f, w, d, v, z);
369
+ for (let x = 0; x < w; x++)
370
+ grid[y * w + x] = d[x];
371
+ }
372
+ }
373
+ /** A canvas (alpha = inside) → signed distance (shell units) over ±half, as a half-float texture. */
374
+ function sdfFromCanvas(c, half) {
375
+ const img = c.getContext('2d').getImageData(0, 0, SDF_N, SDF_N).data;
376
+ const INF = 1e10, inside = new Float32Array(SDF_N * SDF_N), outside = new Float32Array(SDF_N * SDF_N);
377
+ for (let i = 0; i < SDF_N * SDF_N; i++) {
378
+ const on = img[i * 4 + 3] > 127;
379
+ inside[i] = on ? INF : 0;
380
+ outside[i] = on ? 0 : INF;
381
+ }
382
+ edt2d(inside, SDF_N, SDF_N);
383
+ edt2d(outside, SDF_N, SDF_N);
384
+ const unit = (2 * half) / SDF_N;
385
+ const data = new Uint16Array(SDF_N * SDF_N);
386
+ for (let y = 0; y < SDF_N; y++)
387
+ for (let x = 0; x < SDF_N; x++) {
388
+ const i = y * SDF_N + x, j = (SDF_N - 1 - y) * SDF_N + x; // canvas y runs down, texture v runs up
389
+ const d = (Math.sqrt(outside[j]) - Math.sqrt(inside[j])) * unit;
390
+ data[i] = THREE.DataUtils.toHalfFloat(Math.max(-8, Math.min(8, d)));
391
+ }
392
+ const tex = new THREE.DataTexture(data, SDF_N, SDF_N, THREE.RedFormat, THREE.HalfFloatType);
393
+ tex.minFilter = tex.magFilter = THREE.LinearFilter;
394
+ tex.needsUpdate = true;
395
+ return { tex, half };
396
+ }
397
+ function drawTarget(kind, text, fontFamily) {
398
+ const c = document.createElement('canvas');
399
+ c.width = c.height = SDF_N;
400
+ const g = c.getContext('2d');
401
+ g.clearRect(0, 0, SDF_N, SDF_N);
402
+ g.fillStyle = '#fff';
403
+ const cx = SDF_N / 2, cy = SDF_N / 2, R = SDF_N * 0.36;
404
+ const poly = (pts) => { g.beginPath(); pts.forEach((p, i) => (i ? g.lineTo(p[0], p[1]) : g.moveTo(p[0], p[1]))); g.closePath(); g.fill(); };
405
+ const circle = (x, y, r) => { g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fill(); };
406
+ const ngon = (n, r, a0) => { const pts = []; for (let i = 0; i < n; i++) {
407
+ const a = a0 + (i * Math.PI * 2) / n;
408
+ pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
409
+ } poly(pts); };
410
+ const starN = (n, r, inner, a0) => { const pts = []; for (let i = 0; i < n * 2; i++) {
411
+ const rr = i % 2 ? r * inner : r;
412
+ const a = a0 + (i * Math.PI) / n;
413
+ pts.push([cx + rr * Math.cos(a), cy + rr * Math.sin(a)]);
414
+ } poly(pts); };
415
+ /** Erase what `draw` fills (a hole or a bite). */
416
+ const cut = (draw) => { g.globalCompositeOperation = 'destination-out'; draw(); g.globalCompositeOperation = 'source-over'; };
417
+ switch (kind) {
418
+ case 'text': {
419
+ const t = (text || 'A').slice(0, 24);
420
+ let size = SDF_N * 0.5;
421
+ g.font = `800 ${size}px ${fontFamily}`;
422
+ const w = g.measureText(t).width;
423
+ if (w > SDF_N * 0.92) {
424
+ size *= (SDF_N * 0.92) / w;
425
+ g.font = `800 ${size}px ${fontFamily}`;
426
+ }
427
+ g.textAlign = 'center';
428
+ g.textBaseline = 'middle';
429
+ g.fillText(t, cx, cy + size * 0.04);
430
+ break;
431
+ }
432
+ case 'star': {
433
+ const pts = [];
434
+ for (let i = 0; i < 10; i++) {
435
+ const r = i % 2 ? R * 0.45 : R;
436
+ const a = -Math.PI / 2 + (i * Math.PI) / 5;
437
+ pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
438
+ }
439
+ poly(pts);
440
+ break;
441
+ }
442
+ case 'heart': {
443
+ g.beginPath();
444
+ const s = R / 16;
445
+ g.moveTo(cx, cy + 14 * s);
446
+ g.bezierCurveTo(cx - 24 * s, cy - 4 * s, cx - 14 * s, cy - 18 * s, cx, cy - 8 * s);
447
+ g.bezierCurveTo(cx + 14 * s, cy - 18 * s, cx + 24 * s, cy - 4 * s, cx, cy + 14 * s);
448
+ g.fill();
449
+ break;
450
+ }
451
+ case 'ring':
452
+ g.beginPath();
453
+ g.arc(cx, cy, R, 0, Math.PI * 2);
454
+ g.arc(cx, cy, R * 0.55, 0, Math.PI * 2, true);
455
+ g.fill();
456
+ break;
457
+ case 'hexagon': {
458
+ const pts = [];
459
+ for (let i = 0; i < 6; i++) {
460
+ const a = (i * Math.PI) / 3;
461
+ pts.push([cx + R * Math.cos(a), cy + R * Math.sin(a)]);
462
+ }
463
+ poly(pts);
464
+ break;
465
+ }
466
+ case 'triangle':
467
+ poly([[cx, cy - R], [cx + R * 0.95, cy + R * 0.6], [cx - R * 0.95, cy + R * 0.6]]);
468
+ break;
469
+ case 'cross':
470
+ g.fillRect(cx - R * 0.22, cy - R, R * 0.44, 2 * R);
471
+ g.fillRect(cx - R, cy - R * 0.22, 2 * R, R * 0.44);
472
+ break;
473
+ case 'disc':
474
+ circle(cx, cy, R);
475
+ break;
476
+ case 'square':
477
+ g.fillRect(cx - R * 0.88, cy - R * 0.88, R * 1.76, R * 1.76);
478
+ break;
479
+ case 'diamond':
480
+ poly([[cx, cy - R], [cx + R * 0.7, cy], [cx, cy + R], [cx - R * 0.7, cy]]);
481
+ break;
482
+ case 'pentagon':
483
+ ngon(5, R, -Math.PI / 2);
484
+ break;
485
+ case 'octagon':
486
+ ngon(8, R, Math.PI / 8);
487
+ break;
488
+ case 'sparkle':
489
+ starN(4, R, 0.3, -Math.PI / 2);
490
+ break;
491
+ case 'hexstar':
492
+ starN(6, R, 0.58, -Math.PI / 2);
493
+ break;
494
+ case 'crescent':
495
+ circle(cx, cy, R);
496
+ cut(() => circle(cx + R * 0.5, cy - R * 0.12, R * 0.82));
497
+ break;
498
+ case 'drop': {
499
+ g.beginPath();
500
+ g.moveTo(cx, cy - R);
501
+ g.bezierCurveTo(cx + R * 0.95, cy + R * 0.05, cx + R * 0.7, cy + R, cx, cy + R);
502
+ g.bezierCurveTo(cx - R * 0.7, cy + R, cx - R * 0.95, cy + R * 0.05, cx, cy - R);
503
+ g.fill();
504
+ break;
505
+ }
506
+ case 'bolt':
507
+ poly([[cx + R * 0.25, cy - R], [cx - R * 0.55, cy + R * 0.12], [cx - R * 0.05, cy + R * 0.12], [cx - R * 0.3, cy + R], [cx + R * 0.55, cy - R * 0.18], [cx + R * 0.05, cy - R * 0.18]]);
508
+ break;
509
+ case 'arrow':
510
+ poly([[cx - R, cy - R * 0.3], [cx + R * 0.15, cy - R * 0.3], [cx + R * 0.15, cy - R * 0.75], [cx + R, cy], [cx + R * 0.15, cy + R * 0.75], [cx + R * 0.15, cy + R * 0.3], [cx - R, cy + R * 0.3]]);
511
+ break;
512
+ case 'cloud': {
513
+ circle(cx - R * 0.5, cy + R * 0.15, R * 0.42);
514
+ circle(cx - R * 0.1, cy - R * 0.2, R * 0.55);
515
+ circle(cx + R * 0.4, cy + R * 0.05, R * 0.48);
516
+ g.fillRect(cx - R * 0.5, cy + R * 0.15, R * 0.9, R * 0.42);
517
+ break;
518
+ }
519
+ case 'flower': {
520
+ for (let i = 0; i < 6; i++) {
521
+ const a = (i * Math.PI) / 3;
522
+ circle(cx + R * 0.58 * Math.cos(a), cy + R * 0.58 * Math.sin(a), R * 0.42);
523
+ }
524
+ circle(cx, cy, R * 0.4);
525
+ break;
526
+ }
527
+ case 'gear': {
528
+ const pts = [], teeth = 10;
529
+ for (let i = 0; i < teeth * 4; i++) {
530
+ const r = (i % 4) < 2 ? R : R * 0.78;
531
+ const a = (i * Math.PI * 2) / (teeth * 4);
532
+ pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
533
+ }
534
+ poly(pts);
535
+ cut(() => circle(cx, cy, R * 0.3));
536
+ break;
537
+ }
538
+ case 'shield': {
539
+ g.beginPath();
540
+ g.moveTo(cx - R * 0.85, cy - R * 0.8);
541
+ g.lineTo(cx + R * 0.85, cy - R * 0.8);
542
+ g.lineTo(cx + R * 0.85, cy + R * 0.1);
543
+ g.quadraticCurveTo(cx + R * 0.85, cy + R * 0.75, cx, cy + R);
544
+ g.quadraticCurveTo(cx - R * 0.85, cy + R * 0.75, cx - R * 0.85, cy + R * 0.1);
545
+ g.closePath();
546
+ g.fill();
547
+ break;
548
+ }
549
+ case 'leaf': {
550
+ g.beginPath();
551
+ g.moveTo(cx - R * 0.9, cy + R * 0.9);
552
+ g.quadraticCurveTo(cx - R * 1.1, cy - R * 0.9, cx + R * 0.9, cy - R * 0.9);
553
+ g.quadraticCurveTo(cx + R * 1.1, cy + R * 0.9, cx - R * 0.9, cy + R * 0.9);
554
+ g.fill();
555
+ break;
556
+ }
557
+ case 'capsule': {
558
+ g.beginPath();
559
+ g.arc(cx - R * 0.55, cy, R * 0.45, Math.PI / 2, -Math.PI / 2);
560
+ g.arc(cx + R * 0.55, cy, R * 0.45, -Math.PI / 2, Math.PI / 2);
561
+ g.closePath();
562
+ g.fill();
563
+ break;
564
+ }
565
+ case 'infinity': {
566
+ const r = R * 0.5, k = R * 0.28;
567
+ for (const sx of [-1, 1]) {
568
+ g.beginPath();
569
+ g.arc(cx + sx * r, cy, r, 0, Math.PI * 2);
570
+ g.arc(cx + sx * r, cy, r - k, 0, Math.PI * 2, true);
571
+ g.fill();
572
+ }
573
+ break;
574
+ }
575
+ case 'chevron':
576
+ poly([[cx - R * 0.9, cy - R * 0.8], [cx - R * 0.3, cy - R * 0.8], [cx + R * 0.5, cy], [cx - R * 0.3, cy + R * 0.8], [cx - R * 0.9, cy + R * 0.8], [cx - R * 0.1, cy]]);
577
+ break;
578
+ case 'hourglass':
579
+ poly([[cx - R * 0.75, cy - R], [cx + R * 0.75, cy - R], [cx + R * 0.1, cy], [cx + R * 0.75, cy + R], [cx - R * 0.75, cy + R], [cx - R * 0.1, cy]]);
580
+ break;
581
+ case 'clover': {
582
+ for (let i = 0; i < 3; i++) {
583
+ const a = -Math.PI / 2 + (i * Math.PI * 2) / 3;
584
+ circle(cx + R * 0.45 * Math.cos(a), cy + R * 0.45 * Math.sin(a), R * 0.46);
585
+ }
586
+ g.fillRect(cx - R * 0.1, cy, R * 0.2, R);
587
+ break;
588
+ }
589
+ case 'spade': {
590
+ g.beginPath();
591
+ g.moveTo(cx, cy - R);
592
+ g.bezierCurveTo(cx + R * 0.9, cy - R * 0.1, cx + R * 0.9, cy + R * 0.45, cx + R * 0.15, cy + R * 0.3);
593
+ g.bezierCurveTo(cx + R * 0.1, cy + R * 0.7, cx + R * 0.35, cy + R, cx + R * 0.4, cy + R);
594
+ g.lineTo(cx - R * 0.4, cy + R);
595
+ g.bezierCurveTo(cx - R * 0.35, cy + R, cx - R * 0.1, cy + R * 0.7, cx - R * 0.15, cy + R * 0.3);
596
+ g.bezierCurveTo(cx - R * 0.9, cy + R * 0.45, cx - R * 0.9, cy - R * 0.1, cx, cy - R);
597
+ g.fill();
598
+ break;
599
+ }
600
+ case 'sun': {
601
+ circle(cx, cy, R * 0.5);
602
+ for (let i = 0; i < 8; i++) {
603
+ const a = (i * Math.PI) / 4;
604
+ poly([[cx + R * 0.62 * Math.cos(a - 0.14), cy + R * 0.62 * Math.sin(a - 0.14)], [cx + R * Math.cos(a), cy + R * Math.sin(a)], [cx + R * 0.62 * Math.cos(a + 0.14), cy + R * 0.62 * Math.sin(a + 0.14)]]);
605
+ }
606
+ break;
607
+ }
608
+ case 'halfmoon':
609
+ g.beginPath();
610
+ g.arc(cx, cy, R, -Math.PI / 2, Math.PI / 2);
611
+ g.closePath();
612
+ g.fill();
613
+ break;
614
+ }
615
+ return c;
616
+ }
617
+ /**
618
+ * The 2D field of a word or a shape, sized to the shell: `half` is the half-width it
619
+ * spans in shell-local units (the shell's resting radius, a little more).
620
+ * Text waits for the page font once (document.fonts), then draws.
621
+ */
622
+ export async function bakeShapeSdf(kind, text, font, half) {
623
+ if (typeof document === 'undefined')
624
+ return null;
625
+ const family = SHELL_FONTS[font] ?? SHELL_FONTS.sans;
626
+ if (kind === 'text') {
627
+ try {
628
+ await document.fonts?.load?.(`800 40px ${family}`);
629
+ }
630
+ catch { /* the fallback face draws */ }
631
+ }
632
+ return sdfFromCanvas(drawTarget(kind, text, family), half);
633
+ }
634
+ const SDF3_N = 48;
635
+ const sdf3Cache = new WeakMap();
636
+ /** A key for "the same meshes, same geometry" — a model that lands later gets a new bake. */
637
+ function geometryKey(root) {
638
+ const parts = [];
639
+ root.traverse((o) => { if (o.isMesh && o.geometry && !o.userData?.__opFxHelper)
640
+ parts.push(o.geometry.uuid + ':' + (o.geometry.attributes?.position?.count ?? 0)); });
641
+ return parts.join('|');
642
+ }
643
+ /**
644
+ * Every mesh of the object as one position-only, root-local geometry (what the bake
645
+ * needs — materials, uvs and normals are irrelevant). Helpers and hidden meshes are left out.
646
+ */
647
+ function mergedPositions(root) {
648
+ root.updateWorldMatrix(true, true);
649
+ const rootInv = root.matrixWorld.clone().invert();
650
+ const m = new THREE.Matrix4(), v = new THREE.Vector3();
651
+ const chunks = [];
652
+ let total = 0;
653
+ root.traverse((o) => {
654
+ if (!o.isMesh || !o.visible || o.userData?.__opFxHelper)
655
+ return;
656
+ const pos = o.geometry?.attributes?.position;
657
+ if (!pos || pos.count < 3)
658
+ return;
659
+ const idx = o.geometry.index;
660
+ const n = idx ? idx.count : pos.count;
661
+ if (n < 3)
662
+ return;
663
+ m.multiplyMatrices(rootInv, o.matrixWorld);
664
+ const out = new Float32Array(n * 3);
665
+ for (let i = 0; i < n; i++) {
666
+ v.fromBufferAttribute(pos, idx ? idx.getX(i) : i).applyMatrix4(m);
667
+ out[i * 3] = v.x;
668
+ out[i * 3 + 1] = v.y;
669
+ out[i * 3 + 2] = v.z;
670
+ }
671
+ chunks.push(out);
672
+ total += n;
673
+ });
674
+ if (total < 3)
675
+ return null;
676
+ const all = new Float32Array(total * 3);
677
+ let at = 0;
678
+ for (const c of chunks) {
679
+ all.set(c, at);
680
+ at += c.length;
681
+ }
682
+ const g = new THREE.BufferGeometry();
683
+ g.setAttribute('position', new THREE.BufferAttribute(all, 3));
684
+ return g;
685
+ }
686
+ /**
687
+ * The object's signed distance field in its root-local space: all its meshes merged
688
+ * (position only, see mergedPositions), sampled on a 48³ grid with the BVH's
689
+ * closest-point query, sign from the hit face. Baked in slices so the page stays
690
+ * responsive; cached on the root until its geometry changes. ~100–300 ms for a
691
+ * typical model on a desktop CPU — do it at load (pre-bake), never on trigger.
692
+ */
693
+ export async function bakeObjectSdf(root) {
694
+ const key = geometryKey(root);
695
+ if (!key) {
696
+ console.warn('[harmonicShell] target object has no meshes to bake', root.name || root.uuid);
697
+ return null;
698
+ }
699
+ const hit = sdf3Cache.get(root);
700
+ if (hit && hit.key === key)
701
+ return hit.sdf;
702
+ const t0 = performance.now();
703
+ const geometry = mergedPositions(root);
704
+ if (!geometry) {
705
+ console.warn('[harmonicShell] target object has no geometry to bake');
706
+ return null;
707
+ }
708
+ const bvh = new MeshBVH(geometry);
709
+ geometry.computeBoundingBox();
710
+ const bb = geometry.boundingBox.clone();
711
+ bb.expandByScalar(bb.getSize(new THREE.Vector3()).length() * 0.12);
712
+ const min = bb.min.clone(), size = bb.getSize(new THREE.Vector3());
713
+ // cells follow the shape: a wide flat word gets more across and fewer through, same budget
714
+ const mean = Math.cbrt(Math.max(size.x * size.y * size.z, 1e-9));
715
+ const res = (v) => Math.max(8, Math.min(160, Math.round((SDF3_N * v) / mean)));
716
+ const NX = res(size.x), NY = res(size.y), NZ = res(size.z);
717
+ const data = new Uint16Array(NX * NY * NZ);
718
+ const p = new THREE.Vector3(), tri = new THREE.Triangle(), n = new THREE.Vector3(), d = new THREE.Vector3();
719
+ const pos = geometry.attributes.position, idx = geometry.index;
720
+ const target = { point: new THREE.Vector3(), distance: 0, faceIndex: 0 };
721
+ for (let z = 0; z < NZ; z++) {
722
+ for (let y = 0; y < NY; y++)
723
+ for (let x = 0; x < NX; x++) {
724
+ p.set(min.x + (size.x * (x + 0.5)) / NX, min.y + (size.y * (y + 0.5)) / NY, min.z + (size.z * (z + 0.5)) / NZ);
725
+ bvh.closestPointToPoint(p, target);
726
+ const f = target.faceIndex * 3;
727
+ const i0 = idx ? idx.getX(f) : f, i1 = idx ? idx.getX(f + 1) : f + 1, i2 = idx ? idx.getX(f + 2) : f + 2;
728
+ tri.a.fromBufferAttribute(pos, i0);
729
+ tri.b.fromBufferAttribute(pos, i1);
730
+ tri.c.fromBufferAttribute(pos, i2);
731
+ tri.getNormal(n);
732
+ const sign = n.dot(d.copy(p).sub(target.point)) < 0 ? -1 : 1;
733
+ data[x + NX * (y + NY * z)] = THREE.DataUtils.toHalfFloat(sign * target.distance);
734
+ }
735
+ // a slice per task: the studio stays live while a big model bakes
736
+ if (z % 4 === 3)
737
+ await new Promise(r => setTimeout(r, 0));
738
+ }
739
+ geometry.dispose();
740
+ const tex = new THREE.Data3DTexture(data, NX, NY, NZ);
741
+ tex.format = THREE.RedFormat;
742
+ tex.type = THREE.HalfFloatType;
743
+ tex.minFilter = tex.magFilter = THREE.LinearFilter;
744
+ tex.unpackAlignment = 1;
745
+ tex.needsUpdate = true;
746
+ const sdf = { tex, min, size };
747
+ const old = sdf3Cache.get(root);
748
+ old?.sdf.tex.dispose();
749
+ sdf3Cache.set(root, { key, sdf });
750
+ console.info(`[harmonicShell] baked ${NX}×${NY}×${NZ} field of "${root.name || root.uuid}" in ${(performance.now() - t0).toFixed(0)} ms`);
751
+ return sdf;
752
+ }