rayzee 7.30.2 → 7.31.1

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.
@@ -404,6 +404,7 @@ export class PathTracerApp extends EventDispatcher {
404
404
  if ( ! this.stages.pathTracer.isComplete ) {
405
405
 
406
406
  this.completion.updateTime();
407
+ this.denoisingManager?.tickContinuousDenoise( this.stages.pathTracer.frameCount );
407
408
 
408
409
  }
409
410
 
@@ -1096,7 +1097,14 @@ export class PathTracerApp extends EventDispatcher {
1096
1097
  timer.start( 'Environment CDF build (worker)' );
1097
1098
  this.stages.pathTracer.scene.environment = environmentTexture;
1098
1099
  cdfPromise = this.stages.pathTracer.environment.buildEnvironmentCDF()
1099
- .then( () => timer.end( 'Environment CDF build (worker)' ) );
1100
+ .then( () => {
1101
+
1102
+ timer.end( 'Environment CDF build (worker)' );
1103
+ // A loader can install the environment itself and still author how it should
1104
+ // be read — a pbrt scene bakes the light's transform in and needs rotation 0.
1105
+ if ( this._applySceneMetadataEnabled ) this._applySceneMetadataSettings( environmentTexture );
1106
+
1107
+ } );
1100
1108
 
1101
1109
  }
1102
1110
 
@@ -1797,8 +1805,11 @@ export class PathTracerApp extends EventDispatcher {
1797
1805
  if ( denoiser ) {
1798
1806
 
1799
1807
  denoiser.abort();
1800
- denoiser.enabled = config.enableOIDN;
1801
- denoiser.updateQuality( config.oidnQuality );
1808
+ // Through the manager both times: `denoiser.enabled` is the union of its two jobs and
1809
+ // `denoiser.quality` dips to a cheaper model between refreshes, so neither is the
1810
+ // record of what the host asked for.
1811
+ this.denoisingManager.applyOIDNEnabled( config.enableOIDN );
1812
+ this.denoisingManager.applyOIDNQuality( config.oidnQuality );
1802
1813
 
1803
1814
  }
1804
1815
 
@@ -1933,6 +1944,7 @@ export class PathTracerApp extends EventDispatcher {
1933
1944
  dynamicDispatch: stage._useDynamicDispatch,
1934
1945
  autoFocusMode: this.cameraManager?.autoFocusMode,
1935
1946
  autoExposure: this.stages.autoExposure?.enabled,
1947
+ continuousDenoise: this.denoisingManager?.continuousDenoise,
1936
1948
  };
1937
1949
 
1938
1950
  }
@@ -1966,6 +1978,8 @@ export class PathTracerApp extends EventDispatcher {
1966
1978
 
1967
1979
  this.cameraManager?.setAutoFocusMode( 'manual' );
1968
1980
  if ( this.stages.autoExposure ) this.stages.autoExposure.enabled = false;
1981
+ // Cadence denoising is wall-clock driven, so which frame it lands on is not reproducible.
1982
+ this.denoisingManager?.setContinuousDenoise( false );
1969
1983
 
1970
1984
  // The seed axis free-runs across accumulation resets so a camera drag gets fresh
1971
1985
  // sequences; offline rendering needs the opposite. Pinning it makes seedFrame track
@@ -1990,6 +2004,12 @@ export class PathTracerApp extends EventDispatcher {
1990
2004
 
1991
2005
  }
1992
2006
 
2007
+ if ( prev.continuousDenoise !== undefined ) {
2008
+
2009
+ this.denoisingManager?.setContinuousDenoise( prev.continuousDenoise );
2010
+
2011
+ }
2012
+
1993
2013
  stage._pinSeedToFrame = false;
1994
2014
 
1995
2015
  this._deterministicRestore = null;
@@ -14,9 +14,9 @@ import { unzipSync, zipSync, strFromU8 } from 'three/addons/libs/fflate.module.j
14
14
  import { disposeEngineOwnedResources, disposeObjectFromMemory, updateLoading } from './utils';
15
15
  import { BuildTimer } from './BuildTimer.js';
16
16
  import { getAssetConfig } from '../AssetConfig.js';
17
- import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
17
+ import { loadPBRTScene, pickEntryPath, listEntryPaths } from './PBRT/index.js';
18
18
  import { extractSceneMetadata } from './SceneMetadata.js';
19
- import { ISSUE_CODES } from '../EngineIssues.js';
19
+ import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
20
20
  import { getRenderProfile } from '../EngineDefaults.js';
21
21
 
22
22
  // Define supported file formats
@@ -39,6 +39,7 @@ const SUPPORTED_FORMATS = {
39
39
  // layers and the image assets they reference.
40
40
  const USD_LAYER_RE = /\.(usd|usda|usdc)$/i;
41
41
  const USD_IMAGE_RE = /\.(png|jpg|jpeg|avif)$/i;
42
+ const MTL_TEXTURE_TIMEOUT_MS = 30000;
42
43
  // A throwaway stand-in for a geometry the engine must not mutate: the split's mergeGroups()
43
44
  // reorders and disposes what it is given, but never writes the attributes.
44
45
  function standInForSplit( source ) {
@@ -482,7 +483,13 @@ export class AssetLoader extends EventDispatcher {
482
483
  }
483
484
 
484
485
  // Archive handling
485
- async loadArchiveFromFile( file, filename ) {
486
+ /**
487
+ * @param {File|Blob} file
488
+ * @param {string} filename
489
+ * @param {{pbrtEntry?: string}} [options] - `pbrtEntry` names which .pbrt to load when the
490
+ * archive holds several independent scenes; the issue log lists what else was available.
491
+ */
492
+ async loadArchiveFromFile( file, filename, { pbrtEntry = null } = {} ) {
486
493
 
487
494
  try {
488
495
 
@@ -490,7 +497,7 @@ export class AssetLoader extends EventDispatcher {
490
497
  const zip = unzipSync( new Uint8Array( arrayBuffer ) );
491
498
 
492
499
  // A pbrt scene archive takes priority — it owns its own geometry/texture refs.
493
- if ( pickEntryPath( zip ) ) return await this.loadPBRTFromZip( zip, filename );
500
+ if ( pickEntryPath( zip ) ) return await this.loadPBRTFromZip( zip, filename, pbrtEntry );
494
501
 
495
502
  const result = await this.processObjMtlPairsInZip( zip, filename );
496
503
  if ( result ) return result;
@@ -512,7 +519,7 @@ export class AssetLoader extends EventDispatcher {
512
519
  * @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes)
513
520
  * @param {string} filename - original archive name (for display/events)
514
521
  */
515
- async loadPBRTFromZip( zip, filename ) {
522
+ async loadPBRTFromZip( zip, filename, entryPath = null ) {
516
523
 
517
524
  updateLoading( { isLoading: true, status: 'Parsing PBRT scene...', progress: 5 } );
518
525
 
@@ -546,14 +553,34 @@ export class AssetLoader extends EventDispatcher {
546
553
 
547
554
  };
548
555
 
549
- const { group, environment, report, warnings, entryPath } = await loadPBRTScene( {
550
- vfs: zip, plyParser, imageFromBytes, envFromBytes
556
+ const requested = entryPath && listEntryPaths( zip ).includes( entryPath ) ? entryPath : null;
557
+ if ( entryPath && ! requested ) console.warn( `PBRT entry "${entryPath}" is not a scene in this archive — auto-detecting instead` );
558
+
559
+ const { group, environment, report, warnings, entryPath: loadedEntry } = await loadPBRTScene( {
560
+ vfs: zip, entryPath: requested, plyParser, imageFromBytes, envFromBytes
551
561
  } );
552
562
 
563
+ // An archive can hold several independent scenes (transparent-machines ships five
564
+ // animation frames). Only one is loaded, so name it and the alternatives rather
565
+ // than leave the user comparing against a reference of a different scene.
566
+ const candidates = listEntryPaths( zip );
567
+ if ( candidates.length > 1 ) {
568
+
569
+ const others = candidates.filter( p => p !== loadedEntry );
570
+ console.warn( `PBRT archive holds ${candidates.length} scenes; loaded "${loadedEntry}". Others: ${others.join( ', ' )}` );
571
+ this._issues?.record(
572
+ ISSUE_CODES.ASSET_AMBIGUOUS_ENTRY,
573
+ `archive holds ${candidates.length} pbrt scenes; loaded "${loadedEntry}"`,
574
+ { loaded: loadedEntry, alternatives: others },
575
+ ISSUE_SEVERITY.WARNING
576
+ );
577
+
578
+ }
579
+
553
580
  // Diagnostics — surface what each mesh resolved to (helps debug black/wrong materials).
554
581
  if ( report && report.length && typeof console.table === 'function' ) {
555
582
 
556
- console.groupCollapsed( `PBRT loader: ${report.length} mesh(es) from "${entryPath}"` );
583
+ console.groupCollapsed( `PBRT loader: ${report.length} mesh(es) from "${loadedEntry}"` );
557
584
  console.table( report );
558
585
  console.groupEnd();
559
586
 
@@ -561,7 +588,7 @@ export class AssetLoader extends EventDispatcher {
561
588
 
562
589
  if ( warnings && warnings.length ) {
563
590
 
564
- console.warn( `PBRT loader: ${warnings.length} warning(s) parsing "${entryPath}"` );
591
+ console.warn( `PBRT loader: ${warnings.length} warning(s) parsing "${loadedEntry}"` );
565
592
  warnings.forEach( w => console.warn( ' •', w ) );
566
593
 
567
594
  }
@@ -574,14 +601,19 @@ export class AssetLoader extends EventDispatcher {
574
601
 
575
602
  }
576
603
 
577
- group.name = entryPath || filename;
604
+ group.name = loadedEntry || filename;
578
605
  this.releaseTargetModel();
579
606
  this.targetModel = group;
580
607
 
608
+ // The light's own orientation and `scale` are already baked into the texture, so the
609
+ // scene is only correct at rotation 0 / intensity 1. Without this the viewer profile's
610
+ // default 270° rotation lands on top and the sky sits 90° from where pbrt puts it.
611
+ if ( environment?.texture ) this.sceneMetadata = { environment: { rotation: 0, intensity: 1 } };
612
+
581
613
  updateLoading( { isLoading: true, status: 'Processing PBRT geometry...', progress: 10 } );
582
614
  await this.onModelLoad( this.targetModel );
583
615
 
584
- this.dispatchEvent( { type: 'load', model: group, filename: `${entryPath} (from ZIP)` } );
616
+ this.dispatchEvent( { type: 'load', model: group, filename: `${loadedEntry} (from ZIP)` } );
585
617
  return group;
586
618
 
587
619
  }
@@ -932,6 +964,56 @@ export class AssetLoader extends EventDispatcher {
932
964
 
933
965
  }
934
966
 
967
+ // MTLLoader returns Texture objects whose `.image` only lands on a later tick, and
968
+ // SceneProcessor._bucketTextures drops any texture without one — so handing the model
969
+ // over before the maps decode renders the whole scene untextured. Replaces preload().
970
+ async preloadMtlTextures( materials ) {
971
+
972
+ const pending = [];
973
+ const loadTexture = materials.loadTexture.bind( materials );
974
+
975
+ materials.loadTexture = ( url, mapping, onLoad, onProgress, onError ) => {
976
+
977
+ let settle;
978
+ pending.push( new Promise( resolve => ( settle = resolve ) ) );
979
+ return loadTexture( url, mapping,
980
+ texture => {
981
+
982
+ settle();
983
+ onLoad?.( texture );
984
+
985
+ },
986
+ onProgress,
987
+ error => {
988
+
989
+ settle();
990
+ onError?.( error );
991
+
992
+ }
993
+ );
994
+
995
+ };
996
+
997
+ materials.preload();
998
+ if ( pending.length === 0 ) return materials;
999
+
1000
+ let timer;
1001
+ const decoded = await Promise.race( [
1002
+ Promise.all( pending ).then( () => true ),
1003
+ new Promise( resolve => ( timer = setTimeout( () => resolve( false ), MTL_TEXTURE_TIMEOUT_MS ) ) )
1004
+ ] );
1005
+ clearTimeout( timer );
1006
+
1007
+ if ( ! decoded ) this._issues?.record(
1008
+ ISSUE_CODES.TEXTURE_BUILD_FAILED,
1009
+ `MTL textures did not decode within ${MTL_TEXTURE_TIMEOUT_MS}ms; affected materials render untextured`,
1010
+ { pending: pending.length }
1011
+ );
1012
+
1013
+ return materials;
1014
+
1015
+ }
1016
+
935
1017
  async loadMtlFromZip( mtlFilename, objPath, zipContents ) {
936
1018
 
937
1019
  const objDir = objPath.split( '/' ).slice( 0, - 1 ).join( '/' );
@@ -951,7 +1033,7 @@ export class AssetLoader extends EventDispatcher {
951
1033
  manager.setURLModifier( url => this.resolveZipResource( url, objDir, zipContents ) );
952
1034
  const mtlLoader = new MTLLoader( manager );
953
1035
  const materials = mtlLoader.parse( mtlContent, objDir );
954
- materials.preload();
1036
+ await this.preloadMtlTextures( materials );
955
1037
  return materials;
956
1038
 
957
1039
  }
@@ -999,7 +1081,7 @@ export class AssetLoader extends EventDispatcher {
999
1081
  manager.setURLModifier( url => this.resolveTextureInZip( url, objDir, mtlDir, mtlFile, zip, createdUrls ) );
1000
1082
  const mtlContent = this.prepareFixedMtlContent( mtlFile );
1001
1083
  const materials = new MTLLoader( manager ).parse( mtlContent, mtlDir );
1002
- materials.preload();
1084
+ await this.preloadMtlTextures( materials );
1003
1085
 
1004
1086
  const objLoader = new OBJLoader( manager );
1005
1087
  objLoader.setMaterials( materials );
@@ -1712,6 +1794,17 @@ export class AssetLoader extends EventDispatcher {
1712
1794
  object.getWorldPosition( camera.position );
1713
1795
  object.getWorldQuaternion( camera.quaternion );
1714
1796
 
1797
+ // Keep only the sign of the world scale. A camera has no meaningful
1798
+ // size, but a negative axis is a mirror — a pbrt scene's `Scale -1 1 1`
1799
+ // lives here — and dropping it silently un-flips the view. decompose()
1800
+ // parks any mirror on x, so this lands as (±1, 1, 1).
1801
+ object.getWorldScale( camera.scale );
1802
+ camera.scale.set(
1803
+ Math.sign( camera.scale.x ) || 1,
1804
+ Math.sign( camera.scale.y ) || 1,
1805
+ Math.sign( camera.scale.z ) || 1
1806
+ );
1807
+
1715
1808
  // Set a meaningful name
1716
1809
  if ( ! camera.name || camera.name === '' ) {
1717
1810
 
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Equal-area octahedral environment maps, as pbrt-v4 stores them.
3
+ *
4
+ * `LightSource "infinite" "string filename"` does NOT take a lat-long panorama.
5
+ * pbrt-v4's ImageInfiniteLight reads a square image through EqualAreaSphereToSquare:
6
+ * the upper hemisphere fills a centre diamond and the lower hemisphere the four
7
+ * corner triangles (`imgtool makeequiarea` / `makesky` write this). Sampling one as
8
+ * equirectangular smears the black corners over the whole sphere and puts the sun
9
+ * in the wrong place — the scene goes dark.
10
+ *
11
+ * The engine's environment path (CDF build, importance sampling, background) is
12
+ * equirectangular throughout, so convert once at load rather than teach every
13
+ * consumer a second projection. The light's own transform folds into the same
14
+ * resample, which is also how the engine gets a rotation it cannot otherwise
15
+ * express (its environmentRotation is a single Y angle; pbrt lights carry a full
16
+ * matrix).
17
+ */
18
+
19
+ import * as M from './PBRTMath.js';
20
+
21
+ /**
22
+ * pbrt-v4 `EqualAreaSphereToSquare`: unit direction → [0,1]² in the octahedral map.
23
+ * Uses Math.atan where pbrt uses a polynomial approximation — this runs once on the
24
+ * CPU, so take the exact value.
25
+ */
26
+ export function equalAreaSphereToSquare( dx, dy, dz ) {
27
+
28
+ const x = Math.abs( dx ), y = Math.abs( dy ), z = Math.abs( dz );
29
+ const r = Math.sqrt( Math.max( 0, 1 - z ) );
30
+
31
+ const a = Math.max( x, y );
32
+ const b = a === 0 ? 0 : Math.min( x, y ) / a;
33
+
34
+ let phi = Math.atan( b ) * ( 2 / Math.PI );
35
+ if ( x < y ) phi = 1 - phi;
36
+
37
+ let v = phi * r;
38
+ let u = r - v;
39
+
40
+ if ( dz < 0 ) {
41
+
42
+ const prevU = u;
43
+ u = 1 - v;
44
+ v = 1 - prevU;
45
+
46
+ }
47
+
48
+ if ( dx < 0 ) u = - u;
49
+ if ( dy < 0 ) v = - v;
50
+
51
+ return [ 0.5 * ( u + 1 ), 0.5 * ( v + 1 ) ];
52
+
53
+ }
54
+
55
+ /** Bilinear fetch from a tightly packed float image, clamped at the edges. */
56
+ function sampleBilinear( data, width, height, channels, u, v, out ) {
57
+
58
+ const fx = Math.min( Math.max( u * width - 0.5, 0 ), width - 1 );
59
+ const fy = Math.min( Math.max( v * height - 0.5, 0 ), height - 1 );
60
+ const x0 = Math.floor( fx ), y0 = Math.floor( fy );
61
+ const x1 = Math.min( x0 + 1, width - 1 ), y1 = Math.min( y0 + 1, height - 1 );
62
+ const tx = fx - x0, ty = fy - y0;
63
+
64
+ const i00 = ( y0 * width + x0 ) * channels;
65
+ const i10 = ( y0 * width + x1 ) * channels;
66
+ const i01 = ( y1 * width + x0 ) * channels;
67
+ const i11 = ( y1 * width + x1 ) * channels;
68
+
69
+ for ( let c = 0; c < 3; c ++ ) {
70
+
71
+ const top = data[ i00 + c ] * ( 1 - tx ) + data[ i10 + c ] * tx;
72
+ const bottom = data[ i01 + c ] * ( 1 - tx ) + data[ i11 + c ] * tx;
73
+ out[ c ] = top * ( 1 - ty ) + bottom * ty;
74
+
75
+ }
76
+
77
+ }
78
+
79
+ /**
80
+ * Resample a square equal-area octahedral map into an equirectangular RGBA float image.
81
+ *
82
+ * The output uses the engine's own mapping (Environment.js): u = atan2(z,x)/2pi + 0.5,
83
+ * v = 1 - acos(y)/pi, with row 0 at v = 1 so "up" lands at the top like any other HDRI.
84
+ *
85
+ * @param {{data: ArrayLike<number>, width: number, height: number, channels: number,
86
+ * bottomUp?: boolean}} src - `bottomUp` when row 0 holds the BOTTOM of the image, which is
87
+ * what three's EXR/HDR loaders produce; pbrt indexes the square top-down, and reading it
88
+ * the wrong way round turns out to rotate the sky 180° about the light's z axis.
89
+ * @param {number[]} lightToWorld - the light's pbrt CTM (column-major 16), or null
90
+ * @param {number} scale - the light's `scale` parameter, baked into the output
91
+ * @param {number} [outWidth] - defaults to the source width, capped at 4096
92
+ * @returns {{data: Float32Array, width: number, height: number}}
93
+ */
94
+ export function octahedralToEquirect( src, lightToWorld, scale = 1, outWidth = 0 ) {
95
+
96
+ const width = outWidth || Math.min( src.width, 4096 );
97
+ const height = Math.max( 1, width >> 1 );
98
+ const out = new Float32Array( width * height * 4 );
99
+
100
+ // Directions go world → light, the opposite of the CTM, matching pbrt's
101
+ // renderFromLight.ApplyInverse(ray.d) before the square lookup.
102
+ const worldToLight = lightToWorld ? M.invert( lightToWorld ) : null;
103
+ const rgb = [ 0, 0, 0 ];
104
+
105
+ for ( let j = 0; j < height; j ++ ) {
106
+
107
+ // Row index IS the v axis the engine samples on, so row 0 is v = 0 (straight down)
108
+ // and the last row is v = 1 (up). Verified against a rendered frame — inverting
109
+ // this turns the sky upside down with no other visible symptom.
110
+ const v = ( j + 0.5 ) / height;
111
+ const polar = ( 1 - v ) * Math.PI;
112
+ const sinPolar = Math.sin( polar ), cosPolar = Math.cos( polar );
113
+
114
+ for ( let i = 0; i < width; i ++ ) {
115
+
116
+ const u = ( i + 0.5 ) / width;
117
+ const azimuth = ( u - 0.5 ) * 2 * Math.PI;
118
+
119
+ let dx = sinPolar * Math.cos( azimuth );
120
+ let dy = cosPolar;
121
+ let dz = sinPolar * Math.sin( azimuth );
122
+
123
+ if ( worldToLight ) {
124
+
125
+ const m = worldToLight;
126
+ const tx = m[ 0 ] * dx + m[ 4 ] * dy + m[ 8 ] * dz;
127
+ const ty = m[ 1 ] * dx + m[ 5 ] * dy + m[ 9 ] * dz;
128
+ const tz = m[ 2 ] * dx + m[ 6 ] * dy + m[ 10 ] * dz;
129
+ const len = Math.hypot( tx, ty, tz ) || 1;
130
+ dx = tx / len; dy = ty / len; dz = tz / len;
131
+
132
+ }
133
+
134
+ const [ su, sv ] = equalAreaSphereToSquare( dx, dy, dz );
135
+ sampleBilinear( src.data, src.width, src.height, src.channels, su, src.bottomUp ? 1 - sv : sv, rgb );
136
+
137
+ const o = ( j * width + i ) * 4;
138
+ out[ o ] = rgb[ 0 ] * scale;
139
+ out[ o + 1 ] = rgb[ 1 ] * scale;
140
+ out[ o + 2 ] = rgb[ 2 ] * scale;
141
+ out[ o + 3 ] = 1;
142
+
143
+ }
144
+
145
+ }
146
+
147
+ return { data: out, width, height };
148
+
149
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Loop subdivision for pbrt's `loopsubdiv` shape.
3
+ *
4
+ * Mirrors pbrt-v4's loopsubdiv.cpp: Warren's simplified beta for the even-vertex
5
+ * one-ring rule, 1/8 for the boundary rule, 3/8–1/8 for interior edge points, and a
6
+ * final push onto the limit surface. Scenes use this shape for exactly the objects
7
+ * that read wrong as a coarse cage — a tub, a cushion, a curtain — so approximating
8
+ * it by its control mesh is visibly not the same model.
9
+ *
10
+ * Topology is rebuilt per level from the index buffer alone; no half-edge structure,
11
+ * since the meshes involved are control cages (thousands of faces, not millions).
12
+ */
13
+
14
+ /** pbrt: Beta(valence) — the one-ring weight for an interior even vertex. */
15
+ function beta( valence ) {
16
+
17
+ return valence === 3 ? 3 / 16 : 3 / ( 8 * valence );
18
+
19
+ }
20
+
21
+ /** pbrt: LoopGamma(valence) — the one-ring weight for the limit-surface push. */
22
+ function loopGamma( valence ) {
23
+
24
+ return 1 / ( valence + 3 / ( 8 * beta( valence ) ) );
25
+
26
+ }
27
+
28
+ /**
29
+ * Edge records plus per-vertex neighbour rings.
30
+ *
31
+ * A vertex is on the boundary when it touches an edge with a single adjacent face;
32
+ * `boundaryRing` keeps only those neighbours, because the boundary rule weights the
33
+ * two vertices along the boundary curve, not the whole ring.
34
+ */
35
+ function buildTopology( nv, indices ) {
36
+
37
+ const edgeIndex = new Map();
38
+ const edgeV0 = [], edgeV1 = [], edgeOpp0 = [], edgeOpp1 = [], edgeFaces = [];
39
+ const faceCount = indices.length / 3;
40
+
41
+ for ( let f = 0; f < faceCount; f ++ ) {
42
+
43
+ for ( let e = 0; e < 3; e ++ ) {
44
+
45
+ const a = indices[ f * 3 + e ];
46
+ const b = indices[ f * 3 + ( e + 1 ) % 3 ];
47
+ const opposite = indices[ f * 3 + ( e + 2 ) % 3 ];
48
+ const lo = a < b ? a : b;
49
+ const hi = a < b ? b : a;
50
+ const key = lo * nv + hi;
51
+
52
+ const existing = edgeIndex.get( key );
53
+ if ( existing === undefined ) {
54
+
55
+ edgeIndex.set( key, edgeV0.length );
56
+ edgeV0.push( lo );
57
+ edgeV1.push( hi );
58
+ edgeOpp0.push( opposite );
59
+ edgeOpp1.push( - 1 );
60
+ edgeFaces.push( 1 );
61
+
62
+ } else {
63
+
64
+ if ( edgeOpp1[ existing ] < 0 ) edgeOpp1[ existing ] = opposite;
65
+ edgeFaces[ existing ] ++;
66
+
67
+ }
68
+
69
+ }
70
+
71
+ }
72
+
73
+ const ring = Array.from( { length: nv }, () => [] );
74
+ const boundaryRing = Array.from( { length: nv }, () => [] );
75
+
76
+ for ( let i = 0; i < edgeV0.length; i ++ ) {
77
+
78
+ const v0 = edgeV0[ i ], v1 = edgeV1[ i ];
79
+ ring[ v0 ].push( v1 );
80
+ ring[ v1 ].push( v0 );
81
+
82
+ if ( edgeFaces[ i ] === 1 ) {
83
+
84
+ boundaryRing[ v0 ].push( v1 );
85
+ boundaryRing[ v1 ].push( v0 );
86
+
87
+ }
88
+
89
+ }
90
+
91
+ return { edgeIndex, edgeV0, edgeV1, edgeOpp0, edgeOpp1, edgeFaces, ring, boundaryRing };
92
+
93
+ }
94
+
95
+ /**
96
+ * Reposition every vertex by a one-ring weighting: (1 - n·w)·v + w·Σring for an
97
+ * interior vertex, (1 - 2w)·v + w·(both boundary neighbours) on a boundary. Used for
98
+ * both the even-vertex step and the limit push, which differ only in the weights.
99
+ */
100
+ function weightVertices( P, topology, nv, interiorWeight, boundaryWeight ) {
101
+
102
+ const out = new Float64Array( P.length );
103
+
104
+ for ( let v = 0; v < nv; v ++ ) {
105
+
106
+ const base = v * 3;
107
+ const boundary = topology.boundaryRing[ v ];
108
+
109
+ // Anything other than a clean two-neighbour boundary (a wire edge, a
110
+ // non-manifold fan) has no defined rule — leave the vertex where it is.
111
+ if ( boundary.length === 2 ) {
112
+
113
+ const a = boundary[ 0 ] * 3, b = boundary[ 1 ] * 3;
114
+ const centre = 1 - 2 * boundaryWeight;
115
+ for ( let k = 0; k < 3; k ++ ) {
116
+
117
+ out[ base + k ] = centre * P[ base + k ] + boundaryWeight * ( P[ a + k ] + P[ b + k ] );
118
+
119
+ }
120
+
121
+ continue;
122
+
123
+ }
124
+
125
+ const ring = topology.ring[ v ];
126
+ const n = ring.length;
127
+ if ( n === 0 || boundary.length > 0 ) {
128
+
129
+ for ( let k = 0; k < 3; k ++ ) out[ base + k ] = P[ base + k ];
130
+ continue;
131
+
132
+ }
133
+
134
+ const w = interiorWeight( n );
135
+ let sx = 0, sy = 0, sz = 0;
136
+ for ( let i = 0; i < n; i ++ ) {
137
+
138
+ const r = ring[ i ] * 3;
139
+ sx += P[ r ];
140
+ sy += P[ r + 1 ];
141
+ sz += P[ r + 2 ];
142
+
143
+ }
144
+
145
+ const centre = 1 - n * w;
146
+ out[ base ] = centre * P[ base ] + w * sx;
147
+ out[ base + 1 ] = centre * P[ base + 1 ] + w * sy;
148
+ out[ base + 2 ] = centre * P[ base + 2 ] + w * sz;
149
+
150
+ }
151
+
152
+ return out;
153
+
154
+ }
155
+
156
+ /** One subdivision step: 4 faces per face, one new vertex per edge. */
157
+ function subdivideOnce( P, indices ) {
158
+
159
+ const nv = P.length / 3;
160
+ const topology = buildTopology( nv, indices );
161
+ const edgeCount = topology.edgeV0.length;
162
+
163
+ const even = weightVertices( P, topology, nv, beta, 1 / 8 );
164
+ const out = new Float64Array( ( nv + edgeCount ) * 3 );
165
+ out.set( even );
166
+
167
+ for ( let i = 0; i < edgeCount; i ++ ) {
168
+
169
+ const v0 = topology.edgeV0[ i ] * 3;
170
+ const v1 = topology.edgeV1[ i ] * 3;
171
+ const dst = ( nv + i ) * 3;
172
+ const opp1 = topology.edgeOpp1[ i ];
173
+
174
+ if ( opp1 < 0 ) {
175
+
176
+ for ( let k = 0; k < 3; k ++ ) out[ dst + k ] = 0.5 * ( P[ v0 + k ] + P[ v1 + k ] );
177
+
178
+ } else {
179
+
180
+ const o0 = topology.edgeOpp0[ i ] * 3, o1 = opp1 * 3;
181
+ for ( let k = 0; k < 3; k ++ ) {
182
+
183
+ out[ dst + k ] = 0.375 * ( P[ v0 + k ] + P[ v1 + k ] ) + 0.125 * ( P[ o0 + k ] + P[ o1 + k ] );
184
+
185
+ }
186
+
187
+ }
188
+
189
+ }
190
+
191
+ const faceCount = indices.length / 3;
192
+ const outIndices = new Int32Array( faceCount * 12 );
193
+ const midpoint = ( a, b ) => nv + topology.edgeIndex.get( ( a < b ? a : b ) * nv + ( a < b ? b : a ) );
194
+
195
+ for ( let f = 0; f < faceCount; f ++ ) {
196
+
197
+ const a = indices[ f * 3 ], b = indices[ f * 3 + 1 ], c = indices[ f * 3 + 2 ];
198
+ const ab = midpoint( a, b ), bc = midpoint( b, c ), ca = midpoint( c, a );
199
+ const o = f * 12;
200
+
201
+ outIndices[ o ] = a; outIndices[ o + 1 ] = ab; outIndices[ o + 2 ] = ca;
202
+ outIndices[ o + 3 ] = ab; outIndices[ o + 4 ] = b; outIndices[ o + 5 ] = bc;
203
+ outIndices[ o + 6 ] = ca; outIndices[ o + 7 ] = bc; outIndices[ o + 8 ] = c;
204
+ outIndices[ o + 9 ] = ab; outIndices[ o + 10 ] = bc; outIndices[ o + 11 ] = ca;
205
+
206
+ }
207
+
208
+ return { positions: out, indices: outIndices };
209
+
210
+ }
211
+
212
+ /**
213
+ * @param {ArrayLike<number>} P - control-cage positions, 3 per vertex
214
+ * @param {ArrayLike<number>} indices - control-cage triangles, 3 per face
215
+ * @param {number} levels - refinement steps; each multiplies face count by 4
216
+ * @param {number} [maxTriangles=2000000] - stop refining early rather than exhaust memory
217
+ * @returns {{positions: Float32Array, indices: Uint32Array, levels: number}} levels actually applied
218
+ */
219
+ export function loopSubdivide( P, indices, levels, maxTriangles = 2000000 ) {
220
+
221
+ let positions = Float64Array.from( P );
222
+ let faces = Int32Array.from( indices );
223
+ let applied = 0;
224
+
225
+ for ( let i = 0; i < levels; i ++ ) {
226
+
227
+ if ( faces.length / 3 * 4 > maxTriangles ) break;
228
+ const step = subdivideOnce( positions, faces );
229
+ positions = step.positions;
230
+ faces = step.indices;
231
+ applied ++;
232
+
233
+ }
234
+
235
+ const nv = positions.length / 3;
236
+ const limit = weightVertices( positions, buildTopology( nv, faces ), nv, loopGamma, 1 / 5 );
237
+
238
+ return { positions: Float32Array.from( limit ), indices: Uint32Array.from( faces ), levels: applied };
239
+
240
+ }
@@ -17,6 +17,15 @@ export function identity() {
17
17
 
18
18
  }
19
19
 
20
+ /** Determinant of the upper-left 3x3 basis. Negative means the transform mirrors. */
21
+ export function determinant3( m ) {
22
+
23
+ return m[ 0 ] * ( m[ 5 ] * m[ 10 ] - m[ 6 ] * m[ 9 ] )
24
+ - m[ 4 ] * ( m[ 1 ] * m[ 10 ] - m[ 2 ] * m[ 9 ] )
25
+ + m[ 8 ] * ( m[ 1 ] * m[ 6 ] - m[ 2 ] * m[ 5 ] );
26
+
27
+ }
28
+
20
29
  /** Matrix product a*b (applies b first, then a, to a column vector). */
21
30
  export function multiply( a, b ) {
22
31