bookcover-3d-web 0.1.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.
Files changed (51) hide show
  1. package/README.md +159 -0
  2. package/README_mockup.webp +0 -0
  3. package/README_sample.webp +0 -0
  4. package/dist/geometry.d.ts +12 -0
  5. package/dist/geometry.d.ts.map +1 -0
  6. package/dist/geometry.js +390 -0
  7. package/dist/geometry.js.map +1 -0
  8. package/dist/index.d.ts +44 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +404 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/math.d.ts +15 -0
  13. package/dist/math.d.ts.map +1 -0
  14. package/dist/math.js +92 -0
  15. package/dist/math.js.map +1 -0
  16. package/dist/photo.d.ts +6 -0
  17. package/dist/photo.d.ts.map +1 -0
  18. package/dist/photo.js +72 -0
  19. package/dist/photo.js.map +1 -0
  20. package/dist/svg.d.ts +12 -0
  21. package/dist/svg.d.ts.map +1 -0
  22. package/dist/svg.js +40 -0
  23. package/dist/svg.js.map +1 -0
  24. package/dist/types.d.ts +33 -0
  25. package/dist/types.d.ts.map +1 -0
  26. package/dist/types.js +3 -0
  27. package/dist/types.js.map +1 -0
  28. package/dist/webgl.d.ts +15 -0
  29. package/dist/webgl.d.ts.map +1 -0
  30. package/dist/webgl.js +119 -0
  31. package/dist/webgl.js.map +1 -0
  32. package/package.json +18 -0
  33. package/src/backgrounds/coffee_table.jpg +0 -0
  34. package/src/backgrounds/table_side.jpg +0 -0
  35. package/src/backgrounds/table_with_book.jpg +0 -0
  36. package/src/backgrounds/table_with_laptop.jpg +0 -0
  37. package/src/backgrounds/wood.jpg +0 -0
  38. package/src/backgrounds_originals/coffee_table.jpg +0 -0
  39. package/src/backgrounds_originals/table_side.jpg +0 -0
  40. package/src/backgrounds_originals/table_with_book.jpg +0 -0
  41. package/src/backgrounds_originals/table_with_laptop.jpg +0 -0
  42. package/src/backgrounds_originals/wood.jpg +0 -0
  43. package/src/geometry.ts +513 -0
  44. package/src/index.ts +507 -0
  45. package/src/math.ts +84 -0
  46. package/src/photo.ts +77 -0
  47. package/src/svg.ts +46 -0
  48. package/src/types.ts +49 -0
  49. package/src/webgl.ts +131 -0
  50. package/tsconfig.json +19 -0
  51. package/tsconfig.tsbuildinfo +1 -0
package/src/index.ts ADDED
@@ -0,0 +1,507 @@
1
+
2
+ // 3D book renderer — public API
3
+
4
+ export type {BookFaces, CoverType, GenerateOptions, Background, PhotoCompositeOptions} from './types.js'
5
+ export {BACKGROUNDS} from './photo.js'
6
+ import type {BookFaces, CoverType, GenerateOptions, PhotoCompositeOptions} from './types.js'
7
+ import {PHOTO_AZIMUTH, PHOTO_ELEVATION, PHOTO_BOOK_SCALE} from './photo.js'
8
+ import type {FaceData} from './geometry.js'
9
+
10
+ import {mat4_perspective, mat4_look_at, mat4_mul, mat4_identity, mat4_rotate_z, normal_matrix} from './math.js'
11
+ import {parse_svg_size, svg_to_bitmap} from './svg.js'
12
+ import {VERT_SRC, FRAG_SRC, DEFAULT_LIGHT_AZ, DEFAULT_LIGHT_EL, DEFAULT_AMBIENT, build_program, upload_texture, create_page_edge_texture} from './webgl.js'
13
+ import {build_faces} from './geometry.js'
14
+
15
+ // -- Defaults --
16
+
17
+ const DEFAULT_AZIMUTH = -30
18
+ const DEFAULT_ELEVATION = 20
19
+ const DEFAULT_WIDTH = 800
20
+ const DEFAULT_HEIGHT = 600
21
+
22
+ // Camera projection constants — shared between render() and shadow projection in composite_photo()
23
+ const RENDER_FOV = 40 // field of view in degrees
24
+ const RENDER_DIST_FACTOR = 3 // camera distance = RENDER_DIST_FACTOR * book_height * zoom
25
+
26
+ // -- Internal types --
27
+
28
+ interface GlState {
29
+ gl:WebGLRenderingContext
30
+ prog:WebGLProgram
31
+ // Attribute locations
32
+ a_pos:number
33
+ a_uv:number
34
+ a_normal:number
35
+ // Uniform locations
36
+ u_mvp:WebGLUniformLocation
37
+ u_mv:WebGLUniformLocation
38
+ u_norm_mat:WebGLUniformLocation
39
+ u_light_dir:WebGLUniformLocation
40
+ u_ambient:WebGLUniformLocation
41
+ u_exposure:WebGLUniformLocation
42
+ u_tex:WebGLUniformLocation
43
+ u_use_tex:WebGLUniformLocation
44
+ u_color:WebGLUniformLocation
45
+ u_cam_z:WebGLUniformLocation
46
+ }
47
+
48
+ /** Convert light azimuth/elevation degrees to a normalised world-space direction vector.
49
+ * Convention matches camera spherical coords: az=0 = from front, el=0 = horizontal. */
50
+ function light_dir_vec3(az_deg:number, el_deg:number):[number, number, number] {
51
+ const az = az_deg * Math.PI / 180
52
+ const el = el_deg * Math.PI / 180
53
+ const x = Math.sin(az) * Math.cos(el)
54
+ const y = Math.sin(el)
55
+ const z = Math.cos(az) * Math.cos(el)
56
+ const len = Math.sqrt(x * x + y * y + z * z)
57
+ return [x / len, y / len, z / len]
58
+ }
59
+
60
+ interface LoadedFace {
61
+ vbo:WebGLBuffer
62
+ ibo:WebGLBuffer
63
+ tex:WebGLTexture | null
64
+ color:[number,number,number]
65
+ index_count:number
66
+ }
67
+
68
+ // -- Renderer --
69
+
70
+ /** Persistent WebGL book renderer. Create once, call load() when covers change, render() cheaply. */
71
+ export class Book3DRenderer {
72
+
73
+ readonly canvas:OffscreenCanvas | HTMLCanvasElement
74
+ private _gl_state:GlState | null = null
75
+ private _faces:LoadedFace[] = []
76
+ private _w = 1
77
+ private _h = 1
78
+ private _d = 0.1
79
+ // Physical cover height in pt (Typst SVG output), used for photo composite scaling
80
+ private _cover_height = 1
81
+ private _textures:WebGLTexture[] = []
82
+ private _page_tex:WebGLTexture | null = null
83
+
84
+ // Prefer OffscreenCanvas for performance; fall back to DOM canvas for WebGL compatibility
85
+ constructor(width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT) {
86
+ const offscreen = Book3DRenderer._try_offscreen_webgl(width, height)
87
+ if (offscreen) {
88
+ this.canvas = offscreen
89
+ } else {
90
+ const el = document.createElement('canvas')
91
+ el.width = width
92
+ el.height = height
93
+ el.style.display = 'none'
94
+ this.canvas = el
95
+ }
96
+ }
97
+
98
+ /** Resize the render canvas without recreating the WebGL context or re-uploading textures */
99
+ resize(width:number, height:number):void {
100
+ this.canvas.width = width
101
+ this.canvas.height = height
102
+ }
103
+
104
+ /** Projected aspect ratio (width/height) of the book at the default viewing angle */
105
+ get_projected_aspect():number {
106
+ const az_rad = Math.abs(DEFAULT_AZIMUTH) * Math.PI / 180
107
+ const el_rad = Math.abs(DEFAULT_ELEVATION) * Math.PI / 180
108
+ const proj_w = this._w * Math.cos(az_rad) + this._d * Math.sin(az_rad)
109
+ const proj_h = this._h * Math.cos(el_rad)
110
+ return proj_w / proj_h
111
+ }
112
+
113
+ // Test whether OffscreenCanvas supports WebGL in this browser
114
+ private static _try_offscreen_webgl(w:number, h:number):OffscreenCanvas | null {
115
+ try {
116
+ const test = new OffscreenCanvas(1, 1)
117
+ const gl = test.getContext('webgl', {alpha: true, premultipliedAlpha: false, antialias: true})
118
+ if (!gl)
119
+ return null
120
+ gl.getExtension('WEBGL_lose_context')?.loseContext()
121
+ return new OffscreenCanvas(w, h)
122
+ } catch { /* OffscreenCanvas not supported */ }
123
+ return null
124
+ }
125
+
126
+ /** Initialise WebGL on first use */
127
+ private _init_gl():GlState {
128
+ if (this._gl_state)
129
+ return this._gl_state
130
+
131
+ const gl = this.canvas.getContext('webgl', {
132
+ alpha: true, premultipliedAlpha: false, antialias: true,
133
+ }) as WebGLRenderingContext | null
134
+ if (!gl)
135
+ throw new Error('[cover-3d] WebGL not available in this environment')
136
+
137
+ const prog = build_program(gl, VERT_SRC, FRAG_SRC)
138
+
139
+ this._gl_state = {
140
+ gl, prog,
141
+ a_pos: gl.getAttribLocation(prog, 'a_pos'),
142
+ a_uv: gl.getAttribLocation(prog, 'a_uv'),
143
+ a_normal: gl.getAttribLocation(prog, 'a_normal'),
144
+ u_mvp: gl.getUniformLocation(prog, 'u_mvp')!,
145
+ u_mv: gl.getUniformLocation(prog, 'u_mv')!,
146
+ u_norm_mat: gl.getUniformLocation(prog, 'u_norm_mat')!,
147
+ u_light_dir: gl.getUniformLocation(prog, 'u_light_dir')!,
148
+ u_ambient: gl.getUniformLocation(prog, 'u_ambient')!,
149
+ u_exposure: gl.getUniformLocation(prog, 'u_exposure')!,
150
+ u_tex: gl.getUniformLocation(prog, 'u_tex')!,
151
+ u_use_tex: gl.getUniformLocation(prog, 'u_use_tex')!,
152
+ u_color: gl.getUniformLocation(prog, 'u_color')!,
153
+ u_cam_z: gl.getUniformLocation(prog, 'u_cam_z')!,
154
+ }
155
+ return this._gl_state
156
+ }
157
+
158
+ /** Load new cover SVGs and re-upload geometry / textures.
159
+ * depth_mm: book thickness in mm, used when no spine SVG is present (e.g. coil/wire bindings).
160
+ * Pass 0 for bindings where the cover faces meet (e.g. saddle stitch). */
161
+ async load(svgs:BookFaces, cover_type:CoverType = 'paperback', depth_mm?:number):Promise<void> {
162
+ const {gl} = this._init_gl()
163
+
164
+ // Free previous GPU resources
165
+ if (this._page_tex) {
166
+ gl.deleteTexture(this._page_tex)
167
+ this._page_tex = null
168
+ }
169
+ for (const t of this._textures)
170
+ gl.deleteTexture(t)
171
+ this._textures = []
172
+ for (const f of this._faces) {
173
+ gl.deleteBuffer(f.vbo)
174
+ gl.deleteBuffer(f.ibo)
175
+ }
176
+ this._faces = []
177
+
178
+ // Parse SVG dimensions to derive normalised 3D aspect ratios
179
+ const front_size = parse_svg_size(svgs.front)
180
+ this._cover_height = front_size.height
181
+ this._h = 1.0
182
+ this._w = front_size.width / front_size.height
183
+ const spine_size = svgs.spine ? parse_svg_size(svgs.spine) : null
184
+ // No spine SVG → convert depth_mm to the same pt-ratio as spine_size.width/front_size.height
185
+ // 1pt = 25.4/72 mm, so depth_pt = depth_mm * 72/25.4
186
+ const PT_TO_MM = 25.4 / 72
187
+ this._d = spine_size ? spine_size.width / front_size.height
188
+ : (depth_mm != null ? depth_mm / (front_size.height * PT_TO_MM) : 0)
189
+
190
+ // Recreate page-edge texture scaled to this book's spine thickness
191
+ this._page_tex = create_page_edge_texture(gl, this._d)
192
+
193
+ // Rasterise each SVG face into an ImageBitmap for GPU upload (4x resolution for quality)
194
+ const [front_bmp, back_bmp, spine_bmp] = await Promise.all([
195
+ svg_to_bitmap(svgs.front, front_size.width * 4, front_size.height * 4),
196
+ svg_to_bitmap(svgs.back, front_size.width * 4, front_size.height * 4),
197
+ spine_size
198
+ ? svg_to_bitmap(svgs.spine!, spine_size.width * 4, spine_size.height * 4)
199
+ : Promise.resolve(null),
200
+ ])
201
+
202
+ const front_tex = upload_texture(gl, front_bmp)
203
+ const back_tex = upload_texture(gl, back_bmp)
204
+ const spine_tex = spine_bmp ? upload_texture(gl, spine_bmp) : null
205
+
206
+ front_bmp.close()
207
+ back_bmp.close()
208
+ if (spine_bmp) spine_bmp.close()
209
+
210
+ this._textures = [front_tex, back_tex, ...(spine_tex ? [spine_tex] : [])]
211
+
212
+ // Build face geometry and upload vertex/index buffers
213
+ const face_data:FaceData[] = build_faces(
214
+ this._w, this._h, this._d,
215
+ front_tex, back_tex, spine_tex, cover_type, this._page_tex!,
216
+ )
217
+
218
+ for (const fd of face_data) {
219
+ const vbo = gl.createBuffer()!
220
+ gl.bindBuffer(gl.ARRAY_BUFFER, vbo)
221
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(fd.vertices), gl.STATIC_DRAW)
222
+
223
+ const ibo = gl.createBuffer()!
224
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo)
225
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(fd.indices), gl.STATIC_DRAW)
226
+
227
+ this._faces.push({vbo, ibo, tex: fd.texture, color: fd.color, index_count: fd.indices.length})
228
+ }
229
+ }
230
+
231
+ /** Render the book at the given camera angles (degrees), zoom multiplier, and clockwise roll */
232
+ render(azimuth = DEFAULT_AZIMUTH, elevation = DEFAULT_ELEVATION, zoom = 1.0, roll = 0,
233
+ light_az = DEFAULT_LIGHT_AZ, light_el = DEFAULT_LIGHT_EL, ambient = DEFAULT_AMBIENT,
234
+ exposure = 1.0):void {
235
+ if (!this._gl_state || this._faces.length === 0)
236
+ return
237
+
238
+ const {gl, prog,
239
+ a_pos, a_uv, a_normal,
240
+ u_mvp, u_mv, u_norm_mat, u_light_dir, u_ambient, u_exposure, u_tex, u_use_tex, u_color, u_cam_z} = this._gl_state
241
+
242
+ const w = this.canvas.width
243
+ const h = this.canvas.height
244
+
245
+ gl.viewport(0, 0, w, h)
246
+ gl.clearColor(0, 0, 0, 0)
247
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
248
+ gl.enable(gl.DEPTH_TEST)
249
+ gl.disable(gl.CULL_FACE)
250
+ gl.useProgram(prog)
251
+
252
+ // Camera in spherical coordinates orbiting the origin
253
+ const az = azimuth * Math.PI / 180
254
+ const el = elevation * Math.PI / 180
255
+ const dist = RENDER_DIST_FACTOR * this._h * zoom
256
+ const cam:[number,number,number] = [
257
+ dist * Math.sin(az) * Math.cos(el),
258
+ dist * Math.sin(el),
259
+ dist * Math.cos(az) * Math.cos(el),
260
+ ]
261
+
262
+ // MVP = projection × view × model (model rotates book clockwise by roll degrees)
263
+ const proj = mat4_perspective(RENDER_FOV, w / h, 0.01, 100)
264
+ const view = mat4_look_at(cam, [0, 0, 0])
265
+ const model = roll !== 0 ? mat4_rotate_z(-roll) : mat4_identity()
266
+ const mv = mat4_mul(view, model)
267
+ const mvp = mat4_mul(proj, mv)
268
+ const norm = normal_matrix(mv)
269
+
270
+ gl.uniformMatrix4fv(u_mvp, false, mvp)
271
+ gl.uniformMatrix4fv(u_mv, false, mv)
272
+ gl.uniformMatrix3fv(u_norm_mat, false, norm)
273
+ gl.uniform3fv(u_light_dir, light_dir_vec3(light_az, light_el))
274
+ gl.uniform1f(u_ambient, ambient)
275
+ gl.uniform1f(u_exposure, exposure)
276
+ // The world origin (book centre) maps to z = -dist in view space;
277
+ // u_cam_z anchors the gradient so the face centre is always the midpoint.
278
+ gl.uniform1f(u_cam_z, -dist)
279
+
280
+ // Stride: 8 floats per vertex (xyz + uv + normal)
281
+ const stride = 8 * 4
282
+
283
+ for (const face of this._faces) {
284
+ gl.bindBuffer(gl.ARRAY_BUFFER, face.vbo)
285
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, face.ibo)
286
+
287
+ gl.enableVertexAttribArray(a_pos)
288
+ gl.enableVertexAttribArray(a_uv)
289
+ gl.enableVertexAttribArray(a_normal)
290
+
291
+ gl.vertexAttribPointer(a_pos, 3, gl.FLOAT, false, stride, 0)
292
+ gl.vertexAttribPointer(a_uv, 2, gl.FLOAT, false, stride, 3 * 4)
293
+ gl.vertexAttribPointer(a_normal, 3, gl.FLOAT, false, stride, 5 * 4)
294
+
295
+ if (face.tex) {
296
+ gl.activeTexture(gl.TEXTURE0)
297
+ gl.bindTexture(gl.TEXTURE_2D, face.tex)
298
+ gl.uniform1i(u_tex, 0)
299
+ gl.uniform1i(u_use_tex, 1)
300
+ }
301
+ else {
302
+ gl.uniform1i(u_use_tex, 0)
303
+ gl.uniform3fv(u_color, face.color)
304
+ }
305
+
306
+ gl.drawElements(gl.TRIANGLES, face.index_count, gl.UNSIGNED_SHORT, 0)
307
+ }
308
+ }
309
+
310
+ /** Return the current frame as an ImageBitmap */
311
+ async snapshot():Promise<ImageBitmap> {
312
+ return createImageBitmap(this.canvas)
313
+ }
314
+
315
+ /** Render and return the current frame as PNG bytes */
316
+ async to_png():Promise<Uint8Array> {
317
+ let blob:Blob
318
+ if (this.canvas instanceof OffscreenCanvas) {
319
+ blob = await this.canvas.convertToBlob({type: 'image/png'})
320
+ } else {
321
+ blob = await new Promise<Blob>((resolve, reject) => {
322
+ (this.canvas as HTMLCanvasElement).toBlob(
323
+ b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png',
324
+ )
325
+ })
326
+ }
327
+ return new Uint8Array(await blob.arrayBuffer())
328
+ }
329
+
330
+ /** Composite the book onto a background ImageBitmap at a flat-on-table angle.
331
+ * Renders at the given angles (defaulting to a flat overhead perspective),
332
+ * then draws the result centred over the background. */
333
+ async composite_photo(background:ImageBitmap, options:PhotoCompositeOptions = {}):Promise<ImageBitmap> {
334
+ const az = options.azimuth ?? PHOTO_AZIMUTH
335
+ const el = options.elevation ?? PHOTO_ELEVATION
336
+ const zoom = options.zoom ?? 1.0
337
+ const roll = options.roll ?? 0
338
+ const book_scale = options.book_scale ?? PHOTO_BOOK_SCALE
339
+ const offset_x = options.offset_x ?? 0
340
+ const offset_y = options.offset_y ?? 0
341
+ const light_az = options.light_az ?? DEFAULT_LIGHT_AZ
342
+ const light_el = options.light_el ?? DEFAULT_LIGHT_EL
343
+ const ambient = options.ambient ?? DEFAULT_AMBIENT
344
+
345
+ // Render at a fixed square size so the result is independent of the interactive canvas
346
+ const saved_w = this.canvas.width
347
+ const saved_h = this.canvas.height
348
+ const photo_size = 2048
349
+ this.canvas.width = photo_size
350
+ this.canvas.height = photo_size
351
+ this.render(az, el, zoom, roll, light_az, light_el, ambient * 0.7, 1)
352
+ const book_bmp = await this.snapshot()
353
+ this.canvas.width = saved_w
354
+ this.canvas.height = saved_h
355
+
356
+ // Build composite at background's native resolution
357
+ const cw = background.width
358
+ const ch = background.height
359
+ const comp = new OffscreenCanvas(cw, ch)
360
+ const ctx = comp.getContext('2d')!
361
+
362
+ // Draw background, then scale and centre the book render on top
363
+ ctx.drawImage(background, 0, 0)
364
+ // Scale so physical book height maps to a consistent fraction of the background.
365
+ // Divide by book_bmp.height (constant across all aspect ratios) so that landscape
366
+ // books are not made smaller than portrait books of the same physical height.
367
+ // 648pt = 9in, the most common trim height — used as a baseline so composites look
368
+ // correctly sized regardless of the actual book dimensions
369
+ const REFERENCE_HEIGHT = 648
370
+ const scale = cw * book_scale * (this._cover_height / REFERENCE_HEIGHT) / book_bmp.height
371
+ const bw = book_bmp.width * scale
372
+ const bh = book_bmp.height * scale
373
+ // Offsets are fractions of the background dimensions — independent of book size,
374
+ // so the book scales toward its fixed center position rather than the corner
375
+ const bx = (cw - bw) / 2 + offset_x * cw
376
+ const by = (ch - bh) / 2 + offset_y * ch
377
+
378
+ // Derive shadow direction from light properties projected into screen space.
379
+ // Shadow extends opposite to the light's horizontal direction, mapped onto the
380
+ // camera's right and down axes so it matches what the 3D shading already shows.
381
+ const az_rad = az * Math.PI / 180
382
+ const el_rad = el * Math.PI / 180
383
+ const laz_rad = light_az * Math.PI / 180
384
+ const lel_rad = light_el * Math.PI / 180
385
+ // Screen-space X: cross-camera component of the opposite-light direction
386
+ const shadow_sx = -Math.sin(laz_rad - az_rad)
387
+ // Screen-space Y (canvas +Y = down): camera elevation maps depth onto vertical
388
+ const shadow_sy = -Math.sin(el_rad) * Math.cos(laz_rad - az_rad)
389
+ // Shadow opacity: brighter ambient scenes cast softer shadows
390
+ const shadow_alpha = Math.max(0.1, 0.45 - ambient * 0.3)
391
+ // Shadow length: longer for low-angle light, shorter for overhead light
392
+ const shadow_len = bh * 0.09 * Math.max(0.15, Math.cos(lel_rad))
393
+
394
+ // Draw shadow pass: the book masked by a vertical gradient so the shadow is
395
+ // heavy at the base (ground contact) and fades to nothing at the top.
396
+ // This gives the silhouette shape but avoids darkening the air beside the book.
397
+ const smask = new OffscreenCanvas(book_bmp.width, book_bmp.height)
398
+ const sctx = smask.getContext('2d')!
399
+ sctx.drawImage(book_bmp, 0, 0)
400
+ const vgrad = sctx.createLinearGradient(0, 0, 0, book_bmp.height)
401
+ vgrad.addColorStop(0, 'rgba(0,0,0,0)')
402
+ vgrad.addColorStop(1, 'rgba(0,0,0,1)')
403
+ sctx.globalCompositeOperation = 'destination-in'
404
+ sctx.fillStyle = vgrad
405
+ sctx.fillRect(0, 0, book_bmp.width, book_bmp.height)
406
+
407
+ ctx.shadowColor = `rgba(0,0,0,${shadow_alpha.toFixed(2)})`
408
+ ctx.shadowBlur = bh * 0.015 + shadow_len * 0.4
409
+ ctx.shadowOffsetX = shadow_sx * shadow_len
410
+ ctx.shadowOffsetY = shadow_sy * shadow_len
411
+ ctx.filter = 'blur(1.5px)'
412
+ ctx.drawImage(smask, bx, by, bw, bh)
413
+ ctx.filter = 'none'
414
+ ctx.shadowColor = 'transparent'
415
+
416
+ // Draw the real book on top
417
+ ctx.filter = 'blur(1.5px)'
418
+ ctx.drawImage(book_bmp, bx, by, bw, bh)
419
+ ctx.filter = 'none'
420
+
421
+ // Draw sharp version on top, masked to centre only via radial gradient
422
+ const mask = new OffscreenCanvas(cw, ch)
423
+ const mctx = mask.getContext('2d')!
424
+ mctx.drawImage(book_bmp, bx, by, bw, bh)
425
+ const r = Math.min(bw, bh)
426
+ const grad = mctx.createRadialGradient(bx + bw / 2, by + bh / 2, r * 0.25, bx + bw / 2, by + bh / 2, r * 0.6)
427
+ grad.addColorStop(0, 'black')
428
+ grad.addColorStop(1, 'transparent')
429
+ mctx.globalCompositeOperation = 'destination-in'
430
+ mctx.fillStyle = grad
431
+ mctx.fillRect(0, 0, cw, ch)
432
+ ctx.drawImage(mask, 0, 0)
433
+ book_bmp.close()
434
+
435
+ // NOTE Don't remove comment block below and will manually restore when want to test
436
+ // DEBUG: draw light direction line from book centre toward projected light source
437
+ // {
438
+ // const book_cx = bx + bw / 2
439
+ // const book_cy = by + bh / 2
440
+ // // Full 3D projection of light direction onto screen axes
441
+ // const lsx = Math.cos(lel_rad) * Math.sin(laz_rad - az_rad)
442
+ // const lsy = Math.cos(lel_rad) * Math.sin(el_rad) * Math.cos(laz_rad - az_rad)
443
+ // - Math.sin(lel_rad) * Math.cos(el_rad)
444
+ // const line_len = bh * 0.55
445
+ // const end_x = book_cx + lsx * line_len
446
+ // const end_y = book_cy + lsy * line_len
447
+ // ctx.save()
448
+ // ctx.strokeStyle = 'rgba(255,200,0,0.95)'
449
+ // ctx.lineWidth = 3
450
+ // ctx.setLineDash([8, 4])
451
+ // ctx.beginPath()
452
+ // ctx.moveTo(book_cx, book_cy)
453
+ // ctx.lineTo(end_x, end_y)
454
+ // ctx.stroke()
455
+ // ctx.setLineDash([])
456
+ // ctx.fillStyle = 'rgba(255,200,0,0.95)'
457
+ // ctx.beginPath()
458
+ // ctx.arc(end_x, end_y, 10, 0, Math.PI * 2)
459
+ // ctx.fill()
460
+ // ctx.restore()
461
+ // }
462
+
463
+ return createImageBitmap(comp)
464
+ }
465
+
466
+ /** Free all WebGL resources */
467
+ destroy():void {
468
+ if (!this._gl_state)
469
+ return
470
+ const {gl} = this._gl_state
471
+ for (const t of this._textures)
472
+ gl.deleteTexture(t)
473
+ if (this._page_tex)
474
+ gl.deleteTexture(this._page_tex)
475
+ for (const f of this._faces) {
476
+ gl.deleteBuffer(f.vbo)
477
+ gl.deleteBuffer(f.ibo)
478
+ }
479
+ gl.deleteProgram(this._gl_state.prog)
480
+ this._gl_state = null
481
+ this._faces = []
482
+ this._textures = []
483
+ }
484
+ }
485
+
486
+ /**
487
+ * One-shot render: load the SVGs, render at the given angle, return PNG bytes.
488
+ */
489
+ export async function generate(svgs:BookFaces, options:GenerateOptions = {}):Promise<Uint8Array> {
490
+ const renderer = new Book3DRenderer(
491
+ options.width ?? DEFAULT_WIDTH,
492
+ options.height ?? DEFAULT_HEIGHT,
493
+ )
494
+ try {
495
+ await renderer.load(svgs, options.cover_type ?? 'paperback')
496
+ renderer.render(
497
+ options.azimuth ?? DEFAULT_AZIMUTH,
498
+ options.elevation ?? DEFAULT_ELEVATION,
499
+ 1.0,
500
+ options.roll ?? 0,
501
+ )
502
+ return await renderer.to_png()
503
+ }
504
+ finally {
505
+ renderer.destroy()
506
+ }
507
+ }
package/src/math.ts ADDED
@@ -0,0 +1,84 @@
1
+
2
+ // Column-major Mat4 / Mat3 math — no external dependencies
3
+
4
+ export type Mat4 = Float32Array
5
+ export type Mat3 = Float32Array
6
+
7
+ /** Create a 4×4 identity matrix */
8
+ export function mat4_identity():Mat4 {
9
+ const m = new Float32Array(16)
10
+ m[0] = m[5] = m[10] = m[15] = 1
11
+ return m
12
+ }
13
+
14
+ /** Multiply two 4×4 matrices: result = a * b */
15
+ export function mat4_mul(a:Mat4, b:Mat4):Mat4 {
16
+ const out = new Float32Array(16)
17
+ for (let row = 0; row < 4; row++) {
18
+ for (let col = 0; col < 4; col++) {
19
+ let sum = 0
20
+ for (let k = 0; k < 4; k++)
21
+ sum += a[row + k * 4] * b[k + col * 4]
22
+ out[row + col * 4] = sum
23
+ }
24
+ }
25
+ return out
26
+ }
27
+
28
+ /** Perspective projection matrix (column-major, right-hand) */
29
+ export function mat4_perspective(fov_deg:number, aspect:number, near:number, far:number):Mat4 {
30
+ const m = new Float32Array(16)
31
+ const f = 1.0 / Math.tan((fov_deg * Math.PI / 180) / 2)
32
+ m[0] = f / aspect
33
+ m[5] = f
34
+ m[10] = (far + near) / (near - far)
35
+ m[11] = -1
36
+ m[14] = (2 * far * near) / (near - far)
37
+ return m
38
+ }
39
+
40
+ /** LookAt view matrix — up is always +Y */
41
+ export function mat4_look_at(eye:[number,number,number], target:[number,number,number]):Mat4 {
42
+ const up:[number,number,number] = [0, 1, 0]
43
+
44
+ // Forward = normalize(eye - target)
45
+ let fx = eye[0] - target[0], fy = eye[1] - target[1], fz = eye[2] - target[2]
46
+ const fl = Math.sqrt(fx*fx + fy*fy + fz*fz)
47
+ fx /= fl; fy /= fl; fz /= fl
48
+
49
+ // Right = normalize(up × forward)
50
+ let rx = up[1]*fz - up[2]*fy, ry = up[2]*fx - up[0]*fz, rz = up[0]*fy - up[1]*fx
51
+ const rl = Math.sqrt(rx*rx + ry*ry + rz*rz)
52
+ rx /= rl; ry /= rl; rz /= rl
53
+
54
+ // True up = forward × right
55
+ const ux = fy*rz - fz*ry, uy = fz*rx - fx*rz, uz = fx*ry - fy*rx
56
+
57
+ const m = new Float32Array(16)
58
+ m[0] = rx; m[4] = ry; m[8] = rz; m[12] = -(rx*eye[0] + ry*eye[1] + rz*eye[2])
59
+ m[1] = ux; m[5] = uy; m[9] = uz; m[13] = -(ux*eye[0] + uy*eye[1] + uz*eye[2])
60
+ m[2] = fx; m[6] = fy; m[10] = fz; m[14] = -(fx*eye[0] + fy*eye[1] + fz*eye[2])
61
+ m[15] = 1
62
+ return m
63
+ }
64
+
65
+ /** Rotation matrix around the Z-axis (column-major); positive angle = counter-clockwise from +Z */
66
+ export function mat4_rotate_z(angle_deg:number):Mat4 {
67
+ const m = mat4_identity()
68
+ const a = angle_deg * Math.PI / 180
69
+ const c = Math.cos(a)
70
+ const s = Math.sin(a)
71
+ // Column 0: [c, s, 0, 0], Column 1: [-s, c, 0, 0]
72
+ m[0] = c; m[1] = s
73
+ m[4] = -s; m[5] = c
74
+ return m
75
+ }
76
+
77
+ /** Extract the 3×3 normal matrix (upper-left of the view matrix) for rotation-only views */
78
+ export function normal_matrix(mv:Mat4):Mat3 {
79
+ const n = new Float32Array(9)
80
+ n[0] = mv[0]; n[1] = mv[1]; n[2] = mv[2]
81
+ n[3] = mv[4]; n[4] = mv[5]; n[5] = mv[6]
82
+ n[6] = mv[8]; n[7] = mv[9]; n[8] = mv[10]
83
+ return n
84
+ }
package/src/photo.ts ADDED
@@ -0,0 +1,77 @@
1
+
2
+ // Photo composite mode — background image metadata and default camera angles
3
+
4
+ import type {Background} from './types.js'
5
+
6
+ // Default flat-on-table camera angles: moderate elevation to look down at the cover,
7
+ // slight azimuth to show depth without fully revealing the back
8
+ export const PHOTO_AZIMUTH = -15
9
+ export const PHOTO_ELEVATION = 35
10
+
11
+ // Book render width as a fraction of the background image width
12
+ export const PHOTO_BOOK_SCALE = 0.55
13
+
14
+ // Available background photos (images live in src/backgrounds/).
15
+ // Each entry can override azimuth, elevation, and book_scale for a natural fit.
16
+ export const BACKGROUNDS: Background[] = [
17
+ {
18
+ id: 'table_with_book',
19
+ azimuth: 0,
20
+ elevation: -30,
21
+ book_scale: 1,
22
+ offset_x: -0.1,
23
+ offset_y: 0.08,
24
+ roll: -30,
25
+ light_az: 10,
26
+ light_el: 60,
27
+ ambient: 1,
28
+ },
29
+ {
30
+ id: 'wood',
31
+ azimuth: 0,
32
+ elevation: 0,
33
+ book_scale: 0.75,
34
+ offset_x: 0,
35
+ offset_y: 0,
36
+ roll: 0,
37
+ light_az: 20,
38
+ light_el: 10,
39
+ ambient: 1,
40
+ },
41
+ {
42
+ id: 'coffee_table',
43
+ azimuth: 3,
44
+ elevation: -50,
45
+ book_scale: 0.5,
46
+ offset_x: 0,
47
+ offset_y: 0.06,
48
+ roll: -30,
49
+ light_az: 50,
50
+ light_el: 50,
51
+ ambient: 0.85,
52
+ },
53
+ {
54
+ id: 'table_with_laptop',
55
+ azimuth: 0,
56
+ elevation: -65,
57
+ book_scale: 0.65,
58
+ offset_x: 0.15,
59
+ offset_y: 0.13,
60
+ roll: 25,
61
+ light_az: -30,
62
+ light_el: 40,
63
+ ambient: 0.8,
64
+ },
65
+ {
66
+ id: 'table_side',
67
+ azimuth: -30,
68
+ elevation: 5,
69
+ book_scale: 0.55,
70
+ offset_x: -0.025,
71
+ offset_y: 0.06,
72
+ roll: 0,
73
+ light_az: 20,
74
+ light_el: 20,
75
+ ambient: 1,
76
+ },
77
+ ]