hic-pageflip 1.0.0
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/LICENSE +21 -0
- package/README.md +322 -0
- package/components/hic-pageflip-page.js +43 -0
- package/components/hic-pageflip.js +339 -0
- package/core/engines/engine-2d.js +470 -0
- package/core/engines/engine-3d.js +699 -0
- package/core/engines/engine-base.js +73 -0
- package/core/pageflip.js +657 -0
- package/core/utils/math.js +186 -0
- package/index.js +8 -0
- package/package.json +33 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML-in-Canvas 3D WebGL Pageflip Engine (HICPageflipEngine3D)
|
|
3
|
+
* Complete implementation based on Chris Luke's "The Anatomy of a Page Curl":
|
|
4
|
+
* https://blog.flirble.org/2010/10/08/the-anatomy-of-a-page-curl/
|
|
5
|
+
* Captures live DOM elements using gl.texElementImage2D and maps geometry via canvas.updateElementGeometry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { BaseEngine } from './engine-base.js';
|
|
9
|
+
import { clamp } from '../utils/math.js';
|
|
10
|
+
|
|
11
|
+
export class HICPageflipEngine3D extends BaseEngine {
|
|
12
|
+
constructor(canvas, slides = []) {
|
|
13
|
+
super(canvas, slides);
|
|
14
|
+
this.gl = null;
|
|
15
|
+
this.program = null;
|
|
16
|
+
|
|
17
|
+
// Mesh resolution (Chris Luke cylinder grid)
|
|
18
|
+
this.gridX = 80;
|
|
19
|
+
this.gridY = 60;
|
|
20
|
+
|
|
21
|
+
this.meshBuffers = null;
|
|
22
|
+
this.textures = new Map();
|
|
23
|
+
this.isReloadingTextures = false;
|
|
24
|
+
|
|
25
|
+
this.initGL();
|
|
26
|
+
this.resize();
|
|
27
|
+
if (this.slides) {
|
|
28
|
+
this.slides.forEach((s) => {
|
|
29
|
+
if (s.element) s.element.style.transform = 'none';
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
this.preloadSlideTextures();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setDimensions(pw, ph) {
|
|
36
|
+
this.pw = pw;
|
|
37
|
+
this.ph = ph;
|
|
38
|
+
this.resize();
|
|
39
|
+
this.preloadSlideTextures();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
initGL() {
|
|
43
|
+
const glOpts = {
|
|
44
|
+
alpha: true,
|
|
45
|
+
antialias: true,
|
|
46
|
+
premultipliedAlpha: false
|
|
47
|
+
};
|
|
48
|
+
const gl = this.canvas.getContext('webgl2', glOpts) ||
|
|
49
|
+
this.canvas.getContext('webgl', glOpts) ||
|
|
50
|
+
this.canvas.getContext('experimental-webgl', glOpts);
|
|
51
|
+
|
|
52
|
+
if (!gl) {
|
|
53
|
+
console.error('WebGL is not supported in this browser.');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.gl = gl;
|
|
57
|
+
|
|
58
|
+
gl.enable(gl.DEPTH_TEST);
|
|
59
|
+
gl.depthFunc(gl.LEQUAL);
|
|
60
|
+
gl.enable(gl.BLEND);
|
|
61
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
62
|
+
gl.disable(gl.CULL_FACE);
|
|
63
|
+
|
|
64
|
+
this.initShaders();
|
|
65
|
+
this.initBuffers();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
initShaders() {
|
|
69
|
+
const gl = this.gl;
|
|
70
|
+
|
|
71
|
+
// Vertex Shader: Chris Luke's Exact 3D Cylindrical Page Curl Algorithm
|
|
72
|
+
const vsSource = `
|
|
73
|
+
attribute vec2 aPosition; // [0, 1] UV coordinates on page sheet
|
|
74
|
+
uniform mat4 uProjection;
|
|
75
|
+
uniform mat4 uModelView;
|
|
76
|
+
|
|
77
|
+
uniform vec2 uPageSize; // (pw, ph)
|
|
78
|
+
uniform float uPageSide; // -1.0 for left page, 1.0 for right page
|
|
79
|
+
uniform float uIsActive; // 1.0 if curling, 0.0 if flat
|
|
80
|
+
uniform float uDepthOffset; // Negative Z offset for underneath pages to prevent Z-fighting
|
|
81
|
+
|
|
82
|
+
// Chris Luke's Cylindrical & Conical Parameters
|
|
83
|
+
uniform float uTheta; // Cylinder angle in x,y plane
|
|
84
|
+
uniform vec2 uCylBase; // (B, A) origin of cylinder base
|
|
85
|
+
uniform float uC; // Radius of cylinder C
|
|
86
|
+
uniform float uOriginY; // Y origin of the peel corner (+ph/2 for bottom, -ph/2 for top, 0.0 for center)
|
|
87
|
+
|
|
88
|
+
varying vec2 vUv;
|
|
89
|
+
varying vec2 vBackUv;
|
|
90
|
+
varying vec3 vNormal;
|
|
91
|
+
varying vec3 vWorldPos;
|
|
92
|
+
|
|
93
|
+
#define M_PI 3.14159265358979323846
|
|
94
|
+
#define M_PI_2 1.57079632679489661923
|
|
95
|
+
|
|
96
|
+
vec2 rot(vec2 pt, float angle) {
|
|
97
|
+
float c = cos(angle);
|
|
98
|
+
float s = sin(angle);
|
|
99
|
+
return vec2(pt.x * c - pt.y * s, pt.x * s + pt.y * c);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
void main() {
|
|
103
|
+
// Local page coordinates: vx in [0, pw], vy in [-ph/2, ph/2]
|
|
104
|
+
float vx = aPosition.x * uPageSize.x;
|
|
105
|
+
float vy = (aPosition.y - 0.5) * uPageSize.y;
|
|
106
|
+
|
|
107
|
+
vec3 v1 = vec3(vx, vy, 0.0);
|
|
108
|
+
vec3 norm = vec3(0.0, 0.0, 1.0);
|
|
109
|
+
|
|
110
|
+
if (uIsActive > 0.5) {
|
|
111
|
+
float A = uCylBase.y;
|
|
112
|
+
float B = uCylBase.x;
|
|
113
|
+
float theta = uTheta;
|
|
114
|
+
|
|
115
|
+
// Conical radius calculation: tapers radius C towards 0 at the origin edge on diagonal pulls
|
|
116
|
+
float ph = uPageSize.y;
|
|
117
|
+
float distFromOrigin = abs(vy - uOriginY);
|
|
118
|
+
float coneT = clamp(distFromOrigin / ph, 0.0, 1.0);
|
|
119
|
+
float diagFactor = abs(cos(theta));
|
|
120
|
+
float C = max(0.01, uC * mix(1.0, coneT, diagFactor));
|
|
121
|
+
|
|
122
|
+
float tanTheta = tan(theta);
|
|
123
|
+
if (abs(tanTheta) < 0.0001) tanTheta = 0.0001 * sign(tanTheta);
|
|
124
|
+
|
|
125
|
+
// 1. Work out starting X of the cylinder for this Y
|
|
126
|
+
float sx = B + ((vy - A) / tanTheta);
|
|
127
|
+
|
|
128
|
+
// 2. Amount of flat x-travel around cylinder
|
|
129
|
+
float cx = (C * M_PI) * abs(sin(theta));
|
|
130
|
+
if (cx < 0.001) cx = 0.001;
|
|
131
|
+
|
|
132
|
+
// 3. How far round the cylinder we are
|
|
133
|
+
float tx = clamp(vx - sx, 0.0, cx);
|
|
134
|
+
|
|
135
|
+
// 4. Excess of x after the cylinder
|
|
136
|
+
float xx = vx - (cx + sx);
|
|
137
|
+
|
|
138
|
+
if (vx < sx) {
|
|
139
|
+
// Region 1: Flat before cylinder (anchored at spine)
|
|
140
|
+
v1.x = vx;
|
|
141
|
+
v1.y = vy;
|
|
142
|
+
v1.z = 0.0;
|
|
143
|
+
norm = vec3(0.0, 0.0, 1.0);
|
|
144
|
+
|
|
145
|
+
} else if (xx >= 0.0) {
|
|
146
|
+
// Region 3: Flat after cylinder (the turned flap, rotated by 2*theta)
|
|
147
|
+
float beta = M_PI;
|
|
148
|
+
float dy = clamp(tan(M_PI_2 - theta), -2.0, 2.0);
|
|
149
|
+
|
|
150
|
+
vec2 v0 = vec2(C * sin(beta), C * dy);
|
|
151
|
+
vec2 vRot = rot(v0, theta - M_PI_2);
|
|
152
|
+
vec2 excessRot = rot(vec2(xx, 0.0), theta * 2.0);
|
|
153
|
+
|
|
154
|
+
v1.x = vRot.x + excessRot.x + sx;
|
|
155
|
+
v1.y = vRot.y + excessRot.y + vy;
|
|
156
|
+
v1.z = C * 2.0;
|
|
157
|
+
norm = vec3(0.0, 0.0, -1.0);
|
|
158
|
+
|
|
159
|
+
} else {
|
|
160
|
+
// Region 2: Curl around cylinder
|
|
161
|
+
float beta = (tx / cx) * M_PI;
|
|
162
|
+
float dy = clamp(tan(M_PI_2 - theta) * (tx / cx), -2.0, 2.0);
|
|
163
|
+
|
|
164
|
+
vec2 v0 = vec2(C * sin(beta), C * dy);
|
|
165
|
+
vec2 vRot = rot(v0, theta - M_PI_2);
|
|
166
|
+
|
|
167
|
+
v1.x = vRot.x + sx;
|
|
168
|
+
v1.y = vRot.y + vy;
|
|
169
|
+
v1.z = C * (1.0 - cos(beta));
|
|
170
|
+
norm = vec3(-sin(beta) * cos(theta), -sin(beta) * sin(theta), cos(beta));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Boundary constraint: never allow the edge vertices to bulge outside sheet bounds
|
|
174
|
+
if (uOriginY > 0.0) {
|
|
175
|
+
if (vy >= uPageSize.y * 0.499) {
|
|
176
|
+
v1.y = min(v1.y, uPageSize.y * 0.5);
|
|
177
|
+
}
|
|
178
|
+
} else if (uOriginY < 0.0) {
|
|
179
|
+
if (vy <= -uPageSize.y * 0.499) {
|
|
180
|
+
v1.y = max(v1.y, -uPageSize.y * 0.5);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// If turning left page backward, reflect horizontally across spine
|
|
185
|
+
if (uPageSide < 0.0) {
|
|
186
|
+
v1.x = -v1.x;
|
|
187
|
+
norm.x = -norm.x;
|
|
188
|
+
vUv = vec2(1.0 - aPosition.x, aPosition.y);
|
|
189
|
+
vBackUv = aPosition;
|
|
190
|
+
} else {
|
|
191
|
+
vUv = aPosition;
|
|
192
|
+
vBackUv = vec2(1.0 - aPosition.x, aPosition.y);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
} else if (uPageSide < 0.0) {
|
|
196
|
+
// Stationary left page anchored at [-pw, 0]
|
|
197
|
+
v1.x = -uPageSize.x + vx;
|
|
198
|
+
vUv = aPosition;
|
|
199
|
+
vBackUv = aPosition;
|
|
200
|
+
} else {
|
|
201
|
+
// Stationary right page anchored at [0, pw]
|
|
202
|
+
vUv = aPosition;
|
|
203
|
+
vBackUv = aPosition;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
vNormal = normalize(norm);
|
|
207
|
+
v1.z += uDepthOffset;
|
|
208
|
+
vWorldPos = v1;
|
|
209
|
+
gl_Position = uProjection * uModelView * vec4(v1, 1.0);
|
|
210
|
+
}
|
|
211
|
+
`;
|
|
212
|
+
|
|
213
|
+
// Fragment Shader: Hardware gl_FrontFacing Dual-Texture Shading
|
|
214
|
+
const fsSource = `
|
|
215
|
+
precision mediump float;
|
|
216
|
+
|
|
217
|
+
uniform sampler2D uSamplerFront;
|
|
218
|
+
uniform sampler2D uSamplerBack;
|
|
219
|
+
uniform vec3 uLightDir;
|
|
220
|
+
|
|
221
|
+
varying vec2 vUv;
|
|
222
|
+
varying vec2 vBackUv;
|
|
223
|
+
varying vec3 vNormal;
|
|
224
|
+
varying vec3 vWorldPos;
|
|
225
|
+
|
|
226
|
+
void main() {
|
|
227
|
+
vec4 texColor;
|
|
228
|
+
vec3 norm = normalize(vNormal);
|
|
229
|
+
|
|
230
|
+
// Hardware two-sided shading
|
|
231
|
+
if (gl_FrontFacing) {
|
|
232
|
+
texColor = texture2D(uSamplerFront, vUv);
|
|
233
|
+
} else {
|
|
234
|
+
texColor = texture2D(uSamplerBack, vBackUv);
|
|
235
|
+
norm = -norm;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Lighting model (directional light + soft ambient + specular highlight)
|
|
239
|
+
vec3 light = normalize(uLightDir);
|
|
240
|
+
float diff = max(dot(norm, light), 0.0);
|
|
241
|
+
|
|
242
|
+
vec3 viewDir = vec3(0.0, 0.0, 1.0);
|
|
243
|
+
vec3 halfDir = normalize(light + viewDir);
|
|
244
|
+
float spec = pow(max(dot(norm, halfDir), 0.0), 32.0);
|
|
245
|
+
|
|
246
|
+
// Subtle fold curvature shading
|
|
247
|
+
float depthShadow = clamp(1.0 - (vWorldPos.z / 600.0), 0.75, 1.0);
|
|
248
|
+
|
|
249
|
+
vec3 ambient = vec3(0.92);
|
|
250
|
+
vec3 diffuseColor = vec3(0.18) * diff;
|
|
251
|
+
vec3 specularColor = vec3(0.08) * spec;
|
|
252
|
+
|
|
253
|
+
vec3 finalRgb = texColor.rgb * (ambient * depthShadow + diffuseColor) + specularColor;
|
|
254
|
+
gl_FragColor = vec4(finalRgb, texColor.a);
|
|
255
|
+
}
|
|
256
|
+
`;
|
|
257
|
+
|
|
258
|
+
const vs = this.compileShader(gl.VERTEX_SHADER, vsSource);
|
|
259
|
+
const fs = this.compileShader(gl.FRAGMENT_SHADER, fsSource);
|
|
260
|
+
|
|
261
|
+
const program = gl.createProgram();
|
|
262
|
+
gl.attachShader(program, vs);
|
|
263
|
+
gl.attachShader(program, fs);
|
|
264
|
+
gl.linkProgram(program);
|
|
265
|
+
|
|
266
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
267
|
+
console.error('Failed to link WebGL Program:', gl.getProgramInfoLog(program));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
this.program = program;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
compileShader(type, source) {
|
|
274
|
+
const gl = this.gl;
|
|
275
|
+
const shader = gl.createShader(type);
|
|
276
|
+
gl.shaderSource(shader, source);
|
|
277
|
+
gl.compileShader(shader);
|
|
278
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
279
|
+
console.error('WebGL Shader Compilation Error:', gl.getShaderInfoLog(shader));
|
|
280
|
+
gl.deleteShader(shader);
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
return shader;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
initBuffers() {
|
|
287
|
+
const gl = this.gl;
|
|
288
|
+
const gx = this.gridX;
|
|
289
|
+
const gy = this.gridY;
|
|
290
|
+
|
|
291
|
+
const positions = [];
|
|
292
|
+
const indices = [];
|
|
293
|
+
|
|
294
|
+
for (let j = 0; j <= gy; j++) {
|
|
295
|
+
const v = j / gy;
|
|
296
|
+
for (let i = 0; i <= gx; i++) {
|
|
297
|
+
const u = i / gx;
|
|
298
|
+
positions.push(u, v);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (let j = 0; j < gy; j++) {
|
|
303
|
+
for (let i = 0; i < gx; i++) {
|
|
304
|
+
const row1 = j * (gx + 1);
|
|
305
|
+
const row2 = (j + 1) * (gx + 1);
|
|
306
|
+
|
|
307
|
+
// Quad triangles
|
|
308
|
+
indices.push(row1 + i, row2 + i, row1 + i + 1);
|
|
309
|
+
indices.push(row1 + i + 1, row2 + i, row2 + i + 1);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const posBuffer = gl.createBuffer();
|
|
314
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
|
|
315
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
|
|
316
|
+
|
|
317
|
+
const indexBuffer = gl.createBuffer();
|
|
318
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
|
|
319
|
+
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
|
|
320
|
+
|
|
321
|
+
this.meshBuffers = {
|
|
322
|
+
position: posBuffer,
|
|
323
|
+
indexBuffer: indexBuffer,
|
|
324
|
+
indexCount: indices.length
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
preloadSlideTextures() {
|
|
329
|
+
if (!this.slides || this.slides.length === 0) return;
|
|
330
|
+
this.slides.forEach((slide) => {
|
|
331
|
+
this.rasterizeSlideToTexture(slide);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (document.fonts && typeof document.fonts.ready?.then === 'function') {
|
|
335
|
+
document.fonts.ready.then(() => {
|
|
336
|
+
this.slides.forEach((slide) => {
|
|
337
|
+
this.rasterizeSlideToTexture(slide);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
rasterizeSlideToTexture(slide) {
|
|
344
|
+
if (!this.gl || !slide || !slide.element) return;
|
|
345
|
+
const pageNum = slide.pageNum;
|
|
346
|
+
const gl = this.gl;
|
|
347
|
+
const el = slide.element;
|
|
348
|
+
|
|
349
|
+
let texture = this.textures.get(pageNum);
|
|
350
|
+
if (!texture) {
|
|
351
|
+
texture = gl.createTexture();
|
|
352
|
+
this.textures.set(pageNum, texture);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
356
|
+
|
|
357
|
+
// Direct HTML-in-Canvas WebGL capture via gl.texElementImage2D
|
|
358
|
+
if (typeof gl.texElementImage2D === 'function') {
|
|
359
|
+
const isWebGL2 = (typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext);
|
|
360
|
+
const internalFormat = isWebGL2 ? (gl.RGBA8 || gl.RGBA) : gl.RGBA;
|
|
361
|
+
|
|
362
|
+
let captured = false;
|
|
363
|
+
|
|
364
|
+
// 1. Signature A (3 args with validated internalFormat): (target, internalformat, element)
|
|
365
|
+
if (!captured) {
|
|
366
|
+
try {
|
|
367
|
+
gl.texElementImage2D(gl.TEXTURE_2D, internalFormat, el);
|
|
368
|
+
captured = true;
|
|
369
|
+
} catch (e) {}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 2. Signature B (6 args): (target, level, internalformat, format, type, element)
|
|
373
|
+
if (!captured) {
|
|
374
|
+
try {
|
|
375
|
+
gl.texElementImage2D(gl.TEXTURE_2D, 0, internalFormat, gl.RGBA, gl.UNSIGNED_BYTE, el);
|
|
376
|
+
captured = true;
|
|
377
|
+
} catch (e) {}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 3. Signature C (2 args): (target, element)
|
|
381
|
+
if (!captured) {
|
|
382
|
+
try {
|
|
383
|
+
gl.texElementImage2D(gl.TEXTURE_2D, el);
|
|
384
|
+
captured = true;
|
|
385
|
+
} catch (e) {}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
390
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
391
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
392
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
resize() {
|
|
396
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
397
|
+
this.devicePixelRatio = window.devicePixelRatio || 1;
|
|
398
|
+
|
|
399
|
+
const w = rect.width > 0 ? rect.width : (this.canvas.clientWidth || this.pw);
|
|
400
|
+
const h = rect.height > 0 ? rect.height : (this.canvas.clientHeight || this.ph);
|
|
401
|
+
|
|
402
|
+
this.viewportWidth = w;
|
|
403
|
+
this.viewportHeight = h;
|
|
404
|
+
this.canvas.width = Math.round(w * this.devicePixelRatio);
|
|
405
|
+
this.canvas.height = Math.round(h * this.devicePixelRatio);
|
|
406
|
+
this.offsetX = w / 2;
|
|
407
|
+
this.offsetY = h / 2;
|
|
408
|
+
|
|
409
|
+
const pad = 40;
|
|
410
|
+
const availW = Math.max(100, w - pad);
|
|
411
|
+
const availH = Math.max(100, h - pad);
|
|
412
|
+
const spreadW = this.pw * 2;
|
|
413
|
+
const spreadH = this.ph;
|
|
414
|
+
|
|
415
|
+
this.scale = Math.min(availW / spreadW, availH / spreadH, 1);
|
|
416
|
+
this.zoom = 1.0;
|
|
417
|
+
this.panX = 0;
|
|
418
|
+
this.panY = 0;
|
|
419
|
+
|
|
420
|
+
if (this.gl) {
|
|
421
|
+
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
updateDOMSlides(state) {
|
|
426
|
+
if (!this.slides || this.slides.length === 0) return;
|
|
427
|
+
|
|
428
|
+
const [leftPage, rightPage] = state.currentSpread;
|
|
429
|
+
const totalPages = state.totalPages || this.slides.length;
|
|
430
|
+
const scale = this.scale * this.zoom;
|
|
431
|
+
|
|
432
|
+
// Only de-inert pages if resting (not dragging or flipping)
|
|
433
|
+
const isInteracting = state.isDragging || (state.activeFlip && !state.activeFlip.isPeek);
|
|
434
|
+
const activePages = new Set();
|
|
435
|
+
if (!isInteracting) {
|
|
436
|
+
if (leftPage > 0) activePages.add(leftPage);
|
|
437
|
+
if (rightPage <= totalPages) activePages.add(rightPage);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (this.isReloadingTextures) return;
|
|
441
|
+
|
|
442
|
+
this.slides.forEach((s) => {
|
|
443
|
+
if (!s.element) return;
|
|
444
|
+
const isActive = activePages.has(s.pageNum);
|
|
445
|
+
s.element.inert = !isActive;
|
|
446
|
+
|
|
447
|
+
if (isActive) {
|
|
448
|
+
const isLeft = (s.pageNum === leftPage);
|
|
449
|
+
const bx = isLeft ? -this.pw : 0;
|
|
450
|
+
const by = -this.ph / 2;
|
|
451
|
+
const screenPt = this.bookToScreen(bx, by);
|
|
452
|
+
|
|
453
|
+
if (typeof this.canvas.updateElementGeometry === 'function') {
|
|
454
|
+
const matrix = new DOMMatrix()
|
|
455
|
+
.translate(screenPt.x, screenPt.y)
|
|
456
|
+
.scale(scale, scale);
|
|
457
|
+
try {
|
|
458
|
+
this.canvas.updateElementGeometry(s.element, matrix);
|
|
459
|
+
} catch (e) {}
|
|
460
|
+
} else {
|
|
461
|
+
s.element.style.transform = `translate(${screenPt.x}px, ${screenPt.y}px) scale(${scale})`;
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
if (typeof this.canvas.clearElementGeometry === 'function') {
|
|
465
|
+
try {
|
|
466
|
+
this.canvas.clearElementGeometry(s.element);
|
|
467
|
+
} catch (e) {}
|
|
468
|
+
} else {
|
|
469
|
+
s.element.style.transform = `none`;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
render(state) {
|
|
476
|
+
const gl = this.gl;
|
|
477
|
+
if (!gl || !this.program) return;
|
|
478
|
+
|
|
479
|
+
this.updateDOMSlides(state);
|
|
480
|
+
|
|
481
|
+
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
482
|
+
gl.clearColor(0.0, 0.0, 0.0, 0.0);
|
|
483
|
+
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
484
|
+
|
|
485
|
+
gl.useProgram(this.program);
|
|
486
|
+
|
|
487
|
+
const halfW = Math.max(50, this.viewportWidth / 2);
|
|
488
|
+
const halfH = Math.max(50, this.viewportHeight / 2);
|
|
489
|
+
const projMat = this.createOrthoMatrix(-halfW, halfW, halfH, -halfH, -2000, 2000);
|
|
490
|
+
|
|
491
|
+
const mvMat = this.createIdentityMatrix();
|
|
492
|
+
const effectiveScale = this.scale * this.zoom;
|
|
493
|
+
this.scaleMatrix(mvMat, effectiveScale, effectiveScale, 1.0);
|
|
494
|
+
this.translateMatrix(mvMat, this.panX, this.panY, 0.0);
|
|
495
|
+
|
|
496
|
+
gl.uniformMatrix4fv(gl.getUniformLocation(this.program, 'uProjection'), false, projMat);
|
|
497
|
+
gl.uniformMatrix4fv(gl.getUniformLocation(this.program, 'uModelView'), false, mvMat);
|
|
498
|
+
gl.uniform2f(gl.getUniformLocation(this.program, 'uPageSize'), this.pw, this.ph);
|
|
499
|
+
gl.uniform3f(gl.getUniformLocation(this.program, 'uLightDir'), 0.35, -0.45, 0.82);
|
|
500
|
+
|
|
501
|
+
const [leftPage, rightPage] = state.currentSpread;
|
|
502
|
+
const flip = state.activeFlip;
|
|
503
|
+
|
|
504
|
+
if (!flip) {
|
|
505
|
+
// Stationary double spread
|
|
506
|
+
if (leftPage > 0) {
|
|
507
|
+
this.drawDoubleSidedPage(leftPage, leftPage, -1.0, 0.0, null, 0.0);
|
|
508
|
+
}
|
|
509
|
+
if (rightPage <= state.totalPages) {
|
|
510
|
+
this.drawDoubleSidedPage(rightPage, rightPage, 1.0, 0.0, null, 0.0);
|
|
511
|
+
}
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Active 3D Flip
|
|
516
|
+
if (flip.dir > 0) {
|
|
517
|
+
// 1. Stationary Left Page (N-1)
|
|
518
|
+
if (leftPage > 0) {
|
|
519
|
+
this.drawDoubleSidedPage(leftPage, leftPage, -1.0, 0.0, null, 0.0);
|
|
520
|
+
}
|
|
521
|
+
// 2. Underneath Page (N+2) revealed as page N turns (placed at lower Z to prevent Z-fighting)
|
|
522
|
+
if (rightPage + 2 <= state.totalPages) {
|
|
523
|
+
this.drawDoubleSidedPage(rightPage + 2, rightPage + 2, 1.0, 0.0, null, -1.5);
|
|
524
|
+
}
|
|
525
|
+
// 3. Turning Sheet (Front = N, Back = N+1)
|
|
526
|
+
this.drawTurningSheet(rightPage, rightPage + 1, 1.0, flip);
|
|
527
|
+
} else {
|
|
528
|
+
// 1. Stationary Right Page (N)
|
|
529
|
+
if (rightPage <= state.totalPages) {
|
|
530
|
+
this.drawDoubleSidedPage(rightPage, rightPage, 1.0, 0.0, null, 0.0);
|
|
531
|
+
}
|
|
532
|
+
// 2. Underneath Page (N-2) revealed as page N-1 turns back (placed at lower Z to prevent Z-fighting)
|
|
533
|
+
if (leftPage - 2 > 0) {
|
|
534
|
+
this.drawDoubleSidedPage(leftPage - 2, leftPage - 2, -1.0, 0.0, null, -1.5);
|
|
535
|
+
}
|
|
536
|
+
// 3. Turning Sheet (Front = N-1, Back = N)
|
|
537
|
+
this.drawTurningSheet(leftPage, leftPage - 1, -1.0, flip);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
getTexture(pageNum) {
|
|
542
|
+
if (pageNum <= 0 || pageNum > this.slides.length) return null;
|
|
543
|
+
let tex = this.textures.get(pageNum);
|
|
544
|
+
if (!tex) {
|
|
545
|
+
const slide = this.slides[pageNum - 1];
|
|
546
|
+
if (slide) this.rasterizeSlideToTexture(slide);
|
|
547
|
+
tex = this.textures.get(pageNum);
|
|
548
|
+
}
|
|
549
|
+
return tex;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
drawDoubleSidedPage(frontPageNum, backPageNum, pageSide, isActive, cylInfo, depthOffset = 0.0) {
|
|
553
|
+
const gl = this.gl;
|
|
554
|
+
const texFront = this.getTexture(frontPageNum);
|
|
555
|
+
const texBack = this.getTexture(backPageNum) || texFront;
|
|
556
|
+
if (!texFront) return;
|
|
557
|
+
|
|
558
|
+
// Unit 0 = Front texture
|
|
559
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
560
|
+
gl.bindTexture(gl.TEXTURE_2D, texFront);
|
|
561
|
+
gl.uniform1i(gl.getUniformLocation(this.program, 'uSamplerFront'), 0);
|
|
562
|
+
|
|
563
|
+
// Unit 1 = Back texture
|
|
564
|
+
gl.activeTexture(gl.TEXTURE1);
|
|
565
|
+
gl.bindTexture(gl.TEXTURE_2D, texBack);
|
|
566
|
+
gl.uniform1i(gl.getUniformLocation(this.program, 'uSamplerBack'), 1);
|
|
567
|
+
|
|
568
|
+
gl.uniform1f(gl.getUniformLocation(this.program, 'uPageSide'), pageSide);
|
|
569
|
+
gl.uniform1f(gl.getUniformLocation(this.program, 'uIsActive'), isActive);
|
|
570
|
+
gl.uniform1f(gl.getUniformLocation(this.program, 'uDepthOffset'), depthOffset);
|
|
571
|
+
|
|
572
|
+
if (cylInfo) {
|
|
573
|
+
gl.uniform1f(gl.getUniformLocation(this.program, 'uTheta'), cylInfo.theta);
|
|
574
|
+
gl.uniform2f(gl.getUniformLocation(this.program, 'uCylBase'), cylInfo.B, cylInfo.A);
|
|
575
|
+
gl.uniform1f(gl.getUniformLocation(this.program, 'uC'), cylInfo.C);
|
|
576
|
+
gl.uniform1f(gl.getUniformLocation(this.program, 'uOriginY'), cylInfo.originY !== undefined ? cylInfo.originY : 0.0);
|
|
577
|
+
} else {
|
|
578
|
+
gl.uniform1f(gl.getUniformLocation(this.program, 'uTheta'), Math.PI / 2);
|
|
579
|
+
gl.uniform2f(gl.getUniformLocation(this.program, 'uCylBase'), 0.0, 0.0);
|
|
580
|
+
gl.uniform1f(gl.getUniformLocation(this.program, 'uC'), 24.0);
|
|
581
|
+
gl.uniform1f(gl.getUniformLocation(this.program, 'uOriginY'), 0.0);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Set winding order for mirrored left-page flips and restore
|
|
585
|
+
if (pageSide < 0.0 && isActive > 0.5) {
|
|
586
|
+
gl.frontFace(gl.CW);
|
|
587
|
+
} else {
|
|
588
|
+
gl.frontFace(gl.CCW);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const aPosLoc = gl.getAttribLocation(this.program, 'aPosition');
|
|
592
|
+
gl.enableVertexAttribArray(aPosLoc);
|
|
593
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.meshBuffers.position);
|
|
594
|
+
gl.vertexAttribPointer(aPosLoc, 2, gl.FLOAT, false, 0, 0);
|
|
595
|
+
|
|
596
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.meshBuffers.indexBuffer);
|
|
597
|
+
gl.drawElements(gl.TRIANGLES, this.meshBuffers.indexCount, gl.UNSIGNED_SHORT, 0);
|
|
598
|
+
|
|
599
|
+
// Always reset frontFace back to CCW
|
|
600
|
+
gl.frontFace(gl.CCW);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
drawTurningSheet(frontPageNum, backPageNum, pageSide, flip) {
|
|
604
|
+
const pw = this.pw;
|
|
605
|
+
|
|
606
|
+
// Start corner S and dragged corner P
|
|
607
|
+
let sx = flip.sx;
|
|
608
|
+
let sy = flip.sy;
|
|
609
|
+
let px = flip.px;
|
|
610
|
+
let py = flip.py;
|
|
611
|
+
|
|
612
|
+
// Normalize for left page if turning backward
|
|
613
|
+
if (pageSide < 0.0) {
|
|
614
|
+
sx = -sx;
|
|
615
|
+
px = -px;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const dx = px - sx;
|
|
619
|
+
const dy = py - sy;
|
|
620
|
+
const dist = Math.hypot(dx, dy);
|
|
621
|
+
if (dist < 0.5) {
|
|
622
|
+
// Draw as flat page rather than dropping the frame and exposing the page underneath
|
|
623
|
+
this.drawDoubleSidedPage(frontPageNum, backPageNum, pageSide, 0.0, null, 0.0);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Chris Luke's Cylinder angle theta: perpendicular bisector of the drag vector
|
|
628
|
+
const angle = Math.atan2(dy, dx);
|
|
629
|
+
let theta = angle - Math.PI / 2.0;
|
|
630
|
+
while (theta <= 0.05) theta += Math.PI;
|
|
631
|
+
while (theta >= Math.PI - 0.05) theta -= Math.PI;
|
|
632
|
+
theta = clamp(theta, Math.PI * 0.25, Math.PI * 0.75);
|
|
633
|
+
|
|
634
|
+
// Midpoint fold crease base (B, A)
|
|
635
|
+
const midX = (sx + px) / 2.0;
|
|
636
|
+
const midY = (sy + py) / 2.0;
|
|
637
|
+
|
|
638
|
+
// Progress across page [0 = fully turned at spine, 1 = unturned at outer edge]
|
|
639
|
+
const progress = Math.max(0.0, Math.min(1.0, midX / pw));
|
|
640
|
+
// Dynamic cylinder radius C that tapers to 0 at the spine (midX = 0) and at outer edge (midX = pw)
|
|
641
|
+
// with peak 3D curl in the middle of the turn
|
|
642
|
+
const curlEnvelope = Math.sin(progress * Math.PI);
|
|
643
|
+
const C = Math.max(0.5, curlEnvelope * 28.0);
|
|
644
|
+
|
|
645
|
+
const cylInfo = {
|
|
646
|
+
theta,
|
|
647
|
+
B: midX - (C * Math.PI * 0.5) * Math.sin(theta),
|
|
648
|
+
A: midY,
|
|
649
|
+
C,
|
|
650
|
+
originY: flip.sy
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
// Draw single-pass double-sided turning sheet
|
|
654
|
+
this.drawDoubleSidedPage(frontPageNum, backPageNum, pageSide, 1.0, cylInfo);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Matrix Utilities
|
|
658
|
+
createIdentityMatrix() {
|
|
659
|
+
return new Float32Array([
|
|
660
|
+
1, 0, 0, 0,
|
|
661
|
+
0, 1, 0, 0,
|
|
662
|
+
0, 0, 1, 0,
|
|
663
|
+
0, 0, 0, 1
|
|
664
|
+
]);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
createOrthoMatrix(left, right, bottom, top, near, far) {
|
|
668
|
+
const lr = 1 / (left - right);
|
|
669
|
+
const bt = 1 / (bottom - top);
|
|
670
|
+
const nf = 1 / (near - far);
|
|
671
|
+
return new Float32Array([
|
|
672
|
+
-2 * lr, 0, 0, 0,
|
|
673
|
+
0, -2 * bt, 0, 0,
|
|
674
|
+
0, 0, 2 * nf, 0,
|
|
675
|
+
(left + right) * lr, (top + bottom) * bt, (far + near) * nf, 1
|
|
676
|
+
]);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
translateMatrix(mat, tx, ty, tz) {
|
|
680
|
+
mat[12] += tx;
|
|
681
|
+
mat[13] += ty;
|
|
682
|
+
mat[14] += tz;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
scaleMatrix(mat, sx, sy, sz) {
|
|
686
|
+
mat[0] *= sx;
|
|
687
|
+
mat[5] *= sy;
|
|
688
|
+
mat[10] *= sz;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
clearCache() {
|
|
692
|
+
if (this.gl) {
|
|
693
|
+
for (const tex of this.textures.values()) {
|
|
694
|
+
this.gl.deleteTexture(tex);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
this.textures.clear();
|
|
698
|
+
}
|
|
699
|
+
}
|