bookcover-3d-web 0.3.0 → 0.4.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/README.md +8 -5
- package/dist/photo.d.ts.map +1 -1
- package/dist/photo.js +3 -2
- package/dist/photo.js.map +1 -1
- package/package.json +4 -1
- package/README_mockup.webp +0 -0
- package/README_sample.webp +0 -0
- package/src/backgrounds/coffee_table.jpg +0 -0
- package/src/backgrounds/table_side.jpg +0 -0
- package/src/backgrounds/table_with_book.jpg +0 -0
- package/src/backgrounds/table_with_laptop.jpg +0 -0
- package/src/backgrounds/wood.jpg +0 -0
- package/src/backgrounds_originals/coffee_table.jpg +0 -0
- package/src/backgrounds_originals/table_side.jpg +0 -0
- package/src/backgrounds_originals/table_with_book.jpg +0 -0
- package/src/backgrounds_originals/table_with_laptop.jpg +0 -0
- package/src/backgrounds_originals/wood.jpg +0 -0
- package/src/geometry.ts +0 -513
- package/src/index.ts +0 -507
- package/src/math.ts +0 -84
- package/src/photo.ts +0 -77
- package/src/svg.ts +0 -46
- package/src/types.ts +0 -49
- package/src/webgl.ts +0 -131
- package/tsconfig.json +0 -19
- package/tsconfig.tsbuildinfo +0 -1
package/src/index.ts
DELETED
|
@@ -1,507 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
]
|