jsgui3-server 0.0.133 → 0.0.135
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.
- package/AGENTS.md +6 -0
- package/README.md +114 -18
- package/TODO.md +81 -0
- package/cli.js +96 -0
- package/docs/simple-server-api-design.md +702 -0
- package/examples/controls/14) window, canvas/client.js +238 -0
- package/examples/controls/14) window, canvas/server.js +21 -0
- package/examples/controls/14b) window, canvas (improved renderer)/client.js +391 -0
- package/examples/controls/14b) window, canvas (improved renderer)/server.js +21 -0
- package/examples/controls/14d) window, canvas globe/Clipping.js +0 -0
- package/examples/controls/14d) window, canvas globe/EarthGlobeRenderer.js +435 -0
- package/examples/controls/14d) window, canvas globe/RenderingPipeline.js +43 -0
- package/examples/controls/14d) window, canvas globe/client.js +95 -0
- package/examples/controls/14d) window, canvas globe/math.js +76 -0
- package/examples/controls/14d) window, canvas globe/pipeline/BaseStage.js +46 -0
- package/examples/controls/14d) window, canvas globe/pipeline/ClippingStage.js +29 -0
- package/examples/controls/14d) window, canvas globe/pipeline/ComposeStage.js +15 -0
- package/examples/controls/14d) window, canvas globe/pipeline/ContinentsStage.js +116 -0
- package/examples/controls/14d) window, canvas globe/pipeline/GridStage.js +105 -0
- package/examples/controls/14d) window, canvas globe/pipeline/HUDStage.js +10 -0
- package/examples/controls/14d) window, canvas globe/pipeline/ShadeSphereStage.js +153 -0
- package/examples/controls/14d) window, canvas globe/pipeline/TransformStage.js +23 -0
- package/examples/controls/14d) window, canvas globe/pipeline/clipping/FrontFaceStrategy.js +46 -0
- package/examples/controls/14d) window, canvas globe/pipeline/clipping/PlaneClipStrategy.js +339 -0
- package/examples/controls/14d) window, canvas globe/server.js +21 -0
- package/examples/controls/14e) window, canvas multithreaded/client.js +948 -0
- package/examples/controls/14e) window, canvas multithreaded/server.js +21 -0
- package/examples/controls/14f) window, canvas polyglobe/client.js +569 -0
- package/examples/controls/14f) window, canvas polyglobe/math.js +137 -0
- package/examples/controls/14f) window, canvas polyglobe/server.js +21 -0
- package/module.js +3 -1
- package/package.json +12 -6
- package/resources/_old_website-resource.js +10 -2
- package/resources/jsbuilder/babel/deep_iterate/deep_iterate_babel.js +37 -0
- package/serve-factory.js +221 -0
- package/serve-helpers.js +95 -0
- package/server.js +93 -7
- package/tests/cli.test.js +66 -0
- package/tests/dummy-client.js +10 -0
- package/tests/serve.test.js +210 -0
- package/.vscode/settings.json +0 -6
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const jsgui = require('./client');
|
|
2
|
+
const {Demo_UI} = jsgui.controls;
|
|
3
|
+
const Server = require('../../../server');
|
|
4
|
+
if (require.main === module) {
|
|
5
|
+
const server = new Server({
|
|
6
|
+
Ctrl: Demo_UI,
|
|
7
|
+
debug: true,
|
|
8
|
+
'src_path_client_js': require.resolve('./client.js'),
|
|
9
|
+
});
|
|
10
|
+
console.log('waiting for server ready event');
|
|
11
|
+
server.one('ready', () => {
|
|
12
|
+
console.log('server ready');
|
|
13
|
+
server.start(52000, function (err, cb_start) {
|
|
14
|
+
if (err) {
|
|
15
|
+
throw err;
|
|
16
|
+
} else {
|
|
17
|
+
console.log('server started');
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
})
|
|
21
|
+
}
|
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
|
|
2
|
+
const jsgui = require('jsgui3-client');
|
|
3
|
+
const {controls} = jsgui;
|
|
4
|
+
const Active_HTML_Document = require('../../../controls/Active_HTML_Document');
|
|
5
|
+
|
|
6
|
+
// Shared math helpers
|
|
7
|
+
const M = require('./math'); // <- math.js in the same folder
|
|
8
|
+
|
|
9
|
+
// ----------------------------------------------------------------------------
|
|
10
|
+
// HexPentGlobeRenderer
|
|
11
|
+
// Renders a "soccer-ball" style Earth made of hexagons + 12 pentagons:
|
|
12
|
+
// 1) Build a geodesic sphere by subdividing an icosahedron (levels = 0..4 typical)
|
|
13
|
+
// 2) Project vertices to unit sphere
|
|
14
|
+
// 3) Build the dual mesh: one cell per original vertex; cell vertices are the
|
|
15
|
+
// centers of faces incident to that vertex (sorted around the vertex).
|
|
16
|
+
// 4) Shade each cell with simple directional lighting.
|
|
17
|
+
// ----------------------------------------------------------------------------
|
|
18
|
+
class HexPentGlobeRenderer {
|
|
19
|
+
constructor(canvas, opts = {}) {
|
|
20
|
+
this.canvas = typeof canvas === 'string' ? document.getElementById(canvas) : canvas;
|
|
21
|
+
if (!(this.canvas instanceof HTMLCanvasElement)) throw new Error('Pass a canvas element or its id');
|
|
22
|
+
this.ctx = this.canvas.getContext('2d', { alpha: true });
|
|
23
|
+
|
|
24
|
+
// -------- Options --------
|
|
25
|
+
const visual = opts.visual || {};
|
|
26
|
+
const stroke = visual.stroke || {};
|
|
27
|
+
this.opts = {
|
|
28
|
+
padding: opts.padding ?? 8,
|
|
29
|
+
background: opts.background ?? '#081019',
|
|
30
|
+
|
|
31
|
+
// Mesh detail: levels=0 gives raw icosahedron; each level doubles edge frequency (n = 2^levels).
|
|
32
|
+
levels: Math.max(0, Math.min(5, opts.levels ?? 3)),
|
|
33
|
+
|
|
34
|
+
// Lighting
|
|
35
|
+
ambient: opts.ambient ?? 0.15,
|
|
36
|
+
diffuse: opts.diffuse ?? 0.95,
|
|
37
|
+
specular: opts.specular ?? 0.25,
|
|
38
|
+
shininess: opts.shininess ?? 60,
|
|
39
|
+
|
|
40
|
+
// Colors
|
|
41
|
+
baseColor: opts.baseColor ?? [0.13, 0.42, 0.86], // linear-ish RGB 0..1
|
|
42
|
+
pentagonTint: opts.pentagonTint ?? [0.08, 0.10, 0.14], // extra tint for 12 pentagons
|
|
43
|
+
cellJitter: opts.cellJitter ?? 0.06, // subtle per-cell brightness randomization
|
|
44
|
+
|
|
45
|
+
// Strokes
|
|
46
|
+
visual: {
|
|
47
|
+
fillAlpha: visual.fillAlpha ?? 1.0,
|
|
48
|
+
strokeAlpha: visual.strokeAlpha ?? 0.22,
|
|
49
|
+
strokeWidth: stroke.width ?? 0.8,
|
|
50
|
+
strokeColor: stroke.color ?? '#FFFFFF'
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// Interaction
|
|
54
|
+
inertiaFriction: opts.inertiaFriction ?? 2.6,
|
|
55
|
+
inertiaMinSpeed: opts.inertiaMinSpeed ?? 0.05,
|
|
56
|
+
dragInvertX: opts.dragInvertX ?? false,
|
|
57
|
+
dragInvertY: opts.dragInvertY ?? false,
|
|
58
|
+
dragSwapAxes: opts.dragSwapAxes ?? false,
|
|
59
|
+
dragSensitivity: opts.dragSensitivity ?? 1.0,
|
|
60
|
+
|
|
61
|
+
dpr: window.devicePixelRatio || 1
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Sun (unit, camera frame)
|
|
65
|
+
this.sun = M.vec3.norm([-0.45, 0.55, 0.65]);
|
|
66
|
+
|
|
67
|
+
// Orientation (Arcball quaternion) and drag state
|
|
68
|
+
this.Q = [0,0,0,1]; // [x,y,z,w]
|
|
69
|
+
this._dragging = false;
|
|
70
|
+
this._pickObj = null; // object-space unit vector of picked point
|
|
71
|
+
this._updateR();
|
|
72
|
+
|
|
73
|
+
// Build geodesic-dual mesh once
|
|
74
|
+
this._buildCells();
|
|
75
|
+
|
|
76
|
+
this._attachPointerHandlers();
|
|
77
|
+
|
|
78
|
+
// Initial draw
|
|
79
|
+
this.resize();
|
|
80
|
+
this.render();
|
|
81
|
+
|
|
82
|
+
this._onResize = () => { this.resize(); this.render(); };
|
|
83
|
+
window.addEventListener('resize', this._onResize, { passive: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
destroy() {
|
|
87
|
+
window.removeEventListener('resize', this._onResize);
|
|
88
|
+
this._detachPointerHandlers();
|
|
89
|
+
if (this._animHandle) cancelAnimationFrame(this._animHandle);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// -------- Public API --------
|
|
93
|
+
setSunDirection(vec3) { this.sun = M.vec3.norm(vec3); this.render(); }
|
|
94
|
+
setSunFromSpherical(lonDeg, latDeg) {
|
|
95
|
+
const lon = M.deg2rad(lonDeg), lat = M.deg2rad(latDeg);
|
|
96
|
+
const cx = Math.cos(lat), sx = Math.sin(lat), sz = Math.sin(lon), cz = Math.cos(lon);
|
|
97
|
+
this.setSunDirection([ cx*sz, sx, cx*cz ]);
|
|
98
|
+
}
|
|
99
|
+
setRotation(yawDeg, pitchDeg) {
|
|
100
|
+
// Preserve API: map yaw/pitch to quaternion for convenience
|
|
101
|
+
const yaw = M.deg2rad(yawDeg), pitch = M.deg2rad(pitchDeg);
|
|
102
|
+
const qy = M.quat.fromAxisAngle(0,1,0, yaw);
|
|
103
|
+
const qx = M.quat.fromAxisAngle(1,0,0, pitch);
|
|
104
|
+
this.Q = M.quat.normalize(M.quat.mul(qx, qy));
|
|
105
|
+
this._updateR(); this.render();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
resize() {
|
|
109
|
+
const dpr = (this.opts.dpr = window.devicePixelRatio || 1);
|
|
110
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
111
|
+
const displayWidth = rect.width || this.canvas.width;
|
|
112
|
+
const displayHeight = rect.height || this.canvas.height;
|
|
113
|
+
const w = Math.max(1, Math.round(displayWidth * dpr));
|
|
114
|
+
const h = Math.max(1, Math.round(displayHeight * dpr));
|
|
115
|
+
if (this.canvas.width !== w || this.canvas.height !== h) {
|
|
116
|
+
this.canvas.width = w; this.canvas.height = h;
|
|
117
|
+
}
|
|
118
|
+
this.ctx.setTransform(1,0,0,1,0,0);
|
|
119
|
+
this.ctx.scale(dpr, dpr);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// -------- Core rendering --------
|
|
123
|
+
render() {
|
|
124
|
+
const ctx = this.ctx;
|
|
125
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
126
|
+
const width = rect.width || this.canvas.width / this.opts.dpr;
|
|
127
|
+
const height = rect.height || this.canvas.height / this.opts.dpr;
|
|
128
|
+
|
|
129
|
+
// Background
|
|
130
|
+
ctx.clearRect(0, 0, width, height);
|
|
131
|
+
if (this.opts.background) {
|
|
132
|
+
ctx.fillStyle = this.opts.background;
|
|
133
|
+
ctx.fillRect(0, 0, width, height);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const pad = this.opts.padding;
|
|
137
|
+
const r = Math.max(1, Math.min(width, height) / 2 - pad);
|
|
138
|
+
const cx = width / 2, cy = height / 2;
|
|
139
|
+
|
|
140
|
+
// Clip to disc
|
|
141
|
+
ctx.save();
|
|
142
|
+
ctx.beginPath();
|
|
143
|
+
ctx.arc(cx, cy, r, 0, Math.PI*2);
|
|
144
|
+
ctx.clip();
|
|
145
|
+
|
|
146
|
+
// Prepare transforms + lighting
|
|
147
|
+
this._updateR();
|
|
148
|
+
const R = this._R;
|
|
149
|
+
const L = this.sun;
|
|
150
|
+
|
|
151
|
+
// Rotate all face centers once per frame
|
|
152
|
+
const C = this._faceCenters; // [Nf][3]
|
|
153
|
+
const Cr = this._rotCenters; // rotated centers cached per frame
|
|
154
|
+
const nF = C.length;
|
|
155
|
+
for (let i = 0; i < nF; i++) {
|
|
156
|
+
const c = C[i];
|
|
157
|
+
Cr[i][0] = R[0]*c[0] + R[1]*c[1] + R[2]*c[2];
|
|
158
|
+
Cr[i][1] = R[3]*c[0] + R[4]*c[1] + R[5]*c[2];
|
|
159
|
+
Cr[i][2] = R[6]*c[0] + R[7]*c[1] + R[8]*c[2];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// For each cell (one per original vertex) draw polygon
|
|
163
|
+
const cells = this._cells; // {v:[x,y,z], idxs:[faceIndices...], pent:boolean, jitter:number}
|
|
164
|
+
const fillAlpha = this.opts.visual.fillAlpha;
|
|
165
|
+
const strokeAlpha = this.opts.visual.strokeAlpha;
|
|
166
|
+
const strokeColor = this.opts.visual.strokeColor;
|
|
167
|
+
const strokeWidth = this.opts.visual.strokeWidth;
|
|
168
|
+
|
|
169
|
+
ctx.lineWidth = strokeWidth;
|
|
170
|
+
ctx.strokeStyle = strokeColor;
|
|
171
|
+
ctx.globalAlpha = 1;
|
|
172
|
+
|
|
173
|
+
// Sort cells back-to-front by depth of cell center (painter's algorithm within clipped disk)
|
|
174
|
+
// Using rotated vertex (cell center) depth
|
|
175
|
+
const sortTmp = this._sorted;
|
|
176
|
+
for (let i = 0; i < cells.length; i++) {
|
|
177
|
+
const v = cells[i].v; // original unit vertex
|
|
178
|
+
const vx = R[0]*v[0] + R[1]*v[1] + R[2]*v[2];
|
|
179
|
+
const vy = R[3]*v[0] + R[4]*v[1] + R[5]*v[2];
|
|
180
|
+
const vz = R[6]*v[0] + R[7]*v[1] + R[8]*v[2];
|
|
181
|
+
sortTmp[i].z = vz;
|
|
182
|
+
sortTmp[i].i = i;
|
|
183
|
+
// Store rotated center for shading
|
|
184
|
+
cells[i]._vr = [vx, vy, vz];
|
|
185
|
+
}
|
|
186
|
+
sortTmp.sort((a,b)=> b.z - a.z); // draw far to near would overdraw wrongly; we clip anyway.
|
|
187
|
+
// We'll actually draw near-to-far so closer edges sit above. Use ascending z:
|
|
188
|
+
sortTmp.reverse();
|
|
189
|
+
|
|
190
|
+
for (let si = 0; si < sortTmp.length; si++) {
|
|
191
|
+
const cidx = sortTmp[si].i;
|
|
192
|
+
const cell = cells[cidx];
|
|
193
|
+
const vr = cell._vr;
|
|
194
|
+
|
|
195
|
+
// Cull back hemisphere by center
|
|
196
|
+
if (vr[2] <= 0) continue;
|
|
197
|
+
|
|
198
|
+
// Lighting for the whole cell (use cell normal ~ vr)
|
|
199
|
+
const NL = Math.max(0, vr[0]*L[0] + vr[1]*L[1] + vr[2]*L[2]);
|
|
200
|
+
const diff = this.opts.diffuse * NL;
|
|
201
|
+
const amb = this.opts.ambient;
|
|
202
|
+
const spec = this.opts.specular * Math.pow(Math.max(0, NL), this.opts.shininess/100); // subtle
|
|
203
|
+
|
|
204
|
+
// Base color (+ pentagon tint + per-cell jitter)
|
|
205
|
+
let rLin = this.opts.baseColor[0];
|
|
206
|
+
let gLin = this.opts.baseColor[1];
|
|
207
|
+
let bLin = this.opts.baseColor[2];
|
|
208
|
+
|
|
209
|
+
if (cell.pent) {
|
|
210
|
+
rLin += this.opts.pentagonTint[0];
|
|
211
|
+
gLin += this.opts.pentagonTint[1];
|
|
212
|
+
bLin += this.opts.pentagonTint[2];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const jit = cell.jitter;
|
|
216
|
+
rLin = Math.max(0, rLin + jit); gLin = Math.max(0, gLin + jit); bLin = Math.max(0, bLin + jit);
|
|
217
|
+
|
|
218
|
+
// Final lighting and simple tone map
|
|
219
|
+
rLin = M.toneMap(rLin*(amb + diff) + spec);
|
|
220
|
+
gLin = M.toneMap(gLin*(amb + diff) + spec);
|
|
221
|
+
bLin = M.toneMap(bLin*(amb + diff) + spec);
|
|
222
|
+
|
|
223
|
+
const Rsrgb = Math.round(M.toSRGB_linear(rLin)*255);
|
|
224
|
+
const Gsrgb = Math.round(M.toSRGB_linear(gLin)*255);
|
|
225
|
+
const Bsrgb = Math.round(M.toSRGB_linear(bLin)*255);
|
|
226
|
+
|
|
227
|
+
// Fill color
|
|
228
|
+
this.ctx.fillStyle = 'rgba(' + Rsrgb + ',' + Gsrgb + ',' + Bsrgb + ',' + fillAlpha + ')';
|
|
229
|
+
this.ctx.globalAlpha = 1;
|
|
230
|
+
|
|
231
|
+
// Build polygon path from rotated face-centers
|
|
232
|
+
const poly = cell.idxs;
|
|
233
|
+
this.ctx.beginPath();
|
|
234
|
+
for (let k = 0; k < poly.length; k++) {
|
|
235
|
+
const P = Cr[poly[k]];
|
|
236
|
+
const x = cx + r * P[0];
|
|
237
|
+
const y = cy - r * P[1];
|
|
238
|
+
if (k === 0) this.ctx.moveTo(x, y); else this.ctx.lineTo(x, y);
|
|
239
|
+
}
|
|
240
|
+
this.ctx.closePath();
|
|
241
|
+
this.ctx.fill();
|
|
242
|
+
|
|
243
|
+
// Stroke (draw after fill)
|
|
244
|
+
this.ctx.save();
|
|
245
|
+
this.ctx.globalAlpha = strokeAlpha * (cell.pent ? 1.25 : 1.0);
|
|
246
|
+
this.ctx.stroke();
|
|
247
|
+
this.ctx.restore();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
ctx.restore();
|
|
251
|
+
|
|
252
|
+
// Subtle globe outline
|
|
253
|
+
ctx.beginPath();
|
|
254
|
+
ctx.arc(cx, cy, r, 0, Math.PI*2);
|
|
255
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
|
256
|
+
ctx.lineWidth = 1.25;
|
|
257
|
+
ctx.stroke();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// -------- Mesh generation (geodesic icosahedron → dual) --------
|
|
261
|
+
_buildCells() {
|
|
262
|
+
// 1) Icosahedron
|
|
263
|
+
const ico = this._icosahedron();
|
|
264
|
+
let V = ico.vertices; // Array<[x,y,z]>
|
|
265
|
+
let F = ico.faces; // Array<[i,j,k]>
|
|
266
|
+
|
|
267
|
+
// 2) Subdivide by levels (Loop-like midpoint; each iteration splits tri into 4)
|
|
268
|
+
for (let l = 0; l < this.opts.levels; l++) {
|
|
269
|
+
const nextFaces = [];
|
|
270
|
+
const midCache = new Map(); // key "a,b" with a<b -> vertex index
|
|
271
|
+
const getKey = (a,b)=> a<b ? (a+'_'+b) : (b+'_'+a);
|
|
272
|
+
const midpoint = (a,b)=>{
|
|
273
|
+
const key = getKey(a,b);
|
|
274
|
+
if (midCache.has(key)) return midCache.get(key);
|
|
275
|
+
const pa = V[a], pb = V[b];
|
|
276
|
+
const m = M.vec3.norm([ (pa[0]+pb[0]), (pa[1]+pb[1]), (pa[2]+pb[2]) ]);
|
|
277
|
+
const idx = V.push(m) - 1;
|
|
278
|
+
midCache.set(key, idx);
|
|
279
|
+
return idx;
|
|
280
|
+
};
|
|
281
|
+
for (let fi = 0; fi < F.length; fi++) {
|
|
282
|
+
const a = F[fi][0], b = F[fi][1], c = F[fi][2];
|
|
283
|
+
const ab = midpoint(a,b);
|
|
284
|
+
const bc = midpoint(b,c);
|
|
285
|
+
const ca = midpoint(c,a);
|
|
286
|
+
nextFaces.push([a, ab, ca]);
|
|
287
|
+
nextFaces.push([b, bc, ab]);
|
|
288
|
+
nextFaces.push([c, ca, bc]);
|
|
289
|
+
nextFaces.push([ab, bc, ca]);
|
|
290
|
+
}
|
|
291
|
+
F = nextFaces;
|
|
292
|
+
// vertices already normalized via midpoint step; keep them on unit sphere
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Ensure exact normalization (avoid drift)
|
|
296
|
+
for (let i = 0; i < V.length; i++) {
|
|
297
|
+
V[i] = M.vec3.norm(V[i]);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 3) Face centers (normalized)
|
|
301
|
+
const faceCenters = new Array(F.length);
|
|
302
|
+
for (let i = 0; i < F.length; i++) {
|
|
303
|
+
const a = V[F[i][0]], b = V[F[i][1]], c = V[F[i][2]];
|
|
304
|
+
const s = [ (a[0]+b[0]+c[0])/3, (a[1]+b[1]+c[1])/3, (a[2]+b[2]+c[2])/3 ];
|
|
305
|
+
faceCenters[i] = M.vec3.norm(s);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 4) Build incident face list per vertex
|
|
309
|
+
const inc = new Array(V.length);
|
|
310
|
+
for (let i = 0; i < V.length; i++) inc[i] = [];
|
|
311
|
+
for (let fi = 0; fi < F.length; fi++) {
|
|
312
|
+
const tri = F[fi];
|
|
313
|
+
inc[tri[0]].push(fi);
|
|
314
|
+
inc[tri[1]].push(fi);
|
|
315
|
+
inc[tri[2]].push(fi);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 5) For each vertex, sort its incident face-centers around the vertex to form a polygon
|
|
319
|
+
const cells = [];
|
|
320
|
+
const rotCenters = new Array(faceCenters.length);
|
|
321
|
+
for (let i = 0; i < faceCenters.length; i++) rotCenters[i] = [0,0,0];
|
|
322
|
+
|
|
323
|
+
// local tangent basis per vertex for angle sort
|
|
324
|
+
for (let vi = 0; vi < V.length; vi++) {
|
|
325
|
+
const v = V[vi];
|
|
326
|
+
const facesAround = inc[vi];
|
|
327
|
+
|
|
328
|
+
// Build local basis (u,w) perp to v
|
|
329
|
+
let ref = Math.abs(v[1]) < 0.9 ? [0,1,0] : [1,0,0];
|
|
330
|
+
const u = M.vec3.norm(M.vec3.cross(ref, v));
|
|
331
|
+
const w = M.vec3.cross(v, u); // already unit since u and v unit & perpendicular
|
|
332
|
+
|
|
333
|
+
// compute angle of each incident face center around v
|
|
334
|
+
const withAngles = new Array(facesAround.length);
|
|
335
|
+
for (let k = 0; k < facesAround.length; k++) {
|
|
336
|
+
const fc = faceCenters[facesAround[k]];
|
|
337
|
+
// project onto tangent plane
|
|
338
|
+
const dot_v = fc[0]*v[0] + fc[1]*v[1] + fc[2]*v[2];
|
|
339
|
+
const t = [ fc[0] - dot_v*v[0], fc[1] - dot_v*v[1], fc[2] - dot_v*v[2] ];
|
|
340
|
+
const x = t[0]*u[0] + t[1]*u[1] + t[2]*u[2];
|
|
341
|
+
const y = t[0]*w[0] + t[1]*w[1] + t[2]*w[2];
|
|
342
|
+
const ang = Math.atan2(y, x);
|
|
343
|
+
withAngles[k] = { fi: facesAround[k], ang };
|
|
344
|
+
}
|
|
345
|
+
withAngles.sort((a,b)=> a.ang - b.ang);
|
|
346
|
+
const idxs = withAngles.map(o => o.fi);
|
|
347
|
+
|
|
348
|
+
// Identify pentagons (valence 5 at the 12 original icosahedron vertices)
|
|
349
|
+
const isPent = idxs.length === 5;
|
|
350
|
+
|
|
351
|
+
// Per-cell color jitter (consistent pseudo-random from index)
|
|
352
|
+
const jitter = (hashInt(vi) * 2 - 1) * this.opts.cellJitter;
|
|
353
|
+
|
|
354
|
+
cells.push({ v: v, idxs: idxs, pent: isPent, jitter: jitter, _vr: [0,0,0] });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Sort helper buffer (reused each frame)
|
|
358
|
+
const sorted = new Array(cells.length);
|
|
359
|
+
for (let i = 0; i < sorted.length; i++) sorted[i] = { z: 0, i: 0 };
|
|
360
|
+
|
|
361
|
+
this._verts = V;
|
|
362
|
+
this._faces = F;
|
|
363
|
+
this._faceCenters = faceCenters;
|
|
364
|
+
this._rotCenters = rotCenters;
|
|
365
|
+
this._cells = cells;
|
|
366
|
+
this._sorted = sorted;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
_icosahedron() {
|
|
370
|
+
// Returns {vertices, faces} for a unit icosahedron (vertices normalized)
|
|
371
|
+
const t = (1 + Math.sqrt(5)) / 2; // golden ratio
|
|
372
|
+
const raw = [
|
|
373
|
+
[-1, t, 0], [ 1, t, 0], [-1, -t, 0], [ 1, -t, 0],
|
|
374
|
+
[ 0, -1, t], [ 0, 1, t], [ 0, -1, -t], [ 0, 1, -t],
|
|
375
|
+
[ t, 0, -1], [ t, 0, 1], [-t, 0, -1], [-t, 0, 1]
|
|
376
|
+
];
|
|
377
|
+
const vertices = raw.map(M.vec3.norm);
|
|
378
|
+
const faces = [
|
|
379
|
+
[0,11,5],[0,5,1],[0,1,7],[0,7,10],[0,10,11],
|
|
380
|
+
[1,5,9],[5,11,4],[11,10,2],[10,7,6],[7,1,8],
|
|
381
|
+
[3,9,4],[3,4,2],[3,2,6],[3,6,8],[3,8,9],
|
|
382
|
+
[4,9,5],[2,4,11],[6,2,10],[8,6,7],[9,8,1]
|
|
383
|
+
];
|
|
384
|
+
return { vertices, faces };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// -------- Rotation / math --------
|
|
388
|
+
_updateR() {
|
|
389
|
+
// Rotation matrix from quaternion
|
|
390
|
+
this._R = M.mat3.fromQuat(this.Q);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// -------- Interaction --------
|
|
394
|
+
_attachPointerHandlers() {
|
|
395
|
+
const el = this.canvas;
|
|
396
|
+
if (getComputedStyle(el).touchAction !== 'none') el.style.touchAction = 'none';
|
|
397
|
+
|
|
398
|
+
const onDown = (ev)=>{
|
|
399
|
+
const { cx, cy, r } = this._viewGeo();
|
|
400
|
+
const v0 = M.screenToArcball(ev.clientX, ev.clientY, cx, cy, r);
|
|
401
|
+
// Ignore presses far outside the disc
|
|
402
|
+
if (v0[2] === 0) {
|
|
403
|
+
const dx = (ev.clientX - cx) / r, dy = (cy - ev.clientY) / r;
|
|
404
|
+
if (dx*dx + dy*dy > 1.5*1.5) return;
|
|
405
|
+
}
|
|
406
|
+
this._updateR();
|
|
407
|
+
const R = this._R;
|
|
408
|
+
// pickObj = R^T * v0 (row-major R)
|
|
409
|
+
const px = R[0]*v0[0] + R[3]*v0[1] + R[6]*v0[2];
|
|
410
|
+
const py = R[1]*v0[0] + R[4]*v0[1] + R[7]*v0[2];
|
|
411
|
+
const pz = R[2]*v0[0] + R[5]*v0[1] + R[8]*v0[2];
|
|
412
|
+
const invLen = 1/Math.max(1e-12, Math.hypot(px,py,pz));
|
|
413
|
+
this._pickObj = [px*invLen, py*invLen, pz*invLen];
|
|
414
|
+
this._dragging = true;
|
|
415
|
+
try { el.setPointerCapture(ev.pointerId); } catch {}
|
|
416
|
+
};
|
|
417
|
+
const onMove = (ev)=>{
|
|
418
|
+
if (!this._dragging) return;
|
|
419
|
+
const { cx, cy, r } = this._viewGeo();
|
|
420
|
+
const v1 = M.screenToArcball(ev.clientX, ev.clientY, cx, cy, r);
|
|
421
|
+
if (!this._pickObj) return;
|
|
422
|
+
this._updateR();
|
|
423
|
+
const R = this._R;
|
|
424
|
+
// current camera-space position of picked object point
|
|
425
|
+
const cxp = R[0]*this._pickObj[0] + R[1]*this._pickObj[1] + R[2]*this._pickObj[2];
|
|
426
|
+
const cyp = R[3]*this._pickObj[0] + R[4]*this._pickObj[1] + R[5]*this._pickObj[2];
|
|
427
|
+
const czp = R[6]*this._pickObj[0] + R[7]*this._pickObj[1] + R[8]*this._pickObj[2];
|
|
428
|
+
const clen = Math.max(1e-12, Math.hypot(cxp,cyp,czp));
|
|
429
|
+
const c = [cxp/clen, cyp/clen, czp/clen];
|
|
430
|
+
// Compute stable axis/angle for c -> v1
|
|
431
|
+
let dot = c[0]*v1[0] + c[1]*v1[1] + c[2]*v1[2];
|
|
432
|
+
dot = Math.max(-1, Math.min(1, dot));
|
|
433
|
+
let angle = Math.acos(dot);
|
|
434
|
+
let axis = [ c[1]*v1[2] - c[2]*v1[1], c[2]*v1[0] - c[0]*v1[2], c[0]*v1[1] - c[1]*v1[0] ];
|
|
435
|
+
let an = Math.hypot(axis[0], axis[1], axis[2]);
|
|
436
|
+
if (an < 1e-8) {
|
|
437
|
+
// 0° or 180°: choose a stable axis orthogonal to c
|
|
438
|
+
const ref = Math.abs(c[1]) < 0.9 ? [0,1,0] : [1,0,0];
|
|
439
|
+
axis = [ c[1]*ref[2] - c[2]*ref[1], c[2]*ref[0] - c[0]*ref[2], c[0]*ref[1] - c[1]*ref[0] ];
|
|
440
|
+
an = Math.hypot(axis[0], axis[1], axis[2]);
|
|
441
|
+
if (an < 1e-8) axis = [0,1,0]; else { axis[0]/=an; axis[1]/=an; axis[2]/=an; }
|
|
442
|
+
} else {
|
|
443
|
+
axis[0]/=an; axis[1]/=an; axis[2]/=an;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Clamp large steps to avoid overshoot on slow frames
|
|
447
|
+
const MAX_STEP_ANGLE = 0.35; // ~20° per event
|
|
448
|
+
if (angle > MAX_STEP_ANGLE) angle = MAX_STEP_ANGLE;
|
|
449
|
+
|
|
450
|
+
if (angle < 1e-6) { return; }
|
|
451
|
+
const dq = M.quat.fromAxisAngle(axis[0], axis[1], axis[2], angle);
|
|
452
|
+
const qNew = M.quat.norm(M.quat.mul(dq, this.Q));
|
|
453
|
+
this.Q = [qNew[0], qNew[1], qNew[2], qNew[3]];
|
|
454
|
+
this._updateR();
|
|
455
|
+
this.render();
|
|
456
|
+
};
|
|
457
|
+
const onUp = (ev)=>{
|
|
458
|
+
this._dragging = false;
|
|
459
|
+
try { el.releasePointerCapture(ev.pointerId); } catch {}
|
|
460
|
+
this._pickObj = null;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
el.addEventListener('pointerdown', onDown);
|
|
464
|
+
el.addEventListener('pointermove', onMove);
|
|
465
|
+
el.addEventListener('pointerup', onUp);
|
|
466
|
+
el.addEventListener('pointercancel', onUp);
|
|
467
|
+
this._ptr = { onDown, onMove, onUp };
|
|
468
|
+
}
|
|
469
|
+
_detachPointerHandlers(){
|
|
470
|
+
if (!this._ptr) return;
|
|
471
|
+
const el = this.canvas;
|
|
472
|
+
el.removeEventListener('pointerdown', this._ptr.onDown);
|
|
473
|
+
el.removeEventListener('pointermove', this._ptr.onMove);
|
|
474
|
+
el.removeEventListener('pointerup', this._ptr.onUp);
|
|
475
|
+
el.removeEventListener('pointercancel', this._ptr.onUp);
|
|
476
|
+
this._ptr = null;
|
|
477
|
+
}
|
|
478
|
+
// no inertia with arcball; rotation is directly under the cursor
|
|
479
|
+
|
|
480
|
+
_viewGeo() {
|
|
481
|
+
// center (in client px), radius in client px
|
|
482
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
483
|
+
const cx = rect.left + rect.width/2;
|
|
484
|
+
const cy = rect.top + rect.height/2;
|
|
485
|
+
const r = Math.max(1, Math.min(rect.width, rect.height)/2 - (this.opts.padding||0));
|
|
486
|
+
return { cx, cy, r };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// (no arcball helpers needed for yaw/pitch)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Simple integer hash → [0,1)
|
|
493
|
+
function hashInt(i){
|
|
494
|
+
// xorshift-ish; stable across runs for visual jitter
|
|
495
|
+
let x = (i>>>0) + 0x9e3779b9;
|
|
496
|
+
x ^= x << 13; x ^= x >>> 17; x ^= x << 5;
|
|
497
|
+
return ((x>>>0) % 1000) / 1000;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ----------------------------------------------------------------------------
|
|
501
|
+
// Demo UI wiring (kept minimal; same structure you used previously)
|
|
502
|
+
// ----------------------------------------------------------------------------
|
|
503
|
+
class Demo_UI extends Active_HTML_Document {
|
|
504
|
+
constructor(spec = {}) {
|
|
505
|
+
spec.__type_name = spec.__type_name || 'demo_ui';
|
|
506
|
+
super(spec);
|
|
507
|
+
const {context} = this;
|
|
508
|
+
if (typeof this.body.add_class === 'function') this.body.add_class('demo-ui');
|
|
509
|
+
|
|
510
|
+
const compose = () => {
|
|
511
|
+
const windowCtrl = new controls.Window({
|
|
512
|
+
context,
|
|
513
|
+
title: 'Hex/Pent Globe',
|
|
514
|
+
pos: [5, 5]
|
|
515
|
+
});
|
|
516
|
+
windowCtrl.size = [1000, 1000];
|
|
517
|
+
|
|
518
|
+
const canvas = new controls.canvas({ context });
|
|
519
|
+
canvas.dom.attributes.id = 'globeCanvas';
|
|
520
|
+
canvas.size = [900, 900];
|
|
521
|
+
|
|
522
|
+
windowCtrl.inner.add(canvas);
|
|
523
|
+
this.body.add(windowCtrl);
|
|
524
|
+
|
|
525
|
+
this._ctrl_fields = this._ctrl_fields || {};
|
|
526
|
+
this._ctrl_fields.canvas = this.canvas = canvas;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
if (!spec.el) compose();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
activate() {
|
|
533
|
+
if (!this.__active) {
|
|
534
|
+
super.activate();
|
|
535
|
+
const {context} = this;
|
|
536
|
+
console.log('activate Demo_UI');
|
|
537
|
+
|
|
538
|
+
const globe = new HexPentGlobeRenderer('globeCanvas', {
|
|
539
|
+
background: '#081019',
|
|
540
|
+
// Increase polygons: each level ~x4 cells; 5 -> ~10242 cells (~16x from 3)
|
|
541
|
+
levels: 4,
|
|
542
|
+
ambient: 0.18,
|
|
543
|
+
diffuse: 0.95,
|
|
544
|
+
specular: 0.22,
|
|
545
|
+
shininess: 70,
|
|
546
|
+
visual: {
|
|
547
|
+
fillAlpha: 1.0,
|
|
548
|
+
strokeAlpha: 0.22,
|
|
549
|
+
stroke: { width: 0.8, color: '#FFFFFF' }
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
globe.setSunFromSpherical(-35, 25);
|
|
554
|
+
|
|
555
|
+
context.on('window-resize', () => {
|
|
556
|
+
globe.resize();
|
|
557
|
+
globe.render();
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
Demo_UI.css = `
|
|
563
|
+
*{margin:0;padding:0}
|
|
564
|
+
body{overflow-x:hidden;overflow-y:hidden;background-color:#E0E0E0}
|
|
565
|
+
.demo-ui{}
|
|
566
|
+
`;
|
|
567
|
+
|
|
568
|
+
controls.Demo_UI = Demo_UI;
|
|
569
|
+
module.exports = jsgui;
|