entangled-worlds-orbiters-shared 3.3.0 → 4.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 (126) hide show
  1. package/README.md +3 -2
  2. package/dist/clock/Transport.d.ts +8 -2
  3. package/dist/clock/Transport.js +46 -7
  4. package/dist/clock/musical.js +13 -8
  5. package/dist/clock/sync.js +15 -2
  6. package/dist/config/env.d.ts +8 -0
  7. package/dist/config/env.js +36 -0
  8. package/dist/config/index.d.ts +3 -0
  9. package/dist/config/index.js +4 -0
  10. package/dist/config/textureConfig.d.ts +140 -0
  11. package/dist/config/textureConfig.js +318 -0
  12. package/dist/core/index.d.ts +2 -0
  13. package/dist/core/index.js +3 -0
  14. package/dist/core/lightFresnel.d.ts +6 -0
  15. package/dist/core/lightFresnel.js +63 -0
  16. package/dist/core/lights.d.ts +48 -0
  17. package/dist/core/lights.js +79 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +7 -0
  20. package/dist/loaders/bodyTextureLoader.d.ts +10 -0
  21. package/dist/loaders/bodyTextureLoader.js +28 -0
  22. package/dist/loaders/cubeMapLoader.d.ts +58 -0
  23. package/dist/loaders/cubeMapLoader.js +291 -0
  24. package/dist/loaders/glbLoader.d.ts +24 -0
  25. package/dist/loaders/glbLoader.js +85 -0
  26. package/dist/loaders/index.d.ts +5 -0
  27. package/{src → dist}/loaders/index.js +0 -2
  28. package/dist/{types/loaders → loaders}/ktx2.d.ts +4 -6
  29. package/dist/loaders/ktx2.js +48 -0
  30. package/dist/moons/index.d.ts +3 -0
  31. package/dist/moons/index.js +3 -0
  32. package/dist/moons/moonsGeneration.d.ts +64 -0
  33. package/dist/moons/moonsGeneration.js +238 -0
  34. package/dist/moons/moonsManager.d.ts +43 -0
  35. package/dist/moons/moonsManager.js +93 -0
  36. package/dist/{types/render → render}/frameBudget.d.ts +18 -17
  37. package/dist/render/frameBudget.js +42 -0
  38. package/dist/render/index.d.ts +4 -0
  39. package/dist/render/viewportCompositor.d.ts +124 -0
  40. package/dist/render/viewportCompositor.js +643 -0
  41. package/dist/studio/headerBroadcast.d.ts +10 -1
  42. package/dist/studio/hostTransport.d.ts +2 -2
  43. package/dist/studio/index.d.ts +1 -1
  44. package/dist/utils/dispose.d.ts +5 -0
  45. package/dist/utils/dispose.js +50 -0
  46. package/dist/utils/index.d.ts +3 -0
  47. package/{src → dist}/utils/index.js +1 -1
  48. package/dist/utils/materials.d.ts +10 -0
  49. package/dist/utils/materials.js +36 -0
  50. package/dist/world/cloudShell.d.ts +16 -0
  51. package/dist/world/cloudShell.js +73 -0
  52. package/dist/world/index.d.ts +6 -0
  53. package/{src → dist}/world/index.js +0 -2
  54. package/dist/world/worldGLB.d.ts +17 -0
  55. package/dist/world/worldGLB.js +22 -0
  56. package/dist/world/worldManager.d.ts +57 -0
  57. package/dist/world/worldManager.js +125 -0
  58. package/dist/world/worldNormal.d.ts +30 -0
  59. package/dist/world/worldNormal.js +58 -0
  60. package/dist/world/worldTexture.d.ts +56 -0
  61. package/dist/world/worldTexture.js +299 -0
  62. package/package.json +29 -39
  63. package/src/clock/Transport.ts +37 -6
  64. package/src/clock/musical.ts +18 -8
  65. package/src/clock/sync.ts +15 -2
  66. package/src/config/env.ts +48 -0
  67. package/src/config/{index.js → index.ts} +7 -0
  68. package/src/config/{textureConfig.js → textureConfig.ts} +126 -60
  69. package/src/core/{lightFresnel.js → lightFresnel.ts} +1 -1
  70. package/src/core/{lights.js → lights.ts} +21 -21
  71. package/src/loaders/{bodyTextureLoader.js → bodyTextureLoader.ts} +10 -7
  72. package/src/loaders/{cubeMapLoader.js → cubeMapLoader.ts} +83 -49
  73. package/src/loaders/glbLoader.ts +110 -0
  74. package/src/loaders/index.ts +6 -0
  75. package/src/loaders/{ktx2.js → ktx2.ts} +5 -7
  76. package/src/moons/{index.js → index.ts} +1 -0
  77. package/src/moons/{moonsGeneration.js → moonsGeneration.ts} +45 -32
  78. package/src/moons/{moonsManager.js → moonsManager.ts} +37 -11
  79. package/src/render/{frameBudget.js → frameBudget.ts} +15 -8
  80. package/src/render/index.ts +14 -0
  81. package/src/render/{viewportCompositor.js → viewportCompositor.ts} +121 -59
  82. package/src/studio/headerBroadcast.ts +14 -1
  83. package/src/studio/hostTransport.ts +2 -1
  84. package/src/studio/index.ts +1 -1
  85. package/src/utils/dispose.ts +47 -0
  86. package/src/utils/index.ts +4 -0
  87. package/src/utils/materials.ts +44 -0
  88. package/src/world/{cloudShell.js → cloudShell.ts} +19 -12
  89. package/src/world/index.ts +7 -0
  90. package/src/world/worldGLB.ts +41 -0
  91. package/src/world/{worldManager.js → worldManager.ts} +67 -32
  92. package/src/world/{worldNormal.js → worldNormal.ts} +25 -20
  93. package/src/world/{worldTexture.js → worldTexture.ts} +70 -51
  94. package/dist/types/config/env.d.ts +0 -12
  95. package/dist/types/config/index.d.ts +0 -2
  96. package/dist/types/config/textureConfig.d.ts +0 -121
  97. package/dist/types/core/index.d.ts +0 -2
  98. package/dist/types/core/lightFresnel.d.ts +0 -5
  99. package/dist/types/core/lights.d.ts +0 -58
  100. package/dist/types/index.d.ts +0 -6
  101. package/dist/types/loaders/bodyTextureLoader.d.ts +0 -12
  102. package/dist/types/loaders/cubeMapLoader.d.ts +0 -70
  103. package/dist/types/loaders/glbLoader.d.ts +0 -24
  104. package/dist/types/loaders/index.d.ts +0 -5
  105. package/dist/types/moons/index.d.ts +0 -2
  106. package/dist/types/moons/moonsGeneration.d.ts +0 -72
  107. package/dist/types/moons/moonsManager.d.ts +0 -27
  108. package/dist/types/render/index.d.ts +0 -2
  109. package/dist/types/render/viewportCompositor.d.ts +0 -58
  110. package/dist/types/utils/dispose.d.ts +0 -2
  111. package/dist/types/utils/index.d.ts +0 -2
  112. package/dist/types/utils/materials.d.ts +0 -2
  113. package/dist/types/world/cloudShell.d.ts +0 -25
  114. package/dist/types/world/index.d.ts +0 -6
  115. package/dist/types/world/worldGLB.d.ts +0 -20
  116. package/dist/types/world/worldManager.d.ts +0 -63
  117. package/dist/types/world/worldNormal.d.ts +0 -43
  118. package/dist/types/world/worldTexture.d.ts +0 -80
  119. package/src/config/env.js +0 -38
  120. package/src/loaders/glbLoader.js +0 -78
  121. package/src/utils/dispose.js +0 -39
  122. package/src/utils/materials.js +0 -31
  123. package/src/world/worldGLB.js +0 -19
  124. /package/{src → dist}/render/index.js +0 -0
  125. /package/src/core/{index.js → index.ts} +0 -0
  126. /package/src/{index.js → index.ts} +0 -0
@@ -0,0 +1,299 @@
1
+ import * as THREE from 'three';
2
+ import { loadTexture } from '../loaders/bodyTextureLoader.js';
3
+ import { applyFlagsToMaterial } from '../utils/materials.js';
4
+ import { getLightFresnel } from '../core/lightFresnel.js';
5
+ import { createCloudShell } from './cloudShell.js';
6
+ import { resolvePublicUrl } from '../config/env.js';
7
+ // Debug tag so you can verify the loaded file version in console
8
+ export const WORLD_TEXTURE_DEBUG_TAG = 'WT:vFlipGeom-r3@2025-10-28T17:22:00Z';
9
+ // Enable debug if query/localStorage says so; expose tag
10
+ if (typeof window !== 'undefined') {
11
+ const q = (window.location && window.location.search) || '';
12
+ const qsDebug = /(?:[?&])debugTextures=1(?:&|$)/.test(q);
13
+ const lsDebug = (window.localStorage && window.localStorage.DEBUG_TEXTURE_FLIP === '1') || false;
14
+ if (!window.DEBUG_TEXTURE_FLIP && (qsDebug || lsDebug)) {
15
+ window.DEBUG_TEXTURE_FLIP = true;
16
+ }
17
+ window.EW_WT_DEBUG_TAG = WORLD_TEXTURE_DEBUG_TAG;
18
+ if (window.DEBUG_TEXTURE_FLIP) {
19
+ console.info('[worldTexture] loaded', WORLD_TEXTURE_DEBUG_TAG);
20
+ }
21
+ }
22
+ const WT_LOGGED_FIRST_CALL = false;
23
+ export const EARTH_RADIUS_UNITS = 0.5; // scene units
24
+ /**
25
+ * Simplified detection: width = height -> rectangular, width = 2×height -> equirectangular
26
+ */
27
+ function isLikelyEquirectangular(img) {
28
+ const aspectRatio = img.width / img.height;
29
+ // Check for 2:1 aspect ratio (equirectangular)
30
+ const is2to1 = Math.abs(aspectRatio - 2.0) < 0.1;
31
+ // Check for 1:1 aspect ratio (square -> treat as rectangular)
32
+ const is1to1 = Math.abs(aspectRatio - 1.0) < 0.1;
33
+ if (is1to1) {
34
+ return false; // Square images are rectangular
35
+ }
36
+ if (is2to1) {
37
+ return true; // 2:1 images are equirectangular
38
+ }
39
+ // For other aspect ratios, default to rectangular
40
+ return false;
41
+ }
42
+ /**
43
+ * The textured world sphere. Every option is optional. The offset, repeat, rotation, flip and sphere
44
+ * geometry options are read only when they have their type; the rest are used as given (`glow`,
45
+ * `cloudsEnabled` and `mirrorX` by truthiness).
46
+ *
47
+ * - `renderer`: REQUIRED for a KTX2 texture. `flags`: material flags (`forceOpaque`, `wireframe`).
48
+ * - `uOffset`: horizontal texture offset, 0..1 (wins over `textureOffsetDeg`, in degrees).
49
+ * - `mirrorDuplicateX`: explicit override of the equirectangular detection
50
+ * (`autoDetectEquirectangular` true, `forceEquirectangular` / `forceRectangular` false).
51
+ * - `wrapS` ('repeat') / `wrapT` ('clamp'): `'clamp'`, `'repeat'` or `'mirror'`.
52
+ * - `center` ([0.5, 0.5]): texture rotation centre, `[u, v]`.
53
+ * - `materialRoughness` / `materialMetalness`: accepted from saved worlds; no longer change the look.
54
+ * - Defaults: `glow` true, `glowRimHex` 0x66ccff, `glowFacingHex` 0x000000, `glowScale` 1.18,
55
+ * `glowBias` 0.02, `glowFresnelScale` 1, `glowPower` 3.5, `cloudsEnabled` false, `cloudsColor`
56
+ * 0xffffff, `cloudsOpacity` 0.6, `cloudsSpeed` 0.015.
57
+ */
58
+ export async function makeSphere({ textureUrl, renderer, flags, uOffset, textureOffsetDeg, mirrorX, mirrorDuplicateX, // optional, still supported for explicit override
59
+ autoDetectEquirectangular = true, forceEquirectangular = false, forceRectangular = false,
60
+ // Wrapping params
61
+ wrapS = 'repeat', wrapT = 'clamp', repeatX, repeatY, rotationDeg, center = [0.5, 0.5], flipY,
62
+ // NEW: sphere geometry controls (optional)
63
+ sphereRadius, sphereWidthSegments, sphereHeightSegments,
64
+ // PBR material tuning (optional)
65
+ materialRoughness, materialMetalness,
66
+ // NEW: glow controls
67
+ glow = true, glowRimHex = 0x66ccff, glowFacingHex = 0x000000, glowScale = 1.18, glowBias = 0.02, glowFresnelScale = 1.0, glowPower = 3.5,
68
+ // texture clouds controls (simplified)
69
+ cloudsEnabled = false, cloudsTextureUrl = '/assets/texture/2k_earth_clouds.jpg', cloudsColor = 0xffffff, cloudsOpacity = 0.6, cloudsSpeed = 0.015, } = {}) {
70
+ const radius = typeof sphereRadius === 'number' ? Math.max(0.0001, sphereRadius) : EARTH_RADIUS_UNITS;
71
+ // `Number.isFinite` does not narrow: past it, each count is a number.
72
+ const widthSegs = Number.isFinite(sphereWidthSegments) ? Math.max(3, Math.floor(sphereWidthSegments)) : 64;
73
+ const heightSegs = Number.isFinite(sphereHeightSegments) ? Math.max(2, Math.floor(sphereHeightSegments)) : 64;
74
+ const geometry = new THREE.SphereGeometry(radius, widthSegs, heightSegs);
75
+ let meshGeometry = geometry; // may clone/flip UVs for compressed textures
76
+ // Diffuse-only (Lambert) at every tier: under the star's key light the physical (Standard) material
77
+ // cost +20% GPU on low for a barely visible sheen. `materialRoughness` /
78
+ // `materialMetalness` stay accepted from saved worlds but no longer change the look.
79
+ const material = new THREE.MeshLambertMaterial({ color: 0xffffff });
80
+ const WRAP = {
81
+ clamp: THREE.ClampToEdgeWrapping,
82
+ repeat: THREE.RepeatWrapping,
83
+ mirror: THREE.MirroredRepeatWrapping,
84
+ };
85
+ // For compressed textures (e.g., KTX2) WebGL cannot apply UNPACK_FLIP_Y at upload.
86
+ // To normalize orientation across loaders, emulate vertical flip in UV sampling:
87
+ // v' = 1 - v => repeat.y = -repeat.y, offset.y = 1 - offset.y
88
+ const normalizeCompressedFlip = (t) => {
89
+ if (!t || !t.isCompressedTexture)
90
+ return;
91
+ const wantFlip = (typeof flipY === 'boolean') ? flipY : true; // match uncompressed default
92
+ if (!wantFlip)
93
+ return;
94
+ const rY = (t.repeat && typeof t.repeat.y === 'number') ? t.repeat.y : 1;
95
+ const oY = (t.offset && typeof t.offset.y === 'number') ? t.offset.y : 0;
96
+ t.repeat.y = -rY;
97
+ t.offset.y = 1 - oY;
98
+ t.needsUpdate = true;
99
+ };
100
+ // Emulate vertical flip for compressed textures (e.g., KTX2) using UV transform
101
+ const normalizeCompressedFlipY = (t, desired) => {
102
+ if (!t || !t.isCompressedTexture)
103
+ return;
104
+ const wantFlip = (typeof desired === 'boolean') ? desired : true; // default to match uncompressed loader
105
+ const rY = t.repeat?.y ?? 1;
106
+ const oY = t.offset?.y ?? 0;
107
+ if (wantFlip) {
108
+ t.repeat.y = -Math.abs(rY);
109
+ t.offset.y = 1 - oY;
110
+ }
111
+ else {
112
+ t.repeat.y = Math.abs(rY);
113
+ t.offset.y = 0;
114
+ }
115
+ t.needsUpdate = true;
116
+ };
117
+ // Flip UV.y on a cloned geometry (keeps other layers safe)
118
+ const flipGeometryUVY = (geom) => {
119
+ if (!geom || !geom.attributes || !geom.attributes.uv)
120
+ return 0;
121
+ const uv = geom.attributes.uv;
122
+ const arr = uv.array;
123
+ for (let i = 1; i < arr.length; i += 2) {
124
+ arr[i] = 1 - arr[i]; // v = 1 - v
125
+ }
126
+ uv.needsUpdate = true;
127
+ return uv.count;
128
+ };
129
+ // Attach a Fresnel glow mesh as a child of the sphere mesh
130
+ function createGlow(rimHex, facingHex) {
131
+ const fresnelMat = getLightFresnel({ rimHex, facingHex });
132
+ // tweak uniforms for stronger rim
133
+ fresnelMat.uniforms.fresnelBias.value = glowBias;
134
+ fresnelMat.uniforms.fresnelScale.value = glowFresnelScale;
135
+ fresnelMat.uniforms.fresnelPower.value = glowPower;
136
+ const glowGeom = geometry.clone();
137
+ const fresnelMesh = new THREE.Mesh(glowGeom, fresnelMat);
138
+ // Slightly larger to avoid z-fighting and create a halo
139
+ const s = Math.max(glowScale, 1.0);
140
+ fresnelMesh.scale.set(s, s, s);
141
+ fresnelMesh.renderOrder = 999; // render after base mesh
142
+ fresnelMesh.name = 'worldTextureGlow';
143
+ return fresnelMesh;
144
+ }
145
+ const chosenTexture = textureUrl; // require explicit textureUrl — no implicit default
146
+ if (chosenTexture) {
147
+ try {
148
+ const tex = await loadTexture(chosenTexture, { renderer });
149
+ tex.colorSpace = THREE.SRGBColorSpace;
150
+ tex.anisotropy = 8;
151
+ if (typeof flipY === 'boolean')
152
+ tex.flipY = flipY;
153
+ // Only enable mipmaps for non-compressed, power-of-two textures
154
+ // A KTX2 load is a CompressedTexture, which says so; a plain Texture has no such field.
155
+ const isCompressed = tex.isCompressedTexture;
156
+ const img = tex.image;
157
+ const isPowerOfTwo = img && ((img.width & (img.width - 1)) === 0) && ((img.height & (img.height - 1)) === 0);
158
+ tex.generateMipmaps = !isCompressed && isPowerOfTwo;
159
+ // Decide mapping approach
160
+ let shouldMirrorDuplicate = mirrorDuplicateX;
161
+ if (tex.image) {
162
+ const img = tex.image;
163
+ let isEquirectangular = false;
164
+ if (forceEquirectangular) {
165
+ isEquirectangular = true;
166
+ }
167
+ else if (forceRectangular) {
168
+ isEquirectangular = false;
169
+ }
170
+ else if (autoDetectEquirectangular && typeof mirrorDuplicateX === 'undefined') {
171
+ const aspectDetection = isLikelyEquirectangular(img);
172
+ isEquirectangular = aspectDetection && Math.abs(img.width / img.height - 2.0) < 0.05;
173
+ }
174
+ else {
175
+ isEquirectangular = !shouldMirrorDuplicate;
176
+ }
177
+ shouldMirrorDuplicate = !isEquirectangular;
178
+ }
179
+ // Default to rectangular duplicate if still undefined
180
+ if (typeof shouldMirrorDuplicate === 'undefined') {
181
+ shouldMirrorDuplicate = true;
182
+ }
183
+ // Seam alignment (offset only)
184
+ let offsetX = 0;
185
+ if (typeof uOffset === 'number') {
186
+ offsetX = ((uOffset % 1) + 1) % 1;
187
+ }
188
+ else if (typeof textureOffsetDeg === 'number') {
189
+ offsetX = (((textureOffsetDeg / 360) % 1) + 1) % 1;
190
+ }
191
+ const applyWrapOptions = (t) => {
192
+ t.wrapS = WRAP[wrapS] ?? THREE.RepeatWrapping;
193
+ t.wrapT = WRAP[wrapT] ?? THREE.ClampToEdgeWrapping;
194
+ // Compressed textures + NPOT prefer clamp to avoid artifacts
195
+ if (isCompressed) {
196
+ t.wrapS = THREE.ClampToEdgeWrapping;
197
+ t.wrapT = THREE.ClampToEdgeWrapping;
198
+ }
199
+ if (Array.isArray(center) && center.length === 2)
200
+ t.center.set(center[0], center[1]);
201
+ if (typeof rotationDeg === 'number')
202
+ t.rotation = (rotationDeg * Math.PI) / 180;
203
+ if (typeof repeatX === 'number' || typeof repeatY === 'number') {
204
+ t.repeat.set(typeof repeatX === 'number' ? repeatX : t.repeat.x, typeof repeatY === 'number' ? repeatY : t.repeat.y);
205
+ }
206
+ t.offset.x = offsetX;
207
+ if (mirrorX) {
208
+ t.repeat.x = -Math.abs(t.repeat.x || 1);
209
+ t.offset.x = (t.offset.x + 1) % 1;
210
+ }
211
+ t.needsUpdate = true;
212
+ };
213
+ if (shouldMirrorDuplicate && tex.image && typeof document !== 'undefined') {
214
+ // Create a mirrored duplicate without any blending
215
+ const img = tex.image;
216
+ try {
217
+ const canvas = document.createElement('canvas');
218
+ canvas.width = img.width * 2;
219
+ canvas.height = img.height;
220
+ // A missing 2D context throws on first use and lands in the fallback below, as before.
221
+ const ctx = canvas.getContext('2d');
222
+ // Left half: original
223
+ ctx.drawImage(img, 0, 0, img.width, img.height);
224
+ // Right half: horizontally flipped copy
225
+ ctx.save();
226
+ ctx.translate(img.width, 0);
227
+ ctx.scale(-1, 1);
228
+ ctx.drawImage(img, -img.width, 0, img.width, img.height);
229
+ ctx.restore();
230
+ const processedTex = new THREE.CanvasTexture(canvas);
231
+ processedTex.colorSpace = THREE.SRGBColorSpace;
232
+ processedTex.generateMipmaps = true;
233
+ processedTex.anisotropy = 8;
234
+ applyWrapOptions(processedTex);
235
+ material.map = processedTex;
236
+ material.needsUpdate = true;
237
+ }
238
+ catch (e) {
239
+ // Fallback: use mirrored repeat directly
240
+ tex.wrapS = THREE.MirroredRepeatWrapping;
241
+ tex.wrapT = WRAP[wrapT] ?? THREE.ClampToEdgeWrapping;
242
+ tex.repeat.x = Math.max(2, typeof repeatX === 'number' ? repeatX : 2);
243
+ applyWrapOptions(tex);
244
+ // If compressed (e.g., KTX2), normalize vertical orientation using sampling transform
245
+ normalizeCompressedFlip(tex);
246
+ material.map = tex;
247
+ material.needsUpdate = true;
248
+ }
249
+ }
250
+ else {
251
+ // Equirectangular or no canvas: just use original texture
252
+ applyWrapOptions(tex);
253
+ const wantFlip = (typeof flipY === 'boolean') ? flipY : true;
254
+ if (isCompressed && wantFlip) {
255
+ meshGeometry = geometry.clone();
256
+ flipGeometryUVY(meshGeometry);
257
+ }
258
+ else if (isCompressed) {
259
+ tex.repeat.y = Math.abs(tex.repeat?.y ?? 1);
260
+ tex.offset.y = 0;
261
+ tex.needsUpdate = true;
262
+ }
263
+ material.map = tex;
264
+ material.needsUpdate = true;
265
+ }
266
+ }
267
+ catch (e) {
268
+ console.warn('[worldTexture] Failed to load texture:', e);
269
+ }
270
+ }
271
+ if (flags)
272
+ applyFlagsToMaterial(material, flags);
273
+ const mesh = new THREE.Mesh(meshGeometry, material);
274
+ // Named so consumers can find the planet body by name (mirrors the
275
+ // normal-mode body's `worldNormalSphere`) — e.g. to modulate its material.
276
+ mesh.name = 'worldTextureSphere';
277
+ mesh.position.set(0, 0, 0);
278
+ // NEW: add cloud shell before glow
279
+ if (cloudsEnabled) {
280
+ const cloudMesh = await createCloudShell({
281
+ baseGeometry: geometry,
282
+ baseRadius: radius,
283
+ widthSegments: widthSegs,
284
+ heightSegments: heightSegs,
285
+ textureUrl: resolvePublicUrl(cloudsTextureUrl),
286
+ color: cloudsColor,
287
+ opacity: cloudsOpacity,
288
+ speed: cloudsSpeed,
289
+ renderer,
290
+ });
291
+ mesh.add(cloudMesh);
292
+ }
293
+ // Optionally add glow as a child of the mesh so it follows transforms
294
+ if (glow) {
295
+ const glowMesh = createGlow(glowRimHex, glowFacingHex);
296
+ mesh.add(glowMesh);
297
+ }
298
+ return mesh;
299
+ }
package/package.json CHANGED
@@ -1,43 +1,43 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "3.3.0",
3
+ "version": "4.1.0",
4
4
  "description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
5
5
  "type": "module",
6
- "main": "src/index.js",
7
- "types": "./dist/types/index.d.ts",
6
+ "main": "dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
8
  "sideEffects": false,
9
9
  "exports": {
10
10
  ".": {
11
- "types": "./dist/types/index.d.ts",
12
- "default": "./src/index.js"
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
13
  },
14
14
  "./world": {
15
- "types": "./dist/types/world/index.d.ts",
16
- "default": "./src/world/index.js"
15
+ "types": "./dist/world/index.d.ts",
16
+ "default": "./dist/world/index.js"
17
17
  },
18
18
  "./moons": {
19
- "types": "./dist/types/moons/index.d.ts",
20
- "default": "./src/moons/index.js"
19
+ "types": "./dist/moons/index.d.ts",
20
+ "default": "./dist/moons/index.js"
21
21
  },
22
22
  "./loaders": {
23
- "types": "./dist/types/loaders/index.d.ts",
24
- "default": "./src/loaders/index.js"
23
+ "types": "./dist/loaders/index.d.ts",
24
+ "default": "./dist/loaders/index.js"
25
25
  },
26
26
  "./render": {
27
- "types": "./dist/types/render/index.d.ts",
28
- "default": "./src/render/index.js"
27
+ "types": "./dist/render/index.d.ts",
28
+ "default": "./dist/render/index.js"
29
29
  },
30
30
  "./core": {
31
- "types": "./dist/types/core/index.d.ts",
32
- "default": "./src/core/index.js"
31
+ "types": "./dist/core/index.d.ts",
32
+ "default": "./dist/core/index.js"
33
33
  },
34
34
  "./config": {
35
- "types": "./dist/types/config/index.d.ts",
36
- "default": "./src/config/index.js"
35
+ "types": "./dist/config/index.d.ts",
36
+ "default": "./dist/config/index.js"
37
37
  },
38
38
  "./utils": {
39
- "types": "./dist/types/utils/index.d.ts",
40
- "default": "./src/utils/index.js"
39
+ "types": "./dist/utils/index.d.ts",
40
+ "default": "./dist/utils/index.js"
41
41
  },
42
42
  "./clock": {
43
43
  "types": "./dist/clock/index.d.ts",
@@ -91,7 +91,6 @@
91
91
  "files": [
92
92
  "src/**/*",
93
93
  "!src/**/*.test.ts",
94
- "!src/**/*.test.js",
95
94
  "!src/**/fixtures/**",
96
95
  "dist/**/*",
97
96
  "public/**/*",
@@ -111,21 +110,19 @@
111
110
  "author": "Plantasia Space",
112
111
  "license": "MIT",
113
112
  "peerDependencies": {
114
- "three": "^0.166.1",
115
- "three-stdlib": "^2.34.0"
113
+ "three": "^0.166.1"
116
114
  },
117
115
  "peerDependenciesMeta": {
118
116
  "three": {
119
117
  "optional": true
120
- },
121
- "three-stdlib": {
122
- "optional": true
123
118
  }
124
119
  },
125
120
  "devDependencies": {
126
121
  "@eslint/js": "^10.0.1",
127
122
  "@types/node": "^20.19.0",
128
123
  "@types/three": "^0.166.0",
124
+ "@typescript-eslint/eslint-plugin": "^8.72.1",
125
+ "@typescript-eslint/parser": "^8.72.1",
129
126
  "eslint": "^10.0.0",
130
127
  "globals": "^17.3.0",
131
128
  "three": "^0.166.1",
@@ -144,21 +141,14 @@
144
141
  "scripts": {
145
142
  "lint": "eslint .",
146
143
  "lint:strict": "eslint . --max-warnings=0",
147
- "lint:fallback": "node scripts/lint.js",
148
- "build:ts": "tsc -p tsconfig.build.json && tsc -p tsconfig.types.json",
149
- "build:clock": "npm run build:ts",
150
- "prepare": "npm run build:ts",
144
+ "build": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
145
+ "build:ts": "npm run build",
146
+ "prepare": "npm run build",
151
147
  "typecheck": "tsc -p tsconfig.json --noEmit",
152
- "test:clock": "node --test --import tsx src/clock/*.test.ts",
153
- "test:notes": "node --test --import tsx src/notes/*.test.ts",
154
- "test:faust": "node --test --import tsx src/faust/*.test.ts",
155
- "test:studio": "node --test --import tsx src/studio/*.test.js",
156
- "test:uploads": "node --test --import tsx src/uploads/*.test.js",
157
- "test:audio": "node --test --import tsx src/audio/*.test.ts",
158
- "test:render": "node --test src/render/*.test.js",
159
- "test": "npm run test:clock && npm run test:notes && npm run test:faust && npm run test:studio && npm run test:uploads && npm run test:audio && npm run test:render && npm run test:moons && npm run test:star",
160
- "test:moons": "node --test src/moons/*.test.js",
161
- "test:star": "node --test --import tsx src/star/*.test.ts",
148
+ "test": "node --test --import tsx $(find src -name '*.test.ts' | sort)",
149
+ "baseline:update": "tsx scripts/api-baseline.ts update",
150
+ "baseline:release": "tsx scripts/api-baseline.ts release",
151
+ "check:exports": "tsx scripts/check-exports.ts",
162
152
  "bench:star:build": "esbuild bench/star/main.ts --bundle --format=esm --target=es2022 --outfile=bench/star/build/main.js --log-level=warning",
163
153
  "bench:star": "npm run bench:star:build && tsx bench/star/run.ts"
164
154
  }
@@ -77,6 +77,19 @@ export interface TransportOptions {
77
77
  lookaheadSec?: number;
78
78
  }
79
79
 
80
+ function isValidRate(rate: number): boolean {
81
+ return Number.isFinite(rate) && rate > 0;
82
+ }
83
+
84
+ function isValidAnchoredState(s: AnchoredState): boolean {
85
+ if (s.playState !== 'playing' && s.playState !== 'paused') return false;
86
+ if (!Number.isFinite(s.positionSec)) return false;
87
+ if (s.anchorAudioSec != null && !Number.isFinite(s.anchorAudioSec)) return false;
88
+ if (s.rate != null && !isValidRate(s.rate)) return false;
89
+ if (s.loop && !(Number.isFinite(s.loop.startSec) && Number.isFinite(s.loop.endSec))) return false;
90
+ return true;
91
+ }
92
+
80
93
  export class Transport {
81
94
  backend: ClockBackend;
82
95
  lookaheadSec: number;
@@ -132,12 +145,13 @@ export class Transport {
132
145
  this._cancelArmed(); // armed future audio must not sound during pause; re-armed on resume
133
146
  }
134
147
  seekSec(sec: number): void {
148
+ if (!Number.isFinite(sec)) return; // a NaN seek is ignored, never freezes the transport
135
149
  this._reanchor(Math.max(0, sec));
136
150
  this._cancelArmed();
137
151
  this._reopenEvents(); // a seek MOVES position -> fired events may replay
138
152
  }
139
153
  setRate(rate: number): void {
140
- if (rate <= 0) throw new Error('rate must be > 0');
154
+ if (!isValidRate(rate)) throw new Error('rate must be a finite number > 0');
141
155
  this._reanchor();
142
156
  this._rate = rate;
143
157
  this._cancelArmed(); // window/audio-time changed -> re-arm pending events (NOT replay fired ones)
@@ -149,6 +163,7 @@ export class Transport {
149
163
  return this._playing;
150
164
  }
151
165
  setLoopSec(loop: LoopSec | null): void {
166
+ if (loop && !(Number.isFinite(loop.startSec) && Number.isFinite(loop.endSec))) return; // ignored
152
167
  this._loop = loop ? { startSec: loop.startSec, endSec: loop.endSec } : null;
153
168
  this._cancelArmed();
154
169
  this._reopenEvents(); // loop topology changed -> events may re-enter/leave the loop window
@@ -218,6 +233,10 @@ export class Transport {
218
233
  if (d < -EPS) d += len; // wrapped into the next pass
219
234
  return d;
220
235
  }
236
+ // At or past the loop end: the transport wraps back to startSec before getting there, so the
237
+ // event never occurs while looping. Without this, its look-ahead distance came out positive just
238
+ // before the wrap and it armed (and sounded) at the wrap instant, at a position never reached.
239
+ if (len > 0 && ev.atSec >= endSec - EPS) return null;
221
240
  }
222
241
  const d = ev.atSec - pos;
223
242
  return d >= -EPS ? d : null; // non-loop: a passed event won't fire again until a seek
@@ -232,7 +251,13 @@ export class Transport {
232
251
  }
233
252
 
234
253
  // ---- sync apply (Inc 3) -------------------------------------------------------------
235
- applyAnchoredState(s: AnchoredState): void {
254
+ /**
255
+ * Apply a peer's anchored state. A malformed one (unknown playState, non-finite position, anchor or
256
+ * loop bounds, or a rate that is not a finite number > 0) is DROPPED whole and `false` is returned: the transport keeps its last
257
+ * good state until the next good one arrives. It never throws — this runs inside `tick()`.
258
+ */
259
+ applyAnchoredState(s: AnchoredState): boolean {
260
+ if (!isValidAnchoredState(s)) return false;
236
261
  this._playing = s.playState === 'playing';
237
262
  if (s.rate != null) this._rate = s.rate;
238
263
  this._anchorPos = s.positionSec;
@@ -240,9 +265,13 @@ export class Transport {
240
265
  if (s.loop !== undefined) this._loop = s.loop ? { ...s.loop } : null;
241
266
  this._cancelArmed();
242
267
  this._reopenEvents(); // anchored apply repositions the timeline -> events re-evaluate
268
+ return true;
243
269
  }
244
270
  /** Arm an anchored state (with optional control) to apply when audio time reaches `atAudioSec`. */
245
- scheduleStateChange(atAudioSec: number, state: AnchoredState, meta: ScheduleStateMeta = {}): void {
271
+ /** Returns `false` (and arms nothing) for a non-finite `atAudioSec` or a malformed state. */
272
+ scheduleStateChange(atAudioSec: number, state: AnchoredState, meta: ScheduleStateMeta = {}): boolean {
273
+ // checked at intake too: a NaN time would never drain and would block every later change
274
+ if (!Number.isFinite(atAudioSec) || !isValidAnchoredState(state)) return false;
246
275
  this._pending.push({
247
276
  atAudioSec,
248
277
  state,
@@ -251,6 +280,7 @@ export class Transport {
251
280
  onApply: meta.onApply ?? null,
252
281
  });
253
282
  this._pending.sort((a, b) => a.atAudioSec - b.atAudioSec);
283
+ return true;
254
284
  }
255
285
  /** Drop pending changes older than a freshly-applied (term,seq) — supersession (Codex fix #4). */
256
286
  dropPendingOlderThan(term: number, seq: number): void {
@@ -260,8 +290,9 @@ export class Transport {
260
290
  const now = this.backend.nowSec();
261
291
  while (this._pending.length && this._pending[0].atAudioSec <= now + EPS) {
262
292
  const p = this._pending.shift()!;
263
- this.applyAnchoredState({ ...p.state, anchorAudioSec: p.state.anchorAudioSec ?? now });
264
- if (p.onApply) p.onApply(); // control rides WITH the state, applied atomically here (fix #5)
293
+ const applied = this.applyAnchoredState({ ...p.state, anchorAudioSec: p.state.anchorAudioSec ?? now });
294
+ // control rides WITH the state, applied atomically here (fix #5) — a dropped state drops its control too
295
+ if (applied && p.onApply) p.onApply();
265
296
  }
266
297
  }
267
298
 
@@ -312,7 +343,7 @@ export class Transport {
312
343
  if (!this._loop) return;
313
344
  const { startSec, endSec } = this._loop;
314
345
  const len = endSec - startSec;
315
- if (len <= 0) return;
346
+ if (!(len > 0)) return; // also catches NaN
316
347
  const pos = this.positionSec();
317
348
  if (pos < endSec - EPS) return;
318
349
  const overshoot = (pos - startSec) % len;
@@ -58,9 +58,13 @@ export class MusicalProjection {
58
58
  constructor(spec: MusicalProjectionSpec = {}) {
59
59
  this.ppq = spec.ppq ?? DEFAULT_PPQ;
60
60
 
61
- const tempo = (spec.tempoMap && spec.tempoMap.length ? spec.tempoMap : [{ startSec: 0, bpm: 120 }])
62
- .slice()
63
- .sort((a, b) => a.startSec - b.startSec);
61
+ // An entry with bpm <= 0, a NaN bpm or a non-finite start would make beat <-> seconds return
62
+ // Infinity/NaN — skip it. If nothing usable is left, fall back to the same 120 BPM default as an
63
+ // empty map.
64
+ const usable = (spec.tempoMap ?? []).filter(
65
+ (e) => Number.isFinite(e.startSec) && Number.isFinite(e.bpm) && e.bpm > 0,
66
+ );
67
+ const tempo = (usable.length ? usable : [{ startSec: 0, bpm: 120 }]).sort((a, b) => a.startSec - b.startSec);
64
68
  // precompute the cumulative beat position at each tempo segment boundary
65
69
  this._tempo = tempo.map((s) => ({ ...s, startBeat: 0 }));
66
70
  for (let i = 1; i < this._tempo.length; i++) {
@@ -68,11 +72,17 @@ export class MusicalProjection {
68
72
  this._tempo[i].startBeat = prev.startBeat + (this._tempo[i].startSec - prev.startSec) * (prev.bpm / 60);
69
73
  }
70
74
 
71
- const sig = (spec.signatureMap && spec.signatureMap.length
72
- ? spec.signatureMap
73
- : [{ startBar: 0, numerator: 4, denominator: 4 }])
74
- .slice()
75
- .sort((a, b) => a.startBar - b.startBar);
75
+ // Same rule for signatures: a zero/negative/NaN numerator or denominator, or a non-finite start
76
+ // bar, would give Infinity/NaN bars — skip it, and fall back to 4/4 if nothing usable is left.
77
+ const usableSig = (spec.signatureMap ?? []).filter(
78
+ (e) =>
79
+ Number.isFinite(e.startBar) &&
80
+ Number.isFinite(e.numerator) &&
81
+ e.numerator > 0 &&
82
+ Number.isFinite(e.denominator) &&
83
+ e.denominator > 0,
84
+ );
85
+ const sig = (usableSig.length ? usableSig : [{ startBar: 0, numerator: 4, denominator: 4 }]).sort((a, b) => a.startBar - b.startBar);
76
86
  // precompute the cumulative beat position at each signature segment's startBar
77
87
  this._sig = sig.map((s) => ({ ...s, beatsPerBar: (s.numerator * 4) / s.denominator, startBeat: 0 }));
78
88
  for (let i = 1; i < this._sig.length; i++) {
package/src/clock/sync.ts CHANGED
@@ -72,6 +72,14 @@ export interface EstimateOffsetOpts {
72
72
  }
73
73
 
74
74
  const mod = (n: number, m: number): number => ((n % m) + m) % m;
75
+ /** A usable timeline: a finite tempo above zero and finite anchors. Anything else makes every beat
76
+ * <-> time conversion NaN or Infinity, for this peer and for every peer that adopts it. */
77
+ const isValidTimeline = (t: BeatTimelineState | null | undefined): t is BeatTimelineState =>
78
+ !!t &&
79
+ Number.isFinite(t.tempoBpm) &&
80
+ t.tempoBpm > 0 &&
81
+ Number.isFinite(t.anchorBeat) &&
82
+ Number.isFinite(t.anchorSessionMs);
75
83
  const newerTs = (a: TimelineStamp, b: TimelineStamp): boolean =>
76
84
  a.time > b.time || (a.time === b.time && a.id > b.id);
77
85
 
@@ -225,6 +233,8 @@ export class BeatTimeline implements RelayNode {
225
233
 
226
234
  // ---- timeline mutations (all leaderless: broadcast + last-writer-wins) ---------------
227
235
  private _commit(timeline: BeatTimelineState): void {
236
+ // Validated before it is broadcast: last-writer-wins would hand a broken timeline to every peer.
237
+ if (!isValidTimeline(timeline)) throw new Error('beat timeline needs a finite tempo > 0 and finite anchors');
228
238
  this.timeline = { ...timeline };
229
239
  this._ts = { time: this.sessionNowMs(), id: this.id };
230
240
  if (this.enabled)
@@ -269,7 +279,9 @@ export class BeatTimeline implements RelayNode {
269
279
  // ---- start/stop intent (opt-in; never adopted on join) ------------------------------
270
280
  setLocalPlaying(playing: boolean): void {
271
281
  this.localPlaying = playing;
272
- this.relay.send(this.id, { kind: 'beat:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
282
+ // Solo (`enabled` false) broadcasts nothing, as with the timeline and hello messages.
283
+ if (this.enabled)
284
+ this.relay.send(this.id, { kind: 'beat:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
273
285
  if (this.onChange) this.onChange();
274
286
  }
275
287
 
@@ -289,7 +301,8 @@ export class BeatTimeline implements RelayNode {
289
301
  break;
290
302
  case 'beat:timeline':
291
303
  if (msg.id) this.peers.add(msg.id);
292
- if (newerTs(msg.ts, this._ts)) {
304
+ // A malformed timeline (tempo 0, NaN anchor, from a buggy or older peer) is never adopted.
305
+ if (isValidTimeline(msg.timeline) && msg.ts && newerTs(msg.ts, this._ts)) {
293
306
  this.timeline = { ...msg.timeline };
294
307
  this._ts = msg.ts;
295
308
  }