Libro 3D (app/public/book3d.js): lector WebGL2 con mesa de madera, cubierta, hojas que giran, cámara real y giroscopio, ahora con más recorrido alrededor del libro. Tomos (app/volumes.js): agrupa los capítulos en un solo libro con portada y contraportada reales. El reparto sale de Wikipedia, MangaDex, M440 y MangaUpdates, contrastados entre sí. Lo que ninguna fuente sitúa se estima, de modo que solo quedan sueltos los capítulos del final, y los fuera de orden se recolocan. Manhwa y manhua no tienen tomos. Ocultar créditos (app/credits.js): botón por obra en la ficha. Al leer por tomos oculta, sin borrarlas, la página cero y las dos últimas de cada capítulo si se repiten en otros capítulos de la obra. HANDOFF-LIBRO3D.md recoge el estado, las decisiones y las pruebas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1066 lines
53 KiB
JavaScript
1066 lines
53 KiB
JavaScript
// Modo libro 3D: un solo canvas WebGL2 con la mesa, el libro y una cámara de verdad. Hojas y cantos salen de la misma
|
|
// función de altura, así que no pueden separarse. El lector solo le dice qué páginas se ven.
|
|
// Unidades: la página mide 1 de alto. x cruza el libro (la costura en 0), y va hacia arriba de la pantalla, z sale de la mesa.
|
|
|
|
export const BOOK = { THICK: 0.075, BOARD: 0.008, OVER: 0.004, CROWN: 0.05, FALL: 0.02, DIP: 0.06, SPINE: 0.03 }
|
|
const dipAt = t => Math.exp(-t / BOOK.DIP)
|
|
// Lo que se levanta la hoja sobre un bloque plano. t: 0 en la costura, 1 en el borde exterior. open: 0 cerrado, 1 abierto.
|
|
export const arch = (t, open = 1) => open * (BOOK.CROWN * (1 - dipAt(t)) - BOOK.FALL * t * t)
|
|
// Altura de la hoja a profundidad v (0: la de arriba; 1: la de abajo). La curva se atenúa hasta la mitad en la última.
|
|
// k: 1 con el bloque entero; 0 sin hojas (la mitad sobre la que aún no ha aterrizado la cubierta).
|
|
export function sheetZ(t, v = 0, open = 1, k = 1) {
|
|
return BOOK.BOARD + k * (BOOK.THICK * (1 - v) * (1 - 0.45 * dipAt(t) * open) + arch(t, open) * (1 - v / 2))
|
|
}
|
|
export const surface = (t, open = 1, k = 1) => sheetZ(t, 0, open, k)
|
|
const LID_Z = BOOK.BOARD * 2 + BOOK.THICK // la cara de arriba de la cubierta con el libro cerrado
|
|
const smooth = (a, b, x) => { const t = Math.max(0, Math.min(1, (x - a) / (b - a))); return t * t * (3 - 2 * t) }
|
|
const easeIO = t => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2)
|
|
// La mitad sobre la que aterriza la cubierta: hasta que está casi abajo no hay nada, solo la mesa. Después la cubierta ya la tapa.
|
|
export const landWidth = a => (a < 0.97 ? 0 : Math.max(0, -Math.cos(a * Math.PI)))
|
|
// Una hoja a media vuelta sobre la costura. a: 0 tumbada en su lado, 1 tumbada en el otro. rigid: la cubierta, que no se dobla.
|
|
// Parte de la hoja en reposo y vuelve a ella: al empezar y al acabar coincide con la superficie del libro, sin saltos.
|
|
export function sheetCurve(a, side, W, rigid = false, cols = 30, ext = 0) {
|
|
const L = rigid ? 0 : 0.38, pts = []
|
|
const hinge = rigid ? LID_Z + (surface(0) - LID_Z) * a : surface(0) + 0.0015
|
|
const rest = t => (rigid ? LID_Z + (surface(t) - LID_Z) * a : surface(t) + 0.0015)
|
|
let x = 0, z = 0, prev = 0
|
|
for (let c = 0; c <= cols; c++) {
|
|
const t = (c / cols) ** 2, phi = Math.PI * (rigid ? a : smooth(0, 1, a * (1 + L) - L * t))
|
|
x += Math.cos(phi) * (t - prev) * (W + ext)
|
|
z += Math.sin(phi) * (t - prev) * (W + ext)
|
|
prev = t
|
|
pts.push({ t, phi, x: side * x, z: hinge + z + (rest(t) - hinge) * Math.cos(phi) ** 2 })
|
|
}
|
|
pts.forEach((p, c) => {
|
|
const q = pts[Math.min(cols, c + 1)], o = pts[Math.max(0, c - 1)], tx = q.x - o.x, tz = q.z - o.z, l = Math.hypot(tx, tz) || 1
|
|
p.nx = (side * -tz) / l
|
|
p.nz = (side * tx) / l
|
|
})
|
|
return pts
|
|
}
|
|
// Matriz de color de un filtro CSS (filas: r, g, b, desplazamiento), para aplicarlo a las páginas y no a la mesa.
|
|
export function filterMatrix(css) {
|
|
let M = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0]]
|
|
const then = N => { M = N.map(r => [0, 1, 2, 3].map(c => r[0] * M[0][c] + r[1] * M[1][c] + r[2] * M[2][c] + (c === 3 ? r[3] : 0))) }
|
|
for (const [, name, val] of String(css || '').matchAll(/([a-z-]+)\(([-\d.]+)(?:deg|%)?\)/g)) {
|
|
const v = Number(val), i = 1 - v
|
|
if (name === 'brightness') then([[v, 0, 0, 0], [0, v, 0, 0], [0, 0, v, 0]])
|
|
else if (name === 'contrast') { const o = (1 - v) / 2; then([[v, 0, 0, o], [0, v, 0, o], [0, 0, v, o]]) }
|
|
else if (name === 'invert') { const k = 1 - 2 * v; then([[k, 0, 0, v], [0, k, 0, v], [0, 0, k, v]]) }
|
|
else if (name === 'grayscale') then([[0.2126 + 0.7874 * i, 0.7152 - 0.7152 * i, 0.0722 - 0.0722 * i, 0], [0.2126 - 0.2126 * i, 0.7152 + 0.2848 * i, 0.0722 - 0.0722 * i, 0], [0.2126 - 0.2126 * i, 0.7152 - 0.7152 * i, 0.0722 + 0.9278 * i, 0]])
|
|
else if (name === 'sepia') then([[0.393 + 0.607 * i, 0.769 - 0.769 * i, 0.189 - 0.189 * i, 0], [0.349 - 0.349 * i, 0.686 + 0.314 * i, 0.168 - 0.168 * i, 0], [0.272 - 0.272 * i, 0.534 - 0.534 * i, 0.131 + 0.869 * i, 0]])
|
|
else if (name === 'saturate') then([[0.213 + 0.787 * v, 0.715 - 0.715 * v, 0.072 - 0.072 * v, 0], [0.213 - 0.213 * v, 0.715 + 0.285 * v, 0.072 - 0.072 * v, 0], [0.213 - 0.213 * v, 0.715 - 0.715 * v, 0.072 + 0.928 * v, 0]])
|
|
else if (name === 'hue-rotate') {
|
|
const c = Math.cos((v * Math.PI) / 180), n = Math.sin((v * Math.PI) / 180)
|
|
then([[0.213 + c * 0.787 - n * 0.213, 0.715 - c * 0.715 - n * 0.715, 0.072 - c * 0.072 + n * 0.928, 0], [0.213 - c * 0.213 + n * 0.143, 0.715 + c * 0.285 + n * 0.14, 0.072 - c * 0.072 - n * 0.283, 0], [0.213 - c * 0.213 - n * 0.787, 0.715 - c * 0.715 + n * 0.715, 0.072 + c * 0.928 + n * 0.072, 0]])
|
|
}
|
|
}
|
|
return M
|
|
}
|
|
|
|
const norm = v => { const l = Math.hypot(...v) || 1; return v.map(n => n / l) }
|
|
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
|
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
// Perspectiva cerrada e inclinación mayor que medio campo de visión: así la cámara queda al sur del libro y se ve el canto de abajo.
|
|
export const FOV = (14 * Math.PI) / 180, TILT = (14 * Math.PI) / 180, NEAR = 0.5, FAR = 40
|
|
|
|
// Matriz de la cámara, por columnas. tilt: inclinación hacia el sur, para ver el libro con algo de ángulo.
|
|
export function cameraMatrix(target, dist, aspect, tilt = TILT, yaw = 0) {
|
|
const eye = [target[0] + dist * Math.sin(yaw) * Math.cos(tilt), target[1] - dist * Math.sin(tilt), target[2] + dist * Math.cos(yaw) * Math.cos(tilt)]
|
|
const f = norm([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]), s = norm(cross(f, [0, 1, 0])), u = cross(s, f)
|
|
const k = 1 / Math.tan(FOV / 2), A = (FAR + NEAR) / (NEAR - FAR), B = (2 * FAR * NEAR) / (NEAR - FAR)
|
|
const row = (v, o) => [v[0], v[1], v[2], o]
|
|
const V0 = row(s, -dot(s, eye)), V1 = row(u, -dot(u, eye)), V2 = row(f.map(n => -n), dot(f, eye))
|
|
const m = new Float32Array(16)
|
|
for (let c = 0; c < 4; c++) {
|
|
m[c * 4] = (k / aspect) * V0[c]
|
|
m[c * 4 + 1] = k * V1[c]
|
|
m[c * 4 + 2] = A * V2[c] + (c === 3 ? B : 0)
|
|
m[c * 4 + 3] = -V2[c]
|
|
}
|
|
return m
|
|
}
|
|
export function project(m, p) {
|
|
const w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]
|
|
return [(m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]) / w, (m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]) / w]
|
|
}
|
|
// Distancia y objetivo para que los puntos llenen la pantalla, centrados y dejando margen (en fracción de la pantalla).
|
|
export function fitCamera(points, aspect, marginX, marginY) {
|
|
const target = [0, 0, 0]
|
|
const n = points.length
|
|
for (let k = 0; k < 2; k++) target[k] = points.reduce((a, p) => a + p[k], 0) / n
|
|
const limX = 1 - 2 * marginX, limY = 1 - 2 * marginY
|
|
let lo = 0.5, hi = 30
|
|
const extent = d => {
|
|
for (let pass = 0; pass < 5; pass++) { // recentrar: con perspectiva e inclinación el centro se desplaza
|
|
const m = cameraMatrix(target, d, aspect), q = points.map(p => project(m, p))
|
|
const xs = q.map(a => a[0]), ys = q.map(a => a[1])
|
|
const midX = (Math.max(...xs) + Math.min(...xs)) / 2, midY = (Math.max(...ys) + Math.min(...ys)) / 2
|
|
const half = d * Math.tan(FOV / 2)
|
|
target[0] += midX * half * aspect
|
|
target[1] += midY * half
|
|
}
|
|
const m = cameraMatrix(target, d, aspect), q = points.map(p => project(m, p))
|
|
const xs = q.map(a => a[0]), ys = q.map(a => a[1])
|
|
return Math.max((Math.max(...xs) - Math.min(...xs)) / 2 / limX, (Math.max(...ys) - Math.min(...ys)) / 2 / limY)
|
|
}
|
|
for (let i = 0; i < 26; i++) {
|
|
const d = (lo + hi) / 2
|
|
if (extent(d) > 1) lo = d; else hi = d
|
|
}
|
|
extent(hi)
|
|
return { target, dist: hi }
|
|
}
|
|
|
|
// Colores de la portada para la contraportada y sus botones.
|
|
const luma = c => { const f = v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]) }
|
|
const contrast = (a, b) => { const x = luma(a), y = luma(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) }
|
|
const inkOn = c => (contrast(c, [1, 1, 1]) >= contrast(c, [0.07, 0.06, 0.05]) ? [1, 1, 1] : [0.07, 0.06, 0.05])
|
|
const mixColor = (a, b, t) => a.map((n, k) => n + (b[k] - n) * t)
|
|
const css = c => `rgb(${c.map(n => Math.round(n * 255)).join(' ')})`
|
|
// Color dominante y color de acento: el acento es el que más destaca sobre el dominante, sin ser una mota de la imagen.
|
|
export function coverPalette(data) {
|
|
const boxes = new Map()
|
|
for (let p = 0; p < data.length; p += 4) {
|
|
const key = ((data[p] >> 4) << 8) | ((data[p + 1] >> 4) << 4) | (data[p + 2] >> 4)
|
|
const b = boxes.get(key) || { n: 0, c: [0, 0, 0] }
|
|
b.n++
|
|
for (let k = 0; k < 3; k++) b.c[k] += data[p + k] / 255
|
|
boxes.set(key, b)
|
|
}
|
|
const list = [...boxes.values()].map(b => ({ n: b.n, c: b.c.map(v => v / b.n) })).sort((a, b) => b.n - a.n)
|
|
const base = list[0]?.c || [0.2, 0.14, 0.1]
|
|
let accent = null, best = 0
|
|
for (const b of list) {
|
|
const sat = Math.max(...b.c) - Math.min(...b.c), score = Math.sqrt(b.n) * Math.hypot(b.c[0] - base[0], b.c[1] - base[1], b.c[2] - base[2]) * (0.35 + sat)
|
|
if (score > best) { best = score; accent = b.c }
|
|
}
|
|
accent ||= base
|
|
// Si apenas se distingue del dominante, se aclara o se oscurece hasta que se vea.
|
|
const away = luma(base) > 0.3 ? [0, 0, 0] : [1, 1, 1]
|
|
for (let i = 0; i < 10 && contrast(accent, base) < 2.4; i++) accent = mixColor(accent, away, 0.18)
|
|
return { base, accent }
|
|
}
|
|
|
|
// Madera generada: se repite sin que se note el corte.
|
|
function woodCanvas() {
|
|
const S = 1024, PLANKS = 5, canvas = Object.assign(document.createElement('canvas'), { width: S, height: S }), g = canvas.getContext('2d')
|
|
let seed = 7
|
|
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647
|
|
for (let p = 0; p < PLANKS; p++) {
|
|
const x0 = (p * S) / PLANKS, w = S / PLANKS, light = 34 + rnd() * 7
|
|
g.save()
|
|
g.beginPath(); g.rect(x0, 0, w, S); g.clip()
|
|
g.fillStyle = `hsl(${24 + rnd() * 5}, 46%, ${light}%)`
|
|
g.fillRect(x0, 0, w, S)
|
|
const phase = rnd() * 6, swing = 4 + rnd() * 10
|
|
for (let i = 0; i < 170; i++) {
|
|
const x = x0 + rnd() * w, dark = rnd() < 0.72
|
|
g.strokeStyle = dark ? `rgba(40,20,8,${0.05 + rnd() * 0.16})` : `rgba(255,214,160,${0.02 + rnd() * 0.07})`
|
|
g.lineWidth = 0.5 + rnd() * 2.4
|
|
g.beginPath()
|
|
for (let y = 0; y <= S; y += 32) {
|
|
const turn = (y / S) * Math.PI * 2
|
|
const dx = Math.sin(turn + phase) * swing + Math.sin(turn * 3 + x * 0.05) * 1.6
|
|
if (y) g.lineTo(x + dx, y); else g.moveTo(x + dx, y)
|
|
}
|
|
g.stroke()
|
|
}
|
|
g.restore()
|
|
g.fillStyle = 'rgba(20,9,3,.55)'
|
|
g.fillRect(x0 - 1, 0, 2, S)
|
|
}
|
|
return canvas
|
|
}
|
|
|
|
const VS = `#version 300 es
|
|
uniform mat4 uM;
|
|
layout(location = 0) in vec3 aPos;
|
|
layout(location = 1) in vec3 aNor;
|
|
layout(location = 2) in vec2 aUV;
|
|
out vec3 vPos; out vec3 vNor; out vec2 vUV;
|
|
void main() { vPos = aPos; vNor = aNor; vUV = aUV; gl_Position = uM * vec4(aPos, 1.0); }`
|
|
const FS = `#version 300 es
|
|
precision highp float;
|
|
uniform int uKind;
|
|
uniform sampler2D uTex;
|
|
uniform vec3 uColor;
|
|
uniform vec4 uMap, uRect;
|
|
uniform float uSoft, uAlpha, uSeamAt, uHas, uFlip, uMirror;
|
|
uniform vec2 uCast;
|
|
uniform vec3 uTab;
|
|
uniform mat3 uFx;
|
|
uniform vec3 uFxO;
|
|
in vec3 vPos; in vec3 vNor; in vec2 vUV;
|
|
out vec4 color;
|
|
const vec3 L = normalize(vec3(-0.16, -0.26, 0.95));
|
|
// Luz: 1 con la hoja plana; se oscurece al inclinarse hacia la costura o en las caras laterales.
|
|
float light() { return clamp(mix(0.55, 1.0, max(dot(normalize(vNor) * uFlip, L), 0.0) / L.z), 0.0, 1.06); }
|
|
// La sombra de la hoja que gira: desde la costura hasta donde cae la proyección de su borde.
|
|
float castShade() {
|
|
if (uCast.y <= 0.0 || abs(uCast.x) < 0.001) return 1.0;
|
|
float r = vPos.x / uCast.x;
|
|
if (r <= 0.0) return 1.0;
|
|
// La hoja solo mide lo que mide el libro: su sombra no llega a la mesa de más arriba ni de más abajo.
|
|
float inY = 1.0 - smoothstep(0.5, 0.56, abs(vPos.y));
|
|
return 1.0 - uCast.y * inY * (1.0 - smoothstep(0.7, 1.15, r));
|
|
}
|
|
vec3 fx(vec3 c) { return clamp(uFx * c + uFxO, 0.0, 1.0); }
|
|
// Misma hendidura que el lector normal, para que la página se vea igual.
|
|
float gutter(float d) {
|
|
if (d < 0.012) return mix(0.45, 0.62, d / 0.012);
|
|
if (d < 0.03) return mix(0.62, 0.82, (d - 0.012) / 0.018);
|
|
if (d < 0.06) return mix(0.82, 0.94, (d - 0.03) / 0.03);
|
|
if (d < 0.1) return mix(0.94, 1.0, (d - 0.06) / 0.04);
|
|
return 1.0;
|
|
}
|
|
void main() {
|
|
if (uKind == 0) { // mesa: la luz cae sobre el libro y se apaga hacia fuera
|
|
vec3 wood = uTab.z > 0.0 ? texture(uTex, vec2(0.5 + vPos.x * uTab.x, 0.5 - vPos.y * uTab.y), -0.4).rgb : texture(uTex, vUV, -0.4).rgb;
|
|
float r = length((vPos.xy - uRect.xy) / uRect.zw);
|
|
color = vec4(wood * mix(1.08, 0.62, smoothstep(0.5, 2.1, r)) * castShade(), 1.0);
|
|
} else if (uKind == 1) { // sombra en la mesa: un rectángulo difuso
|
|
vec2 a = smoothstep(uRect.xy - uSoft, uRect.xy + uSoft, vPos.xy) * (1.0 - smoothstep(uRect.zw - uSoft, uRect.zw + uSoft, vPos.xy));
|
|
color = vec4(0.05, 0.02, 0.0, 1.0) * uAlpha * a.x * a.y;
|
|
} else if (uKind == 2) { // tapa
|
|
color = vec4(uColor * light() * castShade(), 1.0);
|
|
} else if (uKind == 3) { // canto: cada línea es una hoja, y sigue la curva porque la malla la sigue
|
|
float v = vUV.y * 15.0, w = fwidth(v);
|
|
float line = 1.0 - smoothstep(0.0, max(w, 0.35), abs(fract(v) - 0.5) - 0.12);
|
|
vec3 c = uColor * (1.0 - 0.17 * line) * mix(0.82, 1.0, smoothstep(0.0, 0.45, vUV.y));
|
|
color = vec4(fx(c) * min(1.0, light() * 1.3) * castShade(), 1.0); // el papel de los cantos recibe luz rebotada de la mesa
|
|
} else if (uKind == 4) { // página
|
|
float ux = uMirror > 0.5 ? 1.0 - vUV.x : vUV.x;
|
|
vec2 q = vec2(ux * uMap.x + uMap.y, vUV.y * uMap.z + uMap.w);
|
|
vec3 c = uColor;
|
|
if (uHas > 0.5 && q.x >= 0.0 && q.x <= 1.0 && q.y >= 0.0 && q.y <= 1.0) c = texture(uTex, q, -0.5).rgb;
|
|
color = vec4(fx(c) * light() * gutter(uSeamAt < 0.5 ? ux : 1.0 - ux) * castShade(), 1.0);
|
|
} else if (uKind == 5) { // portada, plana y sin hendidura
|
|
color = vec4(fx(uHas > 0.5 ? texture(uTex, vUV, -0.5).rgb : uColor) * light() * castShade(), 1.0);
|
|
} else { // lomo: el color del borde de la portada, de arriba abajo
|
|
color = vec4(fx(uHas > 0.5 ? texture(uTex, vec2(uSeamAt, vUV.y)).rgb : uColor) * light() * castShade(), 1.0);
|
|
}
|
|
}`
|
|
|
|
// Junta rejillas en una malla: pos(3) normal(3) uv(2).
|
|
class Shape {
|
|
constructor() { this.v = []; this.i = [] }
|
|
grid(cols, rows, at) {
|
|
const base = this.v.length / 8
|
|
for (let c = 0; c <= cols; c++) for (let r = 0; r <= rows; r++) this.v.push(...at(c, r))
|
|
for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) {
|
|
const a = base + c * (rows + 1) + r, b = a + rows + 1
|
|
this.i.push(a, a + 1, b, b, a + 1, b + 1)
|
|
}
|
|
return this
|
|
}
|
|
box(x0, x1, y0, y1, z0, z1) {
|
|
const quad = (o, a, b, n) => this.grid(1, 1, (c, r) => [o[0] + a[0] * c + b[0] * r, o[1] + a[1] * c + b[1] * r, o[2] + a[2] * c + b[2] * r, ...n, c, r])
|
|
const dx = x1 - x0, dy = y1 - y0, dz = z1 - z0
|
|
quad([x0, y0, z1], [dx, 0, 0], [0, dy, 0], [0, 0, 1])
|
|
quad([x0, y0, z0], [dx, 0, 0], [0, 0, dz], [0, -1, 0])
|
|
quad([x0, y1, z0], [dx, 0, 0], [0, 0, dz], [0, 1, 0])
|
|
quad([x0, y0, z0], [0, dy, 0], [0, 0, dz], [-1, 0, 0])
|
|
quad([x1, y0, z0], [0, dy, 0], [0, 0, dz], [1, 0, 0])
|
|
return this
|
|
}
|
|
}
|
|
const COLS = 30
|
|
const tAt = c => (c / COLS) ** 2 // más columnas junto a la costura, donde la curva es más cerrada
|
|
function slope(t, open, k) { const e = 0.002, a = Math.min(1, t + e), b = Math.max(0, t - e); return (surface(a, open, k) - surface(b, open, k)) / (a - b) }
|
|
// Una mitad del libro. side: -1 izquierda, 1 derecha. width: lo que ya se ha extendido, de 0 a 1.
|
|
function pageShape(side, W, open, k = 1, width = 1) {
|
|
return new Shape().grid(COLS, 1, (c, r) => {
|
|
const t = tAt(c), n = norm([(-side * slope(t, open, k)) / W, 0, 1])
|
|
return [side * W * width * t, 0.5 - r, surface(t, open, k), ...n, side > 0 ? t : 1 - t, r]
|
|
})
|
|
}
|
|
// Los cantos de esa mitad, extruidos desde la misma curva: abajo, arriba y por fuera.
|
|
function edgeShape(sh, side, W, open, k = 1, width = 1) {
|
|
const RINGS = 5, v = r => (r >= RINGS ? 1 : r / RINGS)
|
|
const z = (t, r) => (r > RINGS ? BOOK.BOARD : sheetZ(t, v(r), open, k))
|
|
for (const [y, ny] of [[-0.5, -1], [0.5, 1]]) sh.grid(COLS, RINGS + 1, (c, r) => { const t = tAt(c); return [side * W * width * t, y, z(t, r), 0, ny, 0, t, v(r)] })
|
|
sh.grid(1, RINGS + 1, (c, r) => [side * W * width, c - 0.5, z(1, r), side, 0, 0, c, v(r)])
|
|
return sh
|
|
}
|
|
function spineShape(outward, top, size) {
|
|
const STEPS = 10, mid = top / 2
|
|
return new Shape().grid(STEPS, 1, (c, r) => {
|
|
const a = (c / STEPS) * Math.PI, sx = Math.sin(a), cz = Math.cos(a)
|
|
return [outward * size * sx, 0.5 + BOOK.OVER - r * (1 + 2 * BOOK.OVER), mid + mid * cz, outward * sx, 0, cz, c / STEPS, r]
|
|
})
|
|
}
|
|
// La hoja que gira y, en la cubierta, su grosor por el borde de abajo y por fuera.
|
|
function sheetShape(pts, y0 = -0.5, y1 = 0.5) {
|
|
const s = new Shape(), n = pts.length - 1
|
|
s.grid(n, 1, (c, r) => { const p = pts[c]; return [p.x, y1 + (y0 - y1) * r, p.z, p.nx, 0, p.nz, p.u, r] })
|
|
return s
|
|
}
|
|
function rimShape(pts, y0, y1, thick) {
|
|
const s = new Shape(), n = pts.length - 1, e = pts[n]
|
|
s.grid(n, 1, (c, r) => { const p = pts[c]; return [p.x - p.nx * thick * r, y0, p.z - p.nz * thick * r, 0, -1, 0, 0, 0] })
|
|
s.grid(1, 1, (c, r) => [e.x - e.nx * thick * r, y0 + (y1 - y0) * c, e.z - e.nz * thick * r, e.nz * Math.sign(e.x || 1), 0, -e.nx * Math.sign(e.x || 1), 0, 0])
|
|
return s
|
|
}
|
|
|
|
// El sensor: los ejes de la pantalla a partir de la orientación del dispositivo. La inclinación se desvanece con el tiempo.
|
|
// MAX_DEG: lo que hay que girar el dispositivo para llegar al tope. ORBIT_*: cuánto rodea entonces la cámara al libro, en grados.
|
|
const MAX_DEG = 15, FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06
|
|
function createTilt(onChange) {
|
|
const target = { x: 0, y: 0 }
|
|
let listening = false, gotOrientation = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null
|
|
const clamp = v => Math.max(-MAX_DEG, Math.min(MAX_DEG, v))
|
|
function screenAngle() {
|
|
let a = (((window.orientation ?? screen.orientation?.angle ?? 0) % 360) + 360) % 360
|
|
const landscape = innerWidth > innerHeight
|
|
if (landscape && a % 180 === 0) a = 90
|
|
if (!landscape && a % 180) a = 0
|
|
return (a * Math.PI) / 180
|
|
}
|
|
function add(dx, dy) {
|
|
const a = screenAngle(), c = Math.cos(a), s = Math.sin(a)
|
|
target.x = clamp(target.x + dx * c - dy * s)
|
|
target.y = clamp(target.y + dx * s + dy * c)
|
|
lastFade = lastFade || performance.now()
|
|
onChange()
|
|
}
|
|
const stepOf = t => { const dt = Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t; return dt }
|
|
const quiet = v => (Math.abs(v) < QUIET ? 0 : v)
|
|
function onOrientation(e) {
|
|
if (e.beta == null || e.gamma == null) return
|
|
gotOrientation = true
|
|
const dt = stepOf(e.timeStamp)
|
|
if (lastOri) {
|
|
const dx = e.beta - lastOri.b, dy = e.gamma - lastOri.g
|
|
const still = dt > 0 && Math.hypot(dx, dy) / dt < QUIET
|
|
if (!still && Math.abs(dx) < 25 && Math.abs(dy) < 25) add(dx, dy)
|
|
}
|
|
lastOri = { b: e.beta, g: e.gamma }
|
|
}
|
|
function onMotion(e) {
|
|
const r = e.rotationRate
|
|
if (gotOrientation || r?.alpha == null || r?.beta == null) return
|
|
const dt = stepOf(e.timeStamp)
|
|
add(quiet(r.alpha) * dt, quiet(r.beta) * dt) // especificación: alpha es el eje x y beta el eje y
|
|
}
|
|
function listen(on) {
|
|
if (on === listening) return
|
|
listening = on
|
|
const method = on ? 'addEventListener' : 'removeEventListener'
|
|
window[method]('devicemotion', onMotion)
|
|
window[method]('deviceorientation', onOrientation)
|
|
target.x = target.y = 0
|
|
lastT = performance.now()
|
|
lastOri = null
|
|
onChange()
|
|
}
|
|
const forget = () => { if (askOnTouch) document.removeEventListener('click', askOnTouch); askOnTouch = null }
|
|
// iOS pide permiso desde un toque. Con el permiso ya dado se repite sin toque; si no, espera al siguiente. Sin permiso, el libro queda quieto.
|
|
function permit() {
|
|
if (!isSecureContext) return
|
|
const M = window.DeviceMotionEvent, O = window.DeviceOrientationEvent
|
|
const asked = M?.requestPermission ? M.requestPermission() : O?.requestPermission ? O.requestPermission() : null
|
|
if (!asked) return listen(true)
|
|
asked.then(answer => { forget(); if (answer === 'granted' && document.visibilityState === 'visible') listen(true) }, () => {
|
|
if (askOnTouch) return
|
|
askOnTouch = () => { forget(); permit() }
|
|
document.addEventListener('click', askOnTouch)
|
|
})
|
|
}
|
|
return {
|
|
target,
|
|
permit,
|
|
listen,
|
|
get listening() { return listening },
|
|
// Con el dispositivo quieto pueden dejar de llegar lecturas: el regreso al centro va por tiempo.
|
|
fade(now) {
|
|
const dt = Math.min(0.1, Math.max(0, (now - lastFade) / 1000)), keep = Math.exp(-dt / FADE_S)
|
|
lastFade = now
|
|
for (const k of ['x', 'y']) target[k] = Math.abs(target[k] * keep) < 0.3 ? 0 : target[k] * keep
|
|
},
|
|
stop() { forget(); listen(false) },
|
|
}
|
|
}
|
|
|
|
export function createBook3D(container, opts) {
|
|
const { total, src, whenReady, spreadOf, isWide, still = false } = opts
|
|
const canvas = document.createElement('canvas')
|
|
canvas.className = 'b3dcanvas'
|
|
const gl = canvas.getContext('webgl2', { alpha: false, antialias: true, depth: true, powerPreference: 'high-performance' })
|
|
if (!gl) return null
|
|
container.replaceChildren(canvas)
|
|
let dead = false, raf = 0, u = {}, prog = null, wood = null, tableMesh = null, aniso = null, maxSize = 2400
|
|
let tableTex = null, tableAspect = 1
|
|
let backTex = null, backColors = null, backSent = '', backFrom = null, backNear = false, backTimer = 0
|
|
let W = 0, coverColor = [0.2, 0.14, 0.1], view = null, statics = null, staticKey = '', dyn = null
|
|
let anim = null, camNow = null, camMove = null, zoom = { s: 1, x: 0, y: 0 }, fxM = filterMatrix('')
|
|
const textures = new Map(), loading = new Map(), queue = []
|
|
const fitCache = new Map()
|
|
let uploadTimer = 0, rect = { width: 1, height: 1 }
|
|
const tiltNow = { x: 0, y: 0 }
|
|
const tilt = createTilt(() => draw())
|
|
|
|
function makeMesh(sh, usage = gl.STATIC_DRAW) {
|
|
const vao = gl.createVertexArray(), vb = gl.createBuffer(), ib = gl.createBuffer()
|
|
gl.bindVertexArray(vao)
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, vb)
|
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sh.v), usage)
|
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib)
|
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(sh.i), usage)
|
|
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 32, 0)
|
|
gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12)
|
|
gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24)
|
|
gl.bindVertexArray(null)
|
|
return { vao, vb, ib, n: sh.i.length }
|
|
}
|
|
const freeMesh = m => { if (m) { gl.deleteVertexArray(m.vao); gl.deleteBuffer(m.vb); gl.deleteBuffer(m.ib) } }
|
|
const drawMesh = m => { gl.bindVertexArray(m.vao); gl.drawElements(gl.TRIANGLES, m.n, gl.UNSIGNED_SHORT, 0) }
|
|
// Mallas que cambian en cada fotograma de una animación: se reutilizan los mismos búferes.
|
|
function dynMesh(name, sh) {
|
|
const m = (dyn[name] ||= makeMesh(new Shape(), gl.DYNAMIC_DRAW))
|
|
gl.bindVertexArray(m.vao)
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, m.vb)
|
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sh.v), gl.DYNAMIC_DRAW)
|
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, m.ib)
|
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(sh.i), gl.DYNAMIC_DRAW)
|
|
gl.bindVertexArray(null)
|
|
m.n = sh.i.length
|
|
return m
|
|
}
|
|
|
|
function initGL() {
|
|
const shader = (type, text) => { const s = gl.createShader(type); gl.shaderSource(s, text); gl.compileShader(s); return s }
|
|
prog = gl.createProgram()
|
|
gl.attachShader(prog, shader(gl.VERTEX_SHADER, VS))
|
|
gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, FS))
|
|
gl.linkProgram(prog)
|
|
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || 'shader')
|
|
gl.useProgram(prog)
|
|
u = {}
|
|
for (const n of ['uM', 'uKind', 'uTex', 'uColor', 'uMap', 'uRect', 'uSoft', 'uAlpha', 'uSeamAt', 'uHas', 'uFlip', 'uMirror', 'uCast', 'uTab', 'uFx', 'uFxO']) u[n] = gl.getUniformLocation(prog, n)
|
|
gl.uniform1i(u.uTex, 0)
|
|
gl.uniform1f(u.uFlip, 1)
|
|
gl.enable(gl.DEPTH_TEST)
|
|
gl.depthFunc(gl.LEQUAL)
|
|
gl.clearColor(0.29, 0.18, 0.1, 1)
|
|
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
|
|
aniso = gl.getExtension('EXT_texture_filter_anisotropic')
|
|
maxSize = Math.min(gl.getParameter(gl.MAX_TEXTURE_SIZE), 2400) // más grande no se aprecia y agota la memoria en la tableta
|
|
wood = gl.createTexture()
|
|
gl.bindTexture(gl.TEXTURE_2D, wood)
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, woodCanvas())
|
|
gl.generateMipmap(gl.TEXTURE_2D)
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
|
|
const TABLE = 14, TILE = 1.7
|
|
tableMesh = makeMesh(new Shape().grid(1, 1, (c, r) => { const x = (c * 2 - 1) * TABLE, y = (r * 2 - 1) * TABLE; return [x, y, 0, 0, 0, 1, x / TILE, y / TILE] }))
|
|
dyn = {}
|
|
statics = null
|
|
staticKey = ''
|
|
}
|
|
|
|
// ----- Texturas -----
|
|
function makeTexture(source) {
|
|
const tex = gl.createTexture()
|
|
gl.bindTexture(gl.TEXTURE_2D, tex)
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source)
|
|
gl.generateMipmap(gl.TEXTURE_2D)
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
|
if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)))
|
|
return tex
|
|
}
|
|
// Fondo elegido por el usuario: una sola imagen fija sobre la mesa, que llena lo que se ve. Si asoma el borde, sigue en espejo, sin corte.
|
|
function loadTable() {
|
|
if (!opts.table) return
|
|
const im = new Image()
|
|
im.onload = () => {
|
|
if (dead || gl.isContextLost() || !im.naturalWidth) return
|
|
tableTex = makeTexture(im)
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT)
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT)
|
|
tableAspect = im.naturalWidth / im.naturalHeight
|
|
draw()
|
|
}
|
|
im.src = opts.table
|
|
}
|
|
function tableScale() {
|
|
let half = 1.5
|
|
if (W > 0) {
|
|
const ref = fitOf({ kind: 'open', focus: rect.height > rect.width ? 1 : 0 }) // la vista más abierta de esta pantalla
|
|
const hy = ref.dist * 1.3 * Math.tan(FOV / 2) * 1.1 + Math.abs(ref.target[1]), hx = hy * (rect.width / rect.height) + Math.abs(ref.target[0])
|
|
half = Math.max(hy, hx / tableAspect)
|
|
}
|
|
return 1 / (2 * half)
|
|
}
|
|
// La contraportada: los colores de la portada, en espejo y muy difuminados para que continúe por el lomo, con un marco de su color de acento.
|
|
function buildBack(im) {
|
|
try {
|
|
const small = Object.assign(document.createElement('canvas'), { width: 48, height: 72 }), sg = small.getContext('2d', { willReadFrequently: true })
|
|
sg.drawImage(im, 0, 0, 48, 72)
|
|
const { base, accent } = coverPalette(sg.getImageData(0, 0, 48, 72).data)
|
|
const H = 1152, w = Math.round(H * Math.min(0.85, Math.max(0.45, im.naturalWidth / im.naturalHeight)))
|
|
const c = Object.assign(document.createElement('canvas'), { width: w, height: H }), g = c.getContext('2d', { willReadFrequently: true })
|
|
g.fillStyle = css(base)
|
|
g.fillRect(0, 0, w, H)
|
|
const blur = Object.assign(document.createElement('canvas'), { width: 3, height: 4 }), bg = blur.getContext('2d')
|
|
bg.translate(3, 0)
|
|
bg.scale(-1, 1)
|
|
bg.drawImage(small, 0, 0, 3, 4)
|
|
g.imageSmoothingEnabled = true
|
|
g.imageSmoothingQuality = 'high'
|
|
g.globalAlpha = 0.4
|
|
g.drawImage(blur, 0, 0, w, H)
|
|
g.globalAlpha = 1
|
|
const shade = g.createRadialGradient(w / 2, H / 2, H * 0.2, w / 2, H / 2, H * 0.72)
|
|
shade.addColorStop(0, 'rgba(0,0,0,0)')
|
|
shade.addColorStop(1, 'rgba(0,0,0,.28)')
|
|
g.fillStyle = shade
|
|
g.fillRect(0, 0, w, H)
|
|
const inset = w * 0.055
|
|
g.strokeStyle = css(accent)
|
|
g.lineWidth = w * 0.009
|
|
g.strokeRect(inset, inset, w - 2 * inset, H - 2 * inset)
|
|
g.lineWidth = w * 0.003
|
|
g.strokeRect(inset * 1.35, inset * 1.35, w - 2.7 * inset, H - 2.7 * inset)
|
|
const mid = g.getImageData(Math.round(w * 0.2), Math.round(H * 0.3), Math.round(w * 0.6), Math.round(H * 0.4)).data, surface = [0, 0, 0]
|
|
for (let p = 0; p < mid.length; p += 4) for (let k = 0; k < 3; k++) surface[k] += mid[p + k]
|
|
const area = mid.length / 4
|
|
for (let k = 0; k < 3; k++) surface[k] /= area * 255
|
|
if (backTex) gl.deleteTexture(backTex)
|
|
backTex = makeTexture(c)
|
|
backColors = { surface: css(surface), ink: css(inkOn(surface)), accent: css(accent), accentInk: css(inkOn(accent)), line: css(mixColor(surface, inkOn(surface), 0.45)) }
|
|
backSent = ''
|
|
} catch (err) { console.warn('Contraportada:', err) } // sin ella, la tapa queda del color del borde de la portada
|
|
}
|
|
// Se fabrica una vez y solo cerca del final: al abrir el capítulo no debe retrasar las primeras páginas.
|
|
function ensureBack() {
|
|
if (!backFrom || !backNear || dead || gl.isContextLost()) return
|
|
const im = backFrom
|
|
backFrom = null
|
|
buildBack(im)
|
|
realBack()
|
|
draw()
|
|
}
|
|
// La contraportada original del tomo, si existe, sustituye a la fabricada; los colores de los botones no cambian.
|
|
let backImage = null, backAsked = false
|
|
function realBack() {
|
|
if (backImage) return placeRealBack()
|
|
if (!opts.back || backAsked) return
|
|
backAsked = true
|
|
const im = new Image()
|
|
im.onload = () => {
|
|
if (dead || !im.naturalWidth) return
|
|
backImage = im
|
|
placeRealBack()
|
|
draw()
|
|
}
|
|
im.src = opts.back
|
|
}
|
|
function placeRealBack() {
|
|
if (dead || gl.isContextLost() || !backColors) return
|
|
let source = backImage
|
|
const big = Math.max(source.naturalWidth, source.naturalHeight)
|
|
if (big > maxSize) {
|
|
const k = maxSize / big, c = Object.assign(document.createElement('canvas'), { width: Math.round(source.naturalWidth * k), height: Math.round(source.naturalHeight * k) })
|
|
c.getContext('2d').drawImage(source, 0, 0, c.width, c.height)
|
|
source = c
|
|
}
|
|
if (backTex) gl.deleteTexture(backTex)
|
|
backTex = makeTexture(source)
|
|
}
|
|
function upload(i, im) {
|
|
let source = im, w = im.naturalWidth, h = im.naturalHeight
|
|
if (!w || !h) return
|
|
if (Math.max(w, h) > maxSize) { // reducir aquí: pasarla entera podría fallar
|
|
const k = maxSize / Math.max(w, h), c = Object.assign(document.createElement('canvas'), { width: Math.round(w * k), height: Math.round(h * k) }), g = c.getContext('2d')
|
|
g.imageSmoothingQuality = 'high'
|
|
g.drawImage(im, 0, 0, c.width, c.height)
|
|
source = c
|
|
}
|
|
if (i === 0) { coverColor = sampleCover(im); backFrom = im; ensureBack() }
|
|
const tex = makeTexture(source)
|
|
const had = textures.get(i)
|
|
if (had) gl.deleteTexture(had.tex)
|
|
textures.set(i, { tex, w, h })
|
|
// La forma del libro la da la primera página que no sea ancha.
|
|
if (!W && w < h) { W = Math.min(0.85, Math.max(0.45, w / h)); staticKey = ''; fitCache.clear() }
|
|
draw()
|
|
}
|
|
function sampleCover(im) {
|
|
try {
|
|
const c = Object.assign(document.createElement('canvas'), { width: 8, height: 8 }), g = c.getContext('2d', { willReadFrequently: true })
|
|
g.drawImage(im, 0, im.naturalHeight * 0.92, im.naturalWidth, im.naturalHeight * 0.08, 0, 0, 8, 8) // el canto de abajo, que es lo que se ve
|
|
const d = g.getImageData(0, 0, 8, 8).data, sum = [0, 0, 0]
|
|
for (let p = 0; p < d.length; p += 4) for (let k = 0; k < 3; k++) sum[k] += d[p + k]
|
|
return sum.map(v => (v / 64 / 255) * 0.62)
|
|
} catch { return coverColor }
|
|
}
|
|
// Se sube de una en una y lo que se ve ahora primero: subir una textura grande para un momento el hilo principal.
|
|
function pump() {
|
|
uploadTimer = 0
|
|
if (dead || !queue.length || gl.isContextLost()) return
|
|
if (anim && !anim.dragging && anim.p < 1) { uploadTimer = setTimeout(pump, 120); return } // no durante un giro
|
|
const visible = view ? pagesOf(view) : []
|
|
const at = queue.findIndex(q => visible.includes(q.i))
|
|
const [next] = queue.splice(Math.max(0, at), 1)
|
|
if (!textures.has(next.i) && wanted(next.i)) upload(next.i, next.im)
|
|
if (queue.length) uploadTimer = setTimeout(pump, visible.includes(queue[0]?.i) ? 0 : 90)
|
|
}
|
|
let keepFrom = 0, keepTo = 0
|
|
let glEpoch = 0 // lo que se pidió antes de perder el contexto ya no vale: su textura nacería muerta y la página quedaría en blanco
|
|
const wanted = i => i === 0 || (i >= keepFrom && i <= keepTo)
|
|
function want(i) {
|
|
if (i == null || i < 0 || i >= total || textures.has(i) || loading.has(i) || gl.isContextLost()) return
|
|
if (queue.some(q => q.i === i)) return
|
|
loading.set(i, true)
|
|
const epoch = glEpoch
|
|
const go = url => {
|
|
const im = new Image()
|
|
im.onload = async () => {
|
|
await im.decode?.().catch(() => {})
|
|
if (dead || epoch !== glEpoch) return
|
|
loading.delete(i)
|
|
queue.push({ i, im })
|
|
if (!uploadTimer) uploadTimer = setTimeout(pump, 0)
|
|
}
|
|
im.onerror = () => { if (epoch === glEpoch) loading.delete(i) }
|
|
im.src = url
|
|
}
|
|
if (whenReady) whenReady(i, go); else go(src(i))
|
|
}
|
|
function trim() {
|
|
for (const [i, t] of textures) if (!wanted(i)) { gl.deleteTexture(t.tex); textures.delete(i) }
|
|
for (let n = queue.length - 1; n >= 0; n--) if (!wanted(queue[n].i)) queue.splice(n, 1)
|
|
}
|
|
|
|
// ----- Lo que se ve -----
|
|
const rtl = () => opts.dir() === 'rtl'
|
|
function viewOf(group) {
|
|
const first = group[0], spread = spreadOf(first)
|
|
if (spread.length === 1 && isWide(first)) return { kind: 'wide', page: first, focus: 0, key: `w${first}` }
|
|
if (spread.length === 1 && first === 0) return { kind: 'closed', under: rtl() ? -1 : 1, cover: 0, focus: 0, key: 'c' }
|
|
const [a, b = null] = spread, right = rtl() ? a : b, left = rtl() ? b : a
|
|
return { kind: 'open', right, left, focus: group.length === 1 && !(spread.length === 1 && opts.double?.()) ? (group[0] === right ? 1 : -1) : 0, key: `o${a}` } // a doble página, una suelta se ve con el libro entero
|
|
}
|
|
const pagesOf = v => (v.kind === 'closed' ? [0] : v.kind === 'wide' ? [v.page] : [v.left, v.right]).filter(i => i != null)
|
|
const pageOn = (v, side) => (v.kind === 'wide' ? { i: v.page, wide: true } : v.kind === 'closed' ? { i: null } : { i: side > 0 ? v.right : v.left })
|
|
const hasAll = v => pagesOf(v).every(i => textures.has(i))
|
|
|
|
// Lo que debe llenar la pantalla. A una página, solo esa mitad; la otra sigue ahí, fuera de cuadro.
|
|
function frameOf(v) {
|
|
const O = BOOK.OVER, y0 = -0.5 - O, y1 = 0.5 + O, h = BOOK.BOARD + BOOK.THICK + BOOK.CROWN
|
|
let x0 = -W - O, x1 = W + O
|
|
if (v.kind === 'closed') {
|
|
x0 = v.under > 0 ? -BOOK.SPINE : -W - O
|
|
x1 = v.under > 0 ? W + O : BOOK.SPINE
|
|
} else if (v.focus) {
|
|
x0 = v.focus > 0 ? 0 : -W - O
|
|
x1 = v.focus > 0 ? W + O : 0
|
|
}
|
|
return [[x0, y0, 0], [x1, y0, 0], [x0, y1, 0], [x1, y1, 0], [x0, y0, h], [x1, y0, h], [x0, y1, h], [x1, y1, h]]
|
|
}
|
|
// El encuadre se calcula una vez por vista y tamaño de pantalla: no hace falta repetirlo en cada fotograma.
|
|
function fitOf(v) {
|
|
const margin = Math.max(14, Math.min(40, Math.min(rect.width, rect.height) * 0.04))
|
|
const key = `${v.kind}:${v.under || 0}:${v.focus}:${W}:${rect.width}x${rect.height}`
|
|
let cam = fitCache.get(key)
|
|
if (!cam) {
|
|
cam = fitCamera(frameOf(v), rect.width / rect.height, margin / rect.width, margin / rect.height)
|
|
if (fitCache.size > 12) fitCache.clear()
|
|
fitCache.set(key, cam)
|
|
}
|
|
return cam
|
|
}
|
|
const mixCam = (a, b, t) => ({ target: a.target.map((n, k) => n + (b.target[k] - n) * t), dist: a.dist + (b.dist - a.dist) * t })
|
|
|
|
// Libro en reposo: la malla se reutiliza. Durante la cubierta se rehace en cada fotograma.
|
|
function bookShapes(o, under, land) {
|
|
const O = BOOK.OVER, ed = new Shape()
|
|
const out = { left: null, right: null }
|
|
for (const side of [-1, 1]) {
|
|
const isLand = under && side === -under, k = isLand ? land.k : 1, width = isLand ? land.width : 1
|
|
if (!width) continue
|
|
out[side > 0 ? 'right' : 'left'] = pageShape(side, W, o, k, width)
|
|
edgeShape(ed, side, W, o, k, width)
|
|
}
|
|
const landOf = side => (under && side === -under ? land.width : 1)
|
|
const xL = -W * landOf(-1) - (landOf(-1) ? O : 0), xR = W * landOf(1) + (landOf(1) ? O : 0)
|
|
out.edges = ed
|
|
out.board = new Shape().box(xL, xR, -0.5 - O, 0.5 + O, 0, BOOK.BOARD)
|
|
return out
|
|
}
|
|
function staticMeshes(v) {
|
|
const key = `${v.kind === 'closed' ? `c${v.under}` : 'o'}:${W}`
|
|
if (key === staticKey && statics) return statics
|
|
if (statics) for (const m of Object.values(statics)) freeMesh(m)
|
|
staticKey = key
|
|
const sh = v.kind === 'closed' ? bookShapes(0, v.under, { k: 0, width: 0 }) : bookShapes(1, 0, null)
|
|
statics = {}
|
|
for (const [n, s] of Object.entries(sh)) if (s) statics[n] = makeMesh(s)
|
|
return statics
|
|
}
|
|
|
|
function pageMap(i, side, wide) {
|
|
const t = textures.get(i)
|
|
if (!t) return null
|
|
const region = wide ? 2 * W : W, a = t.w / t.h
|
|
let sx = 1, sy = 1 // Fracción del hueco que llena la imagen, sin deformarla
|
|
if (a < region) sx = a / region; else sy = region / a
|
|
const ox = (1 - sx) / 2, oy = (1 - sy) / 2
|
|
const scale = wide ? 0.5 : 1, start = wide && side > 0 ? 0.5 : 0
|
|
return { tex: t.tex, map: [scale / sx, (start - ox) / sx, 1 / sy, -oy / sy] }
|
|
}
|
|
function drawPage(mesh, page, side, mirror = false) {
|
|
const p = page?.i == null ? null : pageMap(page.i, side, page.wide)
|
|
gl.uniform1i(u.uKind, 4)
|
|
gl.uniform1f(u.uSeamAt, side > 0 ? 0 : 1)
|
|
gl.uniform1f(u.uHas, p ? 1 : 0)
|
|
gl.uniform1f(u.uMirror, mirror ? 1 : 0)
|
|
if (p) { gl.bindTexture(gl.TEXTURE_2D, p.tex); gl.uniform4fv(u.uMap, p.map) }
|
|
gl.uniform3fv(u.uColor, opts.paper())
|
|
drawMesh(mesh)
|
|
gl.uniform1f(u.uMirror, 0)
|
|
}
|
|
// Dibuja las dos caras de una hoja: la de delante en su lado y la de detrás, ya volteada, en el otro.
|
|
function drawSheet(mesh, side, front, back, lid = null) {
|
|
gl.enable(gl.CULL_FACE)
|
|
gl.frontFace(side > 0 ? gl.CCW : gl.CW)
|
|
gl.cullFace(gl.BACK)
|
|
if (lid) {
|
|
gl.uniform1i(u.uKind, 5)
|
|
gl.uniform1f(u.uHas, lid.tex ? 1 : 0)
|
|
if (lid.tex) gl.bindTexture(gl.TEXTURE_2D, lid.tex)
|
|
gl.uniform3fv(u.uColor, coverColor)
|
|
drawMesh(mesh)
|
|
} else drawPage(mesh, front, side)
|
|
gl.cullFace(gl.FRONT)
|
|
gl.uniform1f(u.uFlip, -1)
|
|
drawPage(mesh, back, -side, true)
|
|
gl.uniform1f(u.uFlip, 1)
|
|
gl.disable(gl.CULL_FACE)
|
|
}
|
|
|
|
function drawBook(m, left, right) {
|
|
gl.uniform1i(u.uKind, 2)
|
|
gl.uniform3fv(u.uColor, coverColor)
|
|
drawMesh(m.board)
|
|
gl.uniform1i(u.uKind, 3)
|
|
gl.uniform3fv(u.uColor, opts.paper())
|
|
drawMesh(m.edges)
|
|
if (m.left) drawPage(m.left, left, -1)
|
|
if (m.right) drawPage(m.right, right, 1)
|
|
}
|
|
// Lo que va en la tapa: la portada delante, o la contraportada por detrás del libro. El lomo toma el color de su borde.
|
|
const lidOf = isBack => ({ tex: isBack ? backTex : textures.get(0)?.tex })
|
|
function drawSpine(under, top, size, lid) {
|
|
gl.uniform1i(u.uKind, 6)
|
|
gl.uniform1f(u.uHas, lid.tex ? 1 : 0)
|
|
if (lid.tex) gl.bindTexture(gl.TEXTURE_2D, lid.tex)
|
|
gl.uniform3fv(u.uColor, coverColor)
|
|
gl.uniform1f(u.uSeamAt, under > 0 ? 0.01 : 0.99)
|
|
drawMesh(dynMesh('spine', spineShape(-under, top, size)))
|
|
}
|
|
// La cubierta: una pieza rígida con grosor. a: 0 cerrada, 1 ya abierta.
|
|
function drawCover(under, a, backPage, lid) {
|
|
const O = BOOK.OVER
|
|
const pts = sheetCurve(a, under, W, true, 12, O)
|
|
pts.forEach(p => { p.u = under > 0 ? p.t : 1 - p.t })
|
|
const mesh = dynMesh('cover', sheetShape(pts, -0.5 - O, 0.5 + O))
|
|
gl.uniform1i(u.uKind, 2)
|
|
gl.uniform3fv(u.uColor, coverColor)
|
|
drawMesh(dynMesh('rim', rimShape(pts, -0.5 - O, 0.5 + O, BOOK.BOARD)))
|
|
drawSheet(mesh, under, null, backPage, lid)
|
|
return pts.at(-1)
|
|
}
|
|
|
|
function stepTilt(now) {
|
|
tilt.fade(now)
|
|
const on = !still && zoom.s === 1
|
|
const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 }
|
|
let moving = !!(tilt.target.x || tilt.target.y) && on
|
|
for (const k of ['x', 'y']) {
|
|
const d = want[k] - tiltNow[k]
|
|
if (Math.abs(d) < 0.002) tiltNow[k] = want[k]
|
|
else { tiltNow[k] += d * 0.25; moving = true }
|
|
}
|
|
return moving
|
|
}
|
|
function stepAnim(now) {
|
|
if (!anim) return false
|
|
const dt = Math.min(0.12, (now - anim.last) / 1000) // tras un tirón, recuperar sin alargar la animación
|
|
anim.last = now
|
|
if (anim.dragging) {
|
|
const d = anim.goal - anim.p
|
|
anim.p += d * 0.35
|
|
return true
|
|
}
|
|
const before = anim.p
|
|
anim.p = anim.back ? Math.max(0, before - dt / anim.dur) : Math.min(1, before + dt / anim.dur)
|
|
if ((anim.back && anim.p <= 0) || (!anim.back && anim.p >= 1)) { finishAnim(!anim.back); return !!anim }
|
|
return true
|
|
}
|
|
// Acaba la animación en uno de los dos extremos y deja el libro en reposo.
|
|
function finishAnim(commit = true) {
|
|
const a = anim
|
|
if (!a) return
|
|
anim = null
|
|
view = commit ? a.to : a.from
|
|
camNow = null
|
|
camMove = null
|
|
opts.onTurnEnd?.(a.ctl)
|
|
a.done?.(commit)
|
|
draw()
|
|
}
|
|
|
|
// Dónde queda la contraportada en la pantalla: el lector pone ahí los botones, y siguen al libro cuando se mueve.
|
|
function reportBack(M) {
|
|
if (!opts.onBack) return
|
|
let at = null
|
|
if (!anim && view?.back && W > 0) {
|
|
const O = BOOK.OVER, far = view.under * (W + O)
|
|
const q = [[0, -0.5 - O], [far, -0.5 - O], [0, 0.5 + O], [far, 0.5 + O]].map(([x, y]) => project(M, [x, y, LID_Z]))
|
|
const xs = q.map(p => ((p[0] + 1) / 2) * rect.width), ys = q.map(p => ((1 - p[1]) / 2) * rect.height)
|
|
at = { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) }
|
|
}
|
|
const key = at ? `${Math.round(at.x)},${Math.round(at.y)},${Math.round(at.width)},${Math.round(at.height)}` : 'off'
|
|
if (key === (backSent || 'off')) return
|
|
backSent = key
|
|
opts.onBack(at, backColors)
|
|
}
|
|
|
|
function paint(now) {
|
|
raf = 0
|
|
if (dead || gl.isContextLost()) return
|
|
rect = container.getBoundingClientRect()
|
|
if (!rect.width || !rect.height) return
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 3)
|
|
const cw = Math.round(rect.width * dpr), ch = Math.round(rect.height * dpr)
|
|
if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch }
|
|
gl.viewport(0, 0, cw, ch)
|
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
|
|
let again = stepTilt(now)
|
|
if (stepAnim(now)) again = true
|
|
const v = anim ? anim.to : view
|
|
const ready = v && W > 0
|
|
const aspect = cw / ch
|
|
let cam = { target: [0, 0, 0], dist: 4 }
|
|
if (ready) {
|
|
cam = fitOf(v)
|
|
if (anim) cam = mixCam(anim.camFrom, cam, easeIO(anim.p))
|
|
else if (camMove) {
|
|
const t = Math.min(1, (now - camMove.t0) / camMove.dur)
|
|
cam = mixCam(camMove.from, cam, easeIO(t))
|
|
if (t >= 1) camMove = null; else again = true
|
|
}
|
|
camNow = cam
|
|
}
|
|
// La sombra de la hoja se calcula antes, porque cae también en la mesa.
|
|
let castX = 0, castA = 0, cover = null, sheet = null
|
|
if (ready && anim) {
|
|
const e = anim.dragging ? anim.p : easeIO(anim.p)
|
|
if (anim.kind === 'cover') {
|
|
cover = { a: anim.opening ? e : 1 - e, under: anim.under }
|
|
castX = Math.cos(cover.a * Math.PI) * cover.under * (W + BOOK.OVER)
|
|
castA = Math.sin(cover.a * Math.PI) * 0.5
|
|
} else {
|
|
sheet = { a: e, side: anim.side }
|
|
sheet.pts = sheetCurve(e, anim.side, W)
|
|
castX = sheet.pts.at(-1).x
|
|
castA = Math.sin(e * Math.PI) * 0.45
|
|
}
|
|
}
|
|
// Una hoja en alto queda cerca de la cámara: durante el giro, esta se aleja y se pone más vertical para que quepa y no se vea torcida.
|
|
const lift = cover ? Math.sin(cover.a * Math.PI) : sheet ? 0.35 * Math.sin(sheet.a * Math.PI) : 0
|
|
if (lift) cam = { target: cam.target, dist: cam.dist * (1 + 0.3 * lift) }
|
|
// Al rodear el libro, la cámara se aleja un poco para que el lado que se acerca no se salga de la pantalla.
|
|
const round = Math.max(Math.abs(tiltNow.x), Math.abs(tiltNow.y))
|
|
if (round) cam = { target: cam.target, dist: cam.dist * (1 + ORBIT_BACK * round) }
|
|
const dTilt = ((-tiltNow.x * ORBIT_TILT - 9 * lift) * Math.PI) / 180, yaw = (-tiltNow.y * ORBIT_YAW * Math.PI) / 180
|
|
const M = cameraMatrix(cam.target, cam.dist, aspect, TILT + dTilt, yaw)
|
|
// Zoom del lector y el pequeño desplazamiento del movimiento: se aplican a la imagen final, que se vuelve a dibujar nítida.
|
|
const s = zoom.s, ox = s - 1 + (2 * (zoom.x + tiltNow.y * 3)) / rect.width, oy = -(s - 1) - (2 * (zoom.y + tiltNow.x * 3)) / rect.height
|
|
for (let c = 0; c < 4; c++) {
|
|
M[c * 4] = s * M[c * 4] + ox * M[c * 4 + 3]
|
|
M[c * 4 + 1] = s * M[c * 4 + 1] + oy * M[c * 4 + 3]
|
|
}
|
|
gl.uniformMatrix4fv(u.uM, false, M)
|
|
reportBack(M)
|
|
gl.uniformMatrix3fv(u.uFx, false, [fxM[0][0], fxM[1][0], fxM[2][0], fxM[0][1], fxM[1][1], fxM[2][1], fxM[0][2], fxM[1][2], fxM[2][2]])
|
|
gl.uniform3f(u.uFxO, fxM[0][3], fxM[1][3], fxM[2][3])
|
|
gl.uniform2f(u.uCast, castX, castA)
|
|
// Mesa
|
|
gl.depthMask(true)
|
|
gl.uniform1i(u.uKind, 0)
|
|
gl.bindTexture(gl.TEXTURE_2D, tableTex || wood)
|
|
if (tableTex) { const ts = tableScale(); gl.uniform3f(u.uTab, ts / tableAspect, ts, 1) } else gl.uniform3f(u.uTab, 0, 0, 0)
|
|
gl.uniform4f(u.uRect, cam.target[0], cam.target[1], Math.max(W, 0.7) * 1.3, 1)
|
|
drawMesh(tableMesh)
|
|
if (!ready) { if (again) draw(); return }
|
|
// Sombras en la mesa: una muy pegada une el libro con la madera, la otra cae a un lado y abajo.
|
|
const O = BOOK.OVER
|
|
let bx0 = -W - O, bx1 = W + O
|
|
if (cover || v.kind === 'closed') {
|
|
const under = cover ? cover.under : v.under, reach = cover ? landWidth(cover.a) : 0
|
|
if (under > 0) { bx0 = -(W + O) * reach - BOOK.SPINE * (1 - reach); bx1 = W + O } else { bx0 = -W - O; bx1 = (W + O) * reach + BOOK.SPINE * (1 - reach) }
|
|
}
|
|
gl.enable(gl.BLEND)
|
|
gl.depthMask(false)
|
|
gl.disable(gl.DEPTH_TEST)
|
|
gl.uniform1i(u.uKind, 1)
|
|
for (const [dx, dy, soft, alpha] of [[-0.05, -0.062, 0.1, 0.5], [-0.02, -0.026, 0.04, 0.58], [-0.003, -0.004, 0.012, 0.75]]) {
|
|
gl.uniform4f(u.uRect, bx0 + dx, -0.5 - O + dy, bx1 + dx, 0.5 + O + dy)
|
|
gl.uniform1f(u.uSoft, soft)
|
|
gl.uniform1f(u.uAlpha, alpha)
|
|
drawMesh(tableMesh)
|
|
}
|
|
gl.disable(gl.BLEND)
|
|
gl.enable(gl.DEPTH_TEST)
|
|
gl.depthMask(true)
|
|
// Libro
|
|
if (cover) {
|
|
// Durante la apertura, la mitad sobre la que aterriza se extiende justo debajo de la cubierta.
|
|
const openV = anim.opening ? anim.to : anim.from, a = cover.a, o = smooth(0, 1, a)
|
|
const width = landWidth(a), land = { k: smooth(0.97, 1, a), width }
|
|
const sh = bookShapes(o, cover.under, land), m = {}
|
|
for (const [n, q] of Object.entries(sh)) if (q) m[n] = dynMesh(`b_${n}`, q)
|
|
const under = pageOn(openV, cover.under)
|
|
const pages = { [cover.under]: under, [-cover.under]: { i: null } }
|
|
drawBook(m, pages[-1], pages[1])
|
|
const size = BOOK.SPINE * Math.max(0, 1 - a * 2.5)
|
|
const lid = lidOf(anim.from.back || anim.to.back)
|
|
if (size > 0) drawSpine(cover.under, LID_Z, size, lid)
|
|
drawCover(cover.under, a, pageOn(openV, -cover.under), lid)
|
|
} else if (sheet) {
|
|
// La hoja lleva por delante la página vieja de su lado y por detrás la nueva del otro. Debajo ya se ve la nueva de su lado.
|
|
const m = staticMeshes(v), s0 = sheet.side
|
|
const pages = { [s0]: pageOn(anim.to, s0), [-s0]: pageOn(anim.from, -s0) }
|
|
drawBook(m, pages[-1], pages[1])
|
|
sheet.pts.forEach(p => { p.u = s0 > 0 ? p.t : 1 - p.t })
|
|
gl.uniform2f(u.uCast, 0, 0)
|
|
drawSheet(dynMesh('sheet', sheetShape(sheet.pts)), s0, pageOn(anim.from, s0), pageOn(anim.to, -s0))
|
|
} else if (v.kind === 'closed') {
|
|
drawBook(staticMeshes(v), null, null)
|
|
const lid = lidOf(v.back)
|
|
drawSpine(v.under, LID_Z, BOOK.SPINE, lid)
|
|
drawCover(v.under, 0, { i: null }, lid)
|
|
} else {
|
|
drawBook(staticMeshes(v), pageOn(v, -1), pageOn(v, 1))
|
|
}
|
|
if (again) draw()
|
|
}
|
|
function draw() { if (!raf && !dead) raf = requestAnimationFrame(paint) }
|
|
|
|
function startTurn(from, to, dir, drag) {
|
|
const fwd = dir > 0, r = rtl()
|
|
const ctl = { anims: [{ cancel() {} }], range: Math.max(200, rect.width * (from.focus || to.focus ? 0.9 : 0.5)) }
|
|
const shut = from.kind === 'closed' ? from : to.kind === 'closed' ? to : null, cover = !!shut
|
|
anim = {
|
|
ctl, from, to, p: 0, goal: 0, back: false, dragging: !!drag, last: performance.now(), camFrom: camNow || fitOf(from),
|
|
kind: cover ? 'cover' : 'sheet', dur: cover ? 1.3 : 0.75,
|
|
opening: to.kind !== 'closed', under: shut?.under ?? (r ? -1 : 1),
|
|
side: fwd === r ? -1 : 1, // lado del que sale la hoja: en der → izq, avanzar la levanta de la izquierda
|
|
}
|
|
ctl.cleanup = () => { if (anim?.ctl === ctl) finishAnim(!anim.back) }
|
|
if (drag) {
|
|
ctl.move = q => { if (anim?.ctl === ctl) { anim.goal = Math.max(0, Math.min(1, q)); draw() } }
|
|
ctl.release = (commit, done) => {
|
|
if (anim?.ctl !== ctl) return done?.(commit)
|
|
anim.dragging = false
|
|
anim.back = !commit
|
|
anim.done = done
|
|
anim.dur *= 0.7
|
|
// Lo arrastrado ya es lineal: se pasa a la curva de la animación sin saltos.
|
|
anim.p = inverseEase(anim.p)
|
|
anim.last = performance.now()
|
|
draw()
|
|
}
|
|
}
|
|
return ctl
|
|
}
|
|
const inverseEase = y => (y < 0.5 ? Math.sqrt(y / 2) : 1 - Math.sqrt((1 - y) / 2))
|
|
|
|
const ro = new ResizeObserver(draw)
|
|
ro.observe(container)
|
|
const onVis = () => { if (document.visibilityState === 'visible') { if (!still) tilt.permit() } else tilt.listen(false) }
|
|
document.addEventListener('visibilitychange', onVis)
|
|
canvas.addEventListener('webglcontextlost', e => {
|
|
e.preventDefault()
|
|
glEpoch++
|
|
if (anim) finishAnim(true)
|
|
textures.clear(); loading.clear(); queue.length = 0; statics = null; staticKey = ''; backTex = null; tableTex = null
|
|
})
|
|
canvas.addEventListener('webglcontextrestored', () => {
|
|
initGL()
|
|
loadTable()
|
|
want(0)
|
|
if (view) for (const i of pagesOf(view)) want(i)
|
|
draw()
|
|
})
|
|
initGL()
|
|
loadTable()
|
|
if (!still) tilt.permit()
|
|
|
|
const api = {
|
|
// Devuelve el control del giro si lo hay: el lector lo usa para el arrastre y para acabarlo antes de tiempo.
|
|
show(group, dir = 0, drag = false) {
|
|
if (!group.length) return null
|
|
if (anim) finishAnim(!anim.back)
|
|
const next = viewOf(group), first = group[0], last = group.at(-1)
|
|
keepFrom = first - 3
|
|
keepTo = last + 4
|
|
pagesOf(next).forEach(want)
|
|
want(0) // la portada da el color de la tapa y de la contraportada, aunque se empiece a mitad de capítulo
|
|
if (!backNear && last + 4 >= total - 1) { backNear = true; clearTimeout(backTimer); backTimer = setTimeout(ensureBack, 1200) } // cuando acabe el giro
|
|
for (let k = 1; k <= 3; k++) { want(last + k); want(first - k) }
|
|
trim()
|
|
const prev = view
|
|
let ctl = null
|
|
if (prev && dir && prev.key !== next.key && !(prev.kind === 'closed' && next.kind === 'closed') && W > 0 && hasAll(next)) ctl = startTurn(prev, next, dir, drag)
|
|
else if (prev && dir && prev.key === next.key && prev.focus !== next.focus && W > 0 && camNow) camMove = { from: camNow, t0: performance.now(), dur: 450 } // la otra cara de la misma pareja
|
|
if (ctl) anim.to = next
|
|
else view = next
|
|
draw()
|
|
return ctl
|
|
},
|
|
// Fin del capítulo: la tapa de atrás se cierra sobre las hojas. El libro queda boca abajo y con el lomo al otro lado.
|
|
showBack(dir = 0, drag = false) {
|
|
if (anim) finishAnim(!anim.back)
|
|
want(0)
|
|
backNear = true
|
|
ensureBack()
|
|
const next = { kind: 'closed', back: true, under: rtl() ? 1 : -1, focus: 0, key: 'e' }, prev = view
|
|
const ctl = prev && dir && prev.kind !== 'closed' && W > 0 && textures.has(0) ? startTurn(prev, next, dir, drag) : null
|
|
if (!ctl) view = next
|
|
draw()
|
|
return ctl
|
|
},
|
|
setZoom(s, x, y) { zoom = { s, x, y }; draw() },
|
|
setFilter(css) { fxM = filterMatrix(css); draw() },
|
|
invalidate(i) {
|
|
const t = textures.get(i)
|
|
if (t) { gl.deleteTexture(t.tex); textures.delete(i) }
|
|
want(i)
|
|
},
|
|
draw,
|
|
// Para las pruebas.
|
|
debug: () => ({ view: view?.key, focus: view?.focus, anim: anim && { kind: anim.kind, p: anim.p, side: anim.side, dragging: anim.dragging }, tilt: { ...tiltNow }, target: { ...tilt.target }, listening: tilt.listening, textures: [...textures.keys()], W, zoom: zoom.s, cam: camNow && { x: camNow.target[0], dist: camNow.dist } }),
|
|
destroy() {
|
|
dead = true
|
|
cancelAnimationFrame(raf)
|
|
clearTimeout(uploadTimer)
|
|
clearTimeout(backTimer)
|
|
ro.disconnect()
|
|
tilt.stop()
|
|
document.removeEventListener('visibilitychange', onVis)
|
|
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
|
canvas.remove()
|
|
},
|
|
}
|
|
canvas.b3d = api // para las pruebas
|
|
return api
|
|
}
|