rayzee 6.2.0 → 6.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.
@@ -0,0 +1,578 @@
1
+ /**
2
+ * Convert a parsed pbrt IR into a THREE scene graph the engine can ingest.
3
+ *
4
+ * Output: { group, camera, environment, warnings }
5
+ * - group: THREE.Group of meshes (fed to PathTracerApp.loadObject3D)
6
+ * - camera: PerspectiveCamera matching the pbrt Camera/LookAt, parented
7
+ * into the group so AssetLoader.extractCamerasFromModel finds it
8
+ * - environment: { texture } | null — set by the caller as scene.environment
9
+ *
10
+ * Handedness: pbrt scenes import correctly as-is. A `diag(1,1,-1)` mirror is
11
+ * available behind `convertHandedness` (default OFF) — three's `lookAt` builds
12
+ * a correct camera basis regardless of source handedness, so no mirror is
13
+ * needed. Enable only if a scene comes out z-mirrored against a known reference.
14
+ */
15
+
16
+ import {
17
+ Group, Mesh, PerspectiveCamera, Matrix4, Vector3,
18
+ BufferGeometry, Float32BufferAttribute, SphereGeometry,
19
+ DataTexture, FloatType, RGBAFormat, LinearFilter, EquirectangularReflectionMapping
20
+ } from 'three';
21
+ import { buildMaterial, pFloat, pString, resolveSpectrum } from './PBRTMaterials.js';
22
+ import * as M from './PBRTMath.js';
23
+
24
+ const FLIP_Z = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, - 1, 0, 0, 0, 0, 1 ];
25
+
26
+ export class PBRTSceneBuilder {
27
+
28
+ /**
29
+ * @param {object} resolvers
30
+ * @param {(filename:string)=>Promise<BufferGeometry>} [resolvers.resolvePLY]
31
+ * @param {(filename:string)=>Promise<import('three').Texture>} [resolvers.resolveImage]
32
+ * @param {(filename:string)=>Promise<import('three').Texture>} [resolvers.resolveEnvironment]
33
+ * @param {boolean} [resolvers.convertHandedness=false]
34
+ */
35
+ constructor( resolvers = {} ) {
36
+
37
+ this.resolvePLY = resolvers.resolvePLY || ( async () => null );
38
+ this.resolveImage = resolvers.resolveImage || ( async () => null );
39
+ this.resolveEnvironment = resolvers.resolveEnvironment || resolvers.resolveImage || ( async () => null );
40
+ this.convertHandedness = resolvers.convertHandedness === true;
41
+
42
+ this.warnings = [];
43
+ this.report = []; // per-mesh diagnostics for debugging imports
44
+ this._materialCache = new Map(); // material obj -> Map(areaLight obj -> MeshPhysicalMaterial)
45
+ this._textureCache = new Map(); // texName -> { texture } | { constant }
46
+
47
+ }
48
+
49
+ warn( msg ) {
50
+
51
+ this.warnings.push( msg );
52
+
53
+ }
54
+
55
+ /**
56
+ * @param {object} ir - output of PBRTParser
57
+ * @returns {Promise<{group:Group, camera:PerspectiveCamera|null, environment:object|null, warnings:string[]}>}
58
+ */
59
+ async build( ir ) {
60
+
61
+ this.ir = ir;
62
+ const group = new Group();
63
+ group.name = 'PBRTScene';
64
+
65
+ // Shapes (direct + instanced)
66
+ for ( let i = 0; i < ir.shapes.length; i ++ ) {
67
+
68
+ const mesh = await this._buildShapeMesh( ir.shapes[ i ], ir.shapes[ i ].ctm, `shape_${i}` );
69
+ if ( mesh ) group.add( mesh );
70
+
71
+ }
72
+
73
+ await this._buildInstances( ir, group );
74
+
75
+ // Camera
76
+ let camera = null;
77
+ if ( ir.camera ) {
78
+
79
+ camera = this._buildCamera( ir.camera, ir.film );
80
+ if ( camera ) group.add( camera );
81
+
82
+ }
83
+
84
+ // Infinite light → environment
85
+ const environment = await this._buildEnvironment( ir.lights );
86
+
87
+ this._reportUnsupportedLights( ir.lights );
88
+
89
+ return {
90
+ group, camera, environment,
91
+ report: this.report,
92
+ warnings: this.warnings.concat( ir.warnings || [] )
93
+ };
94
+
95
+ }
96
+
97
+ // ── shapes ─────────────────────────────────────────────────────
98
+
99
+ async _buildInstances( ir, group ) {
100
+
101
+ let n = 0;
102
+ for ( const inst of ir.instances ) {
103
+
104
+ const template = ir.objects.get( inst.name );
105
+ if ( ! template ) {
106
+
107
+ this.warn( `ObjectInstance "${inst.name}" has no template` ); continue;
108
+
109
+ }
110
+
111
+ for ( const shape of template ) {
112
+
113
+ // instance placement: instanceCTM * (shape relative to its ObjectBegin frame)
114
+ const worldCTM = M.multiply( inst.ctm, shape.relativeCTM || shape.ctm );
115
+ const mesh = await this._buildShapeMesh( shape, worldCTM, `instance_${n ++}` );
116
+ if ( mesh ) group.add( mesh );
117
+
118
+ }
119
+
120
+ }
121
+
122
+ }
123
+
124
+ async _buildShapeMesh( shape, ctm, name ) {
125
+
126
+ // Geometry parse and material/texture resolution are independent — overlap them.
127
+ const [ geometry, sharedMaterial ] = await Promise.all( [
128
+ this._buildGeometry( shape ),
129
+ this._getMaterial( shape )
130
+ ] );
131
+ if ( ! geometry ) return null;
132
+
133
+ // A textured material on geometry with no UVs samples a single texel — the
134
+ // usual cause of "black"/wrong meshes on import. Drop the map, but on a CLONE:
135
+ // _getMaterial caches and shares one instance across every shape using the
136
+ // same NamedMaterial, so mutating it would strip the texture from sibling
137
+ // meshes that DO have UVs.
138
+ const hasUV = !! geometry.getAttribute( 'uv' );
139
+ let material = sharedMaterial;
140
+ if ( sharedMaterial.map && ! hasUV ) {
141
+
142
+ this.warn( `${name} (${shape.type}, "${shape.material?.type || 'diffuse'}") has a texture map but no UVs — dropping map, using base color` );
143
+ material = sharedMaterial.clone();
144
+ material.map = null;
145
+
146
+ }
147
+
148
+ const mesh = new Mesh( geometry, material );
149
+ mesh.name = name;
150
+
151
+ // Apply the world transform via TRS, NOT a direct mesh.matrix assignment:
152
+ // GeometryExtractor calls mesh.updateMatrix(), which recomposes the matrix
153
+ // from position/quaternion/scale. A directly-set matrix gets overwritten
154
+ // with identity there — silently dropping every per-shape Transform.
155
+ // decompose() round-trips the handedness mirror (det<0) via a negative scale axis.
156
+ const world = this.convertHandedness ? M.multiply( FLIP_Z, ctm ) : ctm;
157
+ new Matrix4().fromArray( world ).decompose( mesh.position, mesh.quaternion, mesh.scale );
158
+ mesh.updateMatrix();
159
+
160
+ // World-space dimensions (object bbox transformed by the baked matrix) —
161
+ // surfaces an oversized/under-scaled mesh at a glance.
162
+ geometry.computeBoundingBox();
163
+ const worldSize = geometry.boundingBox
164
+ ? geometry.boundingBox.clone().applyMatrix4( mesh.matrix ).getSize( new Vector3() )
165
+ : new Vector3();
166
+
167
+ this.report.push( {
168
+ mesh: name,
169
+ shape: shape.type,
170
+ material: shape.material?.type || 'diffuse',
171
+ color: '#' + material.color.getHexString(),
172
+ map: material.map ? 'yes' : '-',
173
+ uv: hasUV ? 'yes' : 'NO',
174
+ normals: geometry.getAttribute( 'normal' ) ? 'yes' : 'NO',
175
+ emissive: material.emissiveIntensity > 0 ? `#${material.emissive.getHexString()}×${material.emissiveIntensity}` : '-',
176
+ size: `${worldSize.x.toFixed( 2 )}×${worldSize.y.toFixed( 2 )}×${worldSize.z.toFixed( 2 )}`,
177
+ tris: geometry.index ? geometry.index.count / 3 : geometry.getAttribute( 'position' ).count / 3
178
+ } );
179
+
180
+ return mesh;
181
+
182
+ }
183
+
184
+ async _buildGeometry( shape ) {
185
+
186
+ switch ( shape.type ) {
187
+
188
+ case 'trianglemesh': return this._triangleMesh( shape.params );
189
+ case 'bilinearmesh': return this._bilinearMesh( shape.params );
190
+ case 'plymesh': return this._plyMesh( shape.params );
191
+ case 'sphere': return this._sphere( shape.params );
192
+ case 'disk': return this._disk( shape.params );
193
+ default:
194
+ this.warn( `shape "${shape.type}" not supported — skipped` );
195
+ return null;
196
+
197
+ }
198
+
199
+ }
200
+
201
+ _triangleMesh( params ) {
202
+
203
+ const P = params.P?.value;
204
+ if ( ! P || P.length < 9 ) {
205
+
206
+ this.warn( 'trianglemesh missing P' ); return null;
207
+
208
+ }
209
+
210
+ const geo = new BufferGeometry();
211
+ geo.setAttribute( 'position', new Float32BufferAttribute( Float32Array.from( P ), 3 ) );
212
+
213
+ const N = params.N?.value;
214
+ if ( N && N.length === P.length ) geo.setAttribute( 'normal', new Float32BufferAttribute( Float32Array.from( N ), 3 ) );
215
+
216
+ const uv = ( params.uv || params.st )?.value;
217
+ if ( uv && uv.length === ( P.length / 3 ) * 2 ) geo.setAttribute( 'uv', new Float32BufferAttribute( Float32Array.from( uv ), 2 ) );
218
+
219
+ const indices = params.indices?.value;
220
+ if ( indices && indices.length ) geo.setIndex( indices );
221
+
222
+ if ( ! N ) geo.computeVertexNormals();
223
+ return geo;
224
+
225
+ }
226
+
227
+ // Bilinear patch mesh → triangulate each quad (P + indices in quads of 4).
228
+ _bilinearMesh( params ) {
229
+
230
+ const P = params.P?.value;
231
+ const quad = params.indices?.value;
232
+ if ( ! P || ! quad ) {
233
+
234
+ this.warn( 'bilinearmesh missing P/indices' ); return null;
235
+
236
+ }
237
+
238
+ const tris = [];
239
+ for ( let i = 0; i + 3 < quad.length; i += 4 ) {
240
+
241
+ const [ a, b, c, d ] = [ quad[ i ], quad[ i + 1 ], quad[ i + 2 ], quad[ i + 3 ] ];
242
+ tris.push( a, b, c, a, c, d );
243
+
244
+ }
245
+
246
+ const geo = new BufferGeometry();
247
+ geo.setAttribute( 'position', new Float32BufferAttribute( Float32Array.from( P ), 3 ) );
248
+ geo.setIndex( tris );
249
+ geo.computeVertexNormals();
250
+ return geo;
251
+
252
+ }
253
+
254
+ async _plyMesh( params ) {
255
+
256
+ const filename = pString( params, 'filename', null );
257
+ if ( ! filename ) {
258
+
259
+ this.warn( 'plymesh missing filename' ); return null;
260
+
261
+ }
262
+
263
+ try {
264
+
265
+ const geo = await this.resolvePLY( filename );
266
+ if ( ! geo ) {
267
+
268
+ this.warn( `plymesh file not found: ${filename}` ); return null;
269
+
270
+ }
271
+
272
+ if ( ! geo.getAttribute( 'normal' ) ) geo.computeVertexNormals();
273
+ return geo;
274
+
275
+ } catch ( e ) {
276
+
277
+ this.warn( `failed to load plymesh ${filename}: ${e.message}` );
278
+ return null;
279
+
280
+ }
281
+
282
+ }
283
+
284
+ _sphere( params ) {
285
+
286
+ const radius = pFloat( params, 'radius', 1 );
287
+ return new SphereGeometry( radius, 48, 32 );
288
+
289
+ }
290
+
291
+ _disk( params ) {
292
+
293
+ // Approximate as a thin ring/disk in the z=height plane.
294
+ const radius = pFloat( params, 'radius', 1 );
295
+ const inner = pFloat( params, 'innerradius', 0 );
296
+ const h = pFloat( params, 'height', 0 );
297
+ const seg = 48;
298
+ const pos = [];
299
+ const idx = [];
300
+ for ( let i = 0; i < seg; i ++ ) {
301
+
302
+ const a0 = ( i / seg ) * Math.PI * 2;
303
+ const a1 = ( ( i + 1 ) / seg ) * Math.PI * 2;
304
+ const base = pos.length / 3;
305
+ pos.push( Math.cos( a0 ) * inner, Math.sin( a0 ) * inner, h );
306
+ pos.push( Math.cos( a0 ) * radius, Math.sin( a0 ) * radius, h );
307
+ pos.push( Math.cos( a1 ) * radius, Math.sin( a1 ) * radius, h );
308
+ pos.push( Math.cos( a1 ) * inner, Math.sin( a1 ) * inner, h );
309
+ idx.push( base, base + 1, base + 2, base, base + 2, base + 3 );
310
+
311
+ }
312
+
313
+ const geo = new BufferGeometry();
314
+ geo.setAttribute( 'position', new Float32BufferAttribute( Float32Array.from( pos ), 3 ) );
315
+ geo.setIndex( idx );
316
+ geo.computeVertexNormals();
317
+ return geo;
318
+
319
+ }
320
+
321
+ // ── materials (with area-light emission) ───────────────────────
322
+
323
+ async _getMaterial( shape ) {
324
+
325
+ // Cache by (material, areaLight) object identity — many shapes share a
326
+ // NamedMaterial, so this dedupes the build + texture-decode work. Nested
327
+ // Map keys on the object refs directly (null is a valid key).
328
+ let byLight = this._materialCache.get( shape.material );
329
+ if ( ! byLight ) {
330
+
331
+ byLight = new Map();
332
+ this._materialCache.set( shape.material, byLight );
333
+
334
+ }
335
+
336
+ if ( byLight.has( shape.areaLight ) ) return byLight.get( shape.areaLight );
337
+
338
+ const ctx = {
339
+ resolveNamedTexture: ( n ) => this._resolveNamedTexture( n ),
340
+ namedMaterials: this.ir.namedMaterials,
341
+ warn: ( m ) => this.warn( m )
342
+ };
343
+ const material = await buildMaterial( shape.material, ctx );
344
+
345
+ if ( shape.areaLight ) await this._applyAreaLight( material, shape.areaLight, ctx );
346
+
347
+ byLight.set( shape.areaLight, material );
348
+ return material;
349
+
350
+ }
351
+
352
+ async _applyAreaLight( material, areaLight, ctx ) {
353
+
354
+ const L = await resolveSpectrum( areaLight.params, 'L', ctx, [ 1, 1, 1 ] );
355
+ const scale = pFloat( areaLight.params, 'scale', 1 );
356
+ const rgb = L.rgb || [ 1, 1, 1 ];
357
+ material.emissive.setRGB( rgb[ 0 ], rgb[ 1 ], rgb[ 2 ] );
358
+ material.emissiveIntensity = scale;
359
+
360
+ }
361
+
362
+ async _resolveNamedTexture( name ) {
363
+
364
+ if ( this._textureCache.has( name ) ) return this._textureCache.get( name );
365
+
366
+ const def = this.ir.namedTextures.get( name );
367
+ let result = null;
368
+
369
+ if ( ! def ) {
370
+
371
+ this.warn( `named texture "${name}" not defined` );
372
+
373
+ } else if ( def.class === 'imagemap' ) {
374
+
375
+ const filename = pString( def.params, 'filename', null );
376
+ if ( filename ) {
377
+
378
+ try {
379
+
380
+ const tex = await this.resolveImage( filename );
381
+ if ( tex ) result = { texture: tex };
382
+ else this.warn( `image not found for texture "${name}": ${filename}` );
383
+
384
+ } catch ( e ) {
385
+
386
+ this.warn( `failed to load texture "${name}" (${filename}): ${e.message}` );
387
+
388
+ }
389
+
390
+ }
391
+
392
+ } else if ( def.class === 'constant' ) {
393
+
394
+ const v = def.params.value;
395
+ if ( v && v.type === 'rgb' ) result = { constant: [ v.value[ 0 ], v.value[ 1 ], v.value[ 2 ] ] };
396
+ else if ( v ) result = { constant: [ v.value[ 0 ], v.value[ 0 ], v.value[ 0 ] ] };
397
+
398
+ } else if ( def.class === 'scale' ) {
399
+
400
+ // Scale = inner_texture * scale_factor. Resolve the inner (recursively if
401
+ // it's a named ref) and the scale factor (rgb/float/spectrum), then propagate
402
+ // both: the inner texture passes through as the `map`, and the scale becomes
403
+ // the material's color tint (three.js multiplies map.rgb × color.rgb).
404
+ result = await this._resolveScaleTexture( name, def );
405
+
406
+ } else {
407
+
408
+ this.warn( `texture class "${def.class}" not supported (texture "${name}")` );
409
+
410
+ }
411
+
412
+ this._textureCache.set( name, result );
413
+ return result;
414
+
415
+ }
416
+
417
+ async _resolveScaleTexture( name, def ) {
418
+
419
+ // pbrt-v4 uses "tex" (the inner texture or constant) and "scale" (the multiplier).
420
+ const innerP = def.params.tex;
421
+ let inner = null;
422
+
423
+ if ( innerP?.type === 'texture' && typeof innerP.value[ 0 ] === 'string' ) {
424
+
425
+ inner = await this._resolveNamedTexture( innerP.value[ 0 ] );
426
+
427
+ } else if ( innerP?.type === 'rgb' || innerP?.type === 'color' ) {
428
+
429
+ inner = { constant: [ innerP.value[ 0 ], innerP.value[ 1 ], innerP.value[ 2 ] ] };
430
+
431
+ } else if ( innerP?.type === 'float' ) {
432
+
433
+ const v = innerP.value[ 0 ];
434
+ inner = { constant: [ v, v, v ] };
435
+
436
+ }
437
+
438
+ const sP = def.params.scale;
439
+ let scale = [ 1, 1, 1 ];
440
+ if ( sP?.type === 'rgb' || sP?.type === 'color' ) scale = [ sP.value[ 0 ], sP.value[ 1 ], sP.value[ 2 ] ];
441
+ else if ( sP?.type === 'float' ) {
442
+
443
+ const v = sP.value[ 0 ]; scale = [ v, v, v ];
444
+
445
+ }
446
+
447
+ if ( inner?.texture ) {
448
+
449
+ const c = inner.constant || [ 1, 1, 1 ];
450
+ return {
451
+ texture: inner.texture,
452
+ constant: [ c[ 0 ] * scale[ 0 ], c[ 1 ] * scale[ 1 ], c[ 2 ] * scale[ 2 ] ]
453
+ };
454
+
455
+ }
456
+
457
+ const c = inner?.constant || [ 1, 1, 1 ];
458
+ return { constant: [ c[ 0 ] * scale[ 0 ], c[ 1 ] * scale[ 1 ], c[ 2 ] * scale[ 2 ] ] };
459
+
460
+ }
461
+
462
+ // ── camera ─────────────────────────────────────────────────────
463
+
464
+ _buildCamera( cam, film ) {
465
+
466
+ const m = new Matrix4().fromArray( cam.cameraToWorld );
467
+ const e = m.elements;
468
+
469
+ // Columns of cameraToWorld: right(0), up(1), dir(2), eye(3).
470
+ let eye = new Vector3( e[ 12 ], e[ 13 ], e[ 14 ] );
471
+ let dir = new Vector3( e[ 8 ], e[ 9 ], e[ 10 ] );
472
+ let up = new Vector3( e[ 4 ], e[ 5 ], e[ 6 ] );
473
+
474
+ if ( this.convertHandedness ) {
475
+
476
+ eye.z *= - 1; dir.z *= - 1; up.z *= - 1;
477
+
478
+ }
479
+
480
+ const target = eye.clone().add( dir );
481
+
482
+ const aspect = film && film.yresolution ? film.xresolution / film.yresolution : 16 / 9;
483
+ const fov = this._verticalFov( cam.params, aspect );
484
+
485
+ const camera = new PerspectiveCamera( fov, aspect, 0.01, 10000 );
486
+ camera.name = 'PBRT Camera';
487
+ camera.up.copy( up.normalize() );
488
+ camera.position.copy( eye );
489
+ camera.lookAt( target );
490
+ camera.updateMatrixWorld( true );
491
+ return camera;
492
+
493
+ }
494
+
495
+ // pbrt `fov` is the angle along the SHORTER image axis. THREE uses vertical fov.
496
+ _verticalFov( params, aspect ) {
497
+
498
+ const pbrtFov = pFloat( params, 'fov', 90 );
499
+ if ( aspect >= 1 ) return pbrtFov; // landscape: shorter axis is vertical
500
+ // portrait: pbrt fov is horizontal → convert to vertical
501
+ const h = pbrtFov * Math.PI / 180;
502
+ const v = 2 * Math.atan( Math.tan( h / 2 ) / aspect );
503
+ return v * 180 / Math.PI;
504
+
505
+ }
506
+
507
+ // ── lights / environment ───────────────────────────────────────
508
+
509
+ async _buildEnvironment( lights ) {
510
+
511
+ const inf = lights.find( l => l.type === 'infinite' );
512
+ if ( ! inf ) return null;
513
+
514
+ const scale = pFloat( inf.params, 'scale', 1 );
515
+ const filename = pString( inf.params, 'filename', null );
516
+
517
+ if ( filename ) {
518
+
519
+ try {
520
+
521
+ const tex = await this.resolveEnvironment( filename );
522
+ if ( tex ) {
523
+
524
+ tex.mapping = EquirectangularReflectionMapping;
525
+ return { texture: tex };
526
+
527
+ }
528
+
529
+ this.warn( `infinite-light image not found: ${filename}` );
530
+
531
+ } catch ( e ) {
532
+
533
+ this.warn( `failed to load infinite-light image ${filename}: ${e.message}` );
534
+
535
+ }
536
+
537
+ }
538
+
539
+ // Constant-radiance infinite light → tiny float texture (CDF-buildable).
540
+ const ctx = { resolveNamedTexture: async () => null, warn: ( m ) => this.warn( m ) };
541
+ const L = await resolveSpectrum( inf.params, 'L', ctx, [ 1, 1, 1 ] );
542
+ const rgb = ( L.rgb || [ 1, 1, 1 ] ).map( v => v * scale );
543
+
544
+ const w = 2, h = 1;
545
+ const data = new Float32Array( w * h * 4 );
546
+ for ( let i = 0; i < w * h; i ++ ) {
547
+
548
+ data[ i * 4 + 0 ] = rgb[ 0 ];
549
+ data[ i * 4 + 1 ] = rgb[ 1 ];
550
+ data[ i * 4 + 2 ] = rgb[ 2 ];
551
+ data[ i * 4 + 3 ] = 1;
552
+
553
+ }
554
+
555
+ const tex = new DataTexture( data, w, h, RGBAFormat, FloatType );
556
+ tex.mapping = EquirectangularReflectionMapping;
557
+ tex.minFilter = LinearFilter;
558
+ tex.magFilter = LinearFilter;
559
+ tex.needsUpdate = true;
560
+ return { texture: tex };
561
+
562
+ }
563
+
564
+ _reportUnsupportedLights( lights ) {
565
+
566
+ for ( const l of lights ) {
567
+
568
+ if ( l.type !== 'infinite' ) {
569
+
570
+ this.warn( `light "${l.type}" not supported (only infinite lights and emissive area lights are mapped)` );
571
+
572
+ }
573
+
574
+ }
575
+
576
+ }
577
+
578
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Tokenizer for the pbrt-v4 scene description grammar.
3
+ *
4
+ * pbrt files are a flat stream of directives. Lexically there are only four
5
+ * things to recognize:
6
+ * - quoted strings: "perspective", "float fov"
7
+ * - numbers: 1, -2.5, 1e-3, .5
8
+ * - brackets: [ ] (array delimiters)
9
+ * - bare words: WorldBegin, Shape, true, false (directives / bools)
10
+ * Comments run from '#' to end of line.
11
+ *
12
+ * Pure JS, no Three.js — keeps it unit-testable in node.
13
+ */
14
+
15
+ export const TokenType = {
16
+ STRING: 'string', // quoted string, quotes stripped
17
+ NUMBER: 'number', // numeric literal, already parsed to Number
18
+ WORD: 'word', // bare identifier (directive name, true/false)
19
+ LBRACKET: '[',
20
+ RBRACKET: ']'
21
+ };
22
+
23
+ const isWhitespace = c => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
24
+ // A number can start with: digit / ".5" / "-1" / "-.5" / "+.5".
25
+ const isDigit = c => c >= '0' && c <= '9';
26
+ const isNumberStart = ( c, next, next2 ) => {
27
+
28
+ if ( isDigit( c ) ) return true;
29
+ if ( c === '.' && isDigit( next ) ) return true;
30
+ if ( c === '-' || c === '+' ) {
31
+
32
+ if ( isDigit( next ) ) return true;
33
+ if ( next === '.' && isDigit( next2 ) ) return true;
34
+
35
+ }
36
+
37
+ return false;
38
+
39
+ };
40
+
41
+ /**
42
+ * Tokenize a pbrt source string.
43
+ * @param {string} src
44
+ * @returns {Array<{type: string, value?: string|number}>}
45
+ */
46
+ export function tokenize( src ) {
47
+
48
+ const tokens = [];
49
+ const n = src.length;
50
+ let i = 0;
51
+
52
+ while ( i < n ) {
53
+
54
+ const c = src[ i ];
55
+
56
+ // Whitespace
57
+ if ( isWhitespace( c ) ) {
58
+
59
+ i ++;
60
+ continue;
61
+
62
+ }
63
+
64
+ // Comment to end of line
65
+ if ( c === '#' ) {
66
+
67
+ while ( i < n && src[ i ] !== '\n' ) i ++;
68
+ continue;
69
+
70
+ }
71
+
72
+ // Brackets
73
+ if ( c === '[' ) {
74
+
75
+ tokens.push( { type: TokenType.LBRACKET } );
76
+ i ++;
77
+ continue;
78
+
79
+ }
80
+
81
+ if ( c === ']' ) {
82
+
83
+ tokens.push( { type: TokenType.RBRACKET } );
84
+ i ++;
85
+ continue;
86
+
87
+ }
88
+
89
+ // Quoted string
90
+ if ( c === '"' ) {
91
+
92
+ i ++; // skip opening quote
93
+ let start = i;
94
+ while ( i < n && src[ i ] !== '"' ) i ++;
95
+ if ( i >= n ) throw new Error( 'PBRT tokenizer: unterminated string literal' );
96
+ tokens.push( { type: TokenType.STRING, value: src.slice( start, i ) } );
97
+ i ++; // skip closing quote
98
+ continue;
99
+
100
+ }
101
+
102
+ // Number
103
+ if ( isNumberStart( c, src[ i + 1 ], src[ i + 2 ] ) ) {
104
+
105
+ let start = i;
106
+ i ++;
107
+ while ( i < n ) {
108
+
109
+ const ch = src[ i ];
110
+ if ( ( ch >= '0' && ch <= '9' ) || ch === '.' || ch === 'e' || ch === 'E' ||
111
+ ch === '-' || ch === '+' ) {
112
+
113
+ i ++;
114
+
115
+ } else break;
116
+
117
+ }
118
+
119
+ const text = src.slice( start, i );
120
+ const num = Number( text );
121
+ if ( Number.isNaN( num ) ) throw new Error( `PBRT tokenizer: invalid number "${text}"` );
122
+ tokens.push( { type: TokenType.NUMBER, value: num } );
123
+ continue;
124
+
125
+ }
126
+
127
+ // Bare word (directive, true/false, etc.)
128
+ {
129
+
130
+ let start = i;
131
+ while ( i < n && ! isWhitespace( src[ i ] ) && src[ i ] !== '"' &&
132
+ src[ i ] !== '[' && src[ i ] !== ']' && src[ i ] !== '#' ) i ++;
133
+ tokens.push( { type: TokenType.WORD, value: src.slice( start, i ) } );
134
+
135
+ }
136
+
137
+ }
138
+
139
+ return tokens;
140
+
141
+ }