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.
- package/README.md +3 -2
- package/dist/clock/Transport.d.ts +8 -2
- package/dist/clock/Transport.js +46 -7
- package/dist/clock/musical.js +13 -8
- package/dist/clock/sync.js +15 -2
- package/dist/config/env.d.ts +8 -0
- package/dist/config/env.js +36 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.js +4 -0
- package/dist/config/textureConfig.d.ts +140 -0
- package/dist/config/textureConfig.js +318 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +3 -0
- package/dist/core/lightFresnel.d.ts +6 -0
- package/dist/core/lightFresnel.js +63 -0
- package/dist/core/lights.d.ts +48 -0
- package/dist/core/lights.js +79 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/loaders/bodyTextureLoader.d.ts +10 -0
- package/dist/loaders/bodyTextureLoader.js +28 -0
- package/dist/loaders/cubeMapLoader.d.ts +58 -0
- package/dist/loaders/cubeMapLoader.js +291 -0
- package/dist/loaders/glbLoader.d.ts +24 -0
- package/dist/loaders/glbLoader.js +85 -0
- package/dist/loaders/index.d.ts +5 -0
- package/{src → dist}/loaders/index.js +0 -2
- package/dist/{types/loaders → loaders}/ktx2.d.ts +4 -6
- package/dist/loaders/ktx2.js +48 -0
- package/dist/moons/index.d.ts +3 -0
- package/dist/moons/index.js +3 -0
- package/dist/moons/moonsGeneration.d.ts +64 -0
- package/dist/moons/moonsGeneration.js +238 -0
- package/dist/moons/moonsManager.d.ts +43 -0
- package/dist/moons/moonsManager.js +93 -0
- package/dist/{types/render → render}/frameBudget.d.ts +18 -17
- package/dist/render/frameBudget.js +42 -0
- package/dist/render/index.d.ts +4 -0
- package/dist/render/viewportCompositor.d.ts +124 -0
- package/dist/render/viewportCompositor.js +643 -0
- package/dist/studio/headerBroadcast.d.ts +10 -1
- package/dist/studio/hostTransport.d.ts +2 -2
- package/dist/studio/index.d.ts +1 -1
- package/dist/utils/dispose.d.ts +5 -0
- package/dist/utils/dispose.js +50 -0
- package/dist/utils/index.d.ts +3 -0
- package/{src → dist}/utils/index.js +1 -1
- package/dist/utils/materials.d.ts +10 -0
- package/dist/utils/materials.js +36 -0
- package/dist/world/cloudShell.d.ts +16 -0
- package/dist/world/cloudShell.js +73 -0
- package/dist/world/index.d.ts +6 -0
- package/{src → dist}/world/index.js +0 -2
- package/dist/world/worldGLB.d.ts +17 -0
- package/dist/world/worldGLB.js +22 -0
- package/dist/world/worldManager.d.ts +57 -0
- package/dist/world/worldManager.js +125 -0
- package/dist/world/worldNormal.d.ts +30 -0
- package/dist/world/worldNormal.js +58 -0
- package/dist/world/worldTexture.d.ts +56 -0
- package/dist/world/worldTexture.js +299 -0
- package/package.json +29 -39
- package/src/clock/Transport.ts +37 -6
- package/src/clock/musical.ts +18 -8
- package/src/clock/sync.ts +15 -2
- package/src/config/env.ts +48 -0
- package/src/config/{index.js → index.ts} +7 -0
- package/src/config/{textureConfig.js → textureConfig.ts} +126 -60
- package/src/core/{lightFresnel.js → lightFresnel.ts} +1 -1
- package/src/core/{lights.js → lights.ts} +21 -21
- package/src/loaders/{bodyTextureLoader.js → bodyTextureLoader.ts} +10 -7
- package/src/loaders/{cubeMapLoader.js → cubeMapLoader.ts} +83 -49
- package/src/loaders/glbLoader.ts +110 -0
- package/src/loaders/index.ts +6 -0
- package/src/loaders/{ktx2.js → ktx2.ts} +5 -7
- package/src/moons/{index.js → index.ts} +1 -0
- package/src/moons/{moonsGeneration.js → moonsGeneration.ts} +45 -32
- package/src/moons/{moonsManager.js → moonsManager.ts} +37 -11
- package/src/render/{frameBudget.js → frameBudget.ts} +15 -8
- package/src/render/index.ts +14 -0
- package/src/render/{viewportCompositor.js → viewportCompositor.ts} +121 -59
- package/src/studio/headerBroadcast.ts +14 -1
- package/src/studio/hostTransport.ts +2 -1
- package/src/studio/index.ts +1 -1
- package/src/utils/dispose.ts +47 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/materials.ts +44 -0
- package/src/world/{cloudShell.js → cloudShell.ts} +19 -12
- package/src/world/index.ts +7 -0
- package/src/world/worldGLB.ts +41 -0
- package/src/world/{worldManager.js → worldManager.ts} +67 -32
- package/src/world/{worldNormal.js → worldNormal.ts} +25 -20
- package/src/world/{worldTexture.js → worldTexture.ts} +70 -51
- package/dist/types/config/env.d.ts +0 -12
- package/dist/types/config/index.d.ts +0 -2
- package/dist/types/config/textureConfig.d.ts +0 -121
- package/dist/types/core/index.d.ts +0 -2
- package/dist/types/core/lightFresnel.d.ts +0 -5
- package/dist/types/core/lights.d.ts +0 -58
- package/dist/types/index.d.ts +0 -6
- package/dist/types/loaders/bodyTextureLoader.d.ts +0 -12
- package/dist/types/loaders/cubeMapLoader.d.ts +0 -70
- package/dist/types/loaders/glbLoader.d.ts +0 -24
- package/dist/types/loaders/index.d.ts +0 -5
- package/dist/types/moons/index.d.ts +0 -2
- package/dist/types/moons/moonsGeneration.d.ts +0 -72
- package/dist/types/moons/moonsManager.d.ts +0 -27
- package/dist/types/render/index.d.ts +0 -2
- package/dist/types/render/viewportCompositor.d.ts +0 -58
- package/dist/types/utils/dispose.d.ts +0 -2
- package/dist/types/utils/index.d.ts +0 -2
- package/dist/types/utils/materials.d.ts +0 -2
- package/dist/types/world/cloudShell.d.ts +0 -25
- package/dist/types/world/index.d.ts +0 -6
- package/dist/types/world/worldGLB.d.ts +0 -20
- package/dist/types/world/worldManager.d.ts +0 -63
- package/dist/types/world/worldNormal.d.ts +0 -43
- package/dist/types/world/worldTexture.d.ts +0 -80
- package/src/config/env.js +0 -38
- package/src/loaders/glbLoader.js +0 -78
- package/src/utils/dispose.js +0 -39
- package/src/utils/materials.js +0 -31
- package/src/world/worldGLB.js +0 -19
- /package/{src → dist}/render/index.js +0 -0
- /package/src/core/{index.js → index.ts} +0 -0
- /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
|
+
"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": "
|
|
7
|
-
"types": "./dist/
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
8
|
"sideEffects": false,
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
|
-
"types": "./dist/
|
|
12
|
-
"default": "./
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
13
|
},
|
|
14
14
|
"./world": {
|
|
15
|
-
"types": "./dist/
|
|
16
|
-
"default": "./
|
|
15
|
+
"types": "./dist/world/index.d.ts",
|
|
16
|
+
"default": "./dist/world/index.js"
|
|
17
17
|
},
|
|
18
18
|
"./moons": {
|
|
19
|
-
"types": "./dist/
|
|
20
|
-
"default": "./
|
|
19
|
+
"types": "./dist/moons/index.d.ts",
|
|
20
|
+
"default": "./dist/moons/index.js"
|
|
21
21
|
},
|
|
22
22
|
"./loaders": {
|
|
23
|
-
"types": "./dist/
|
|
24
|
-
"default": "./
|
|
23
|
+
"types": "./dist/loaders/index.d.ts",
|
|
24
|
+
"default": "./dist/loaders/index.js"
|
|
25
25
|
},
|
|
26
26
|
"./render": {
|
|
27
|
-
"types": "./dist/
|
|
28
|
-
"default": "./
|
|
27
|
+
"types": "./dist/render/index.d.ts",
|
|
28
|
+
"default": "./dist/render/index.js"
|
|
29
29
|
},
|
|
30
30
|
"./core": {
|
|
31
|
-
"types": "./dist/
|
|
32
|
-
"default": "./
|
|
31
|
+
"types": "./dist/core/index.d.ts",
|
|
32
|
+
"default": "./dist/core/index.js"
|
|
33
33
|
},
|
|
34
34
|
"./config": {
|
|
35
|
-
"types": "./dist/
|
|
36
|
-
"default": "./
|
|
35
|
+
"types": "./dist/config/index.d.ts",
|
|
36
|
+
"default": "./dist/config/index.js"
|
|
37
37
|
},
|
|
38
38
|
"./utils": {
|
|
39
|
-
"types": "./dist/
|
|
40
|
-
"default": "./
|
|
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
|
-
"
|
|
148
|
-
"build:ts": "
|
|
149
|
-
"
|
|
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
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
"
|
|
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
|
}
|
package/src/clock/Transport.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/src/clock/musical.ts
CHANGED
|
@@ -58,9 +58,13 @@ export class MusicalProjection {
|
|
|
58
58
|
constructor(spec: MusicalProjectionSpec = {}) {
|
|
59
59
|
this.ppq = spec.ppq ?? DEFAULT_PPQ;
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|