entangled-worlds-orbiters-shared 2.6.0 → 2.8.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.
@@ -14,6 +14,16 @@ export interface SyncSettingsLike {
14
14
  sourceTimeSec?: unknown;
15
15
  } | null | undefined> | null;
16
16
  gridStartTimeSec?: unknown;
17
+ /** Whether this track time-stretches onto the grid. A BOOLEAN, so `false` is an answer rather than
18
+ * an absence — see `resolveWarpEnabledFromTrackData`. */
19
+ warpEnabled?: unknown;
20
+ /** Whether this track plays looped. Same boolean rule as `warpEnabled`, and the same three states:
21
+ * on, off, and nobody-said — see `resolveLoopEnabledFromTrackData`. */
22
+ loopEnabled?: unknown;
23
+ /** The loop region in SECONDS (storage's time unit; the audio engine works in ms and converts at
24
+ * its edge). Only ever meaningful as a pair — see `resolveLoopRangeSecFromTrackData`. */
25
+ loopStartSec?: unknown;
26
+ loopEndSec?: unknown;
17
27
  }
18
28
  export interface TrackMetadataLike {
19
29
  trackBpm?: unknown;
@@ -71,3 +81,33 @@ export declare function resolveTrackBpmFromTrackData(trackData?: TrackDataLike |
71
81
  * Returns null when nothing resolves (consumers default to 0).
72
82
  */
73
83
  export declare function resolvePrimaryGridMarkerTimeSecFromTrackData(trackData?: TrackDataLike | null): number | null;
84
+ /**
85
+ * Resolve whether this track should warp (time-stretch onto the grid) for the viewer.
86
+ *
87
+ * Returns null when nothing anywhere expressed a preference. Null means "no opinion", NOT "off" —
88
+ * the caller supplies its own default, because only the caller knows its runtime constraints (in
89
+ * orbiters, a deck with no tempo cannot warp at all, whatever was saved).
90
+ */
91
+ export declare function resolveWarpEnabledFromTrackData(trackData?: TrackDataLike | null): boolean | null;
92
+ /**
93
+ * Resolve whether this track should play looped for the viewer.
94
+ *
95
+ * Same three states as warp — on, off, nobody-said — but no smart default anywhere behind it: the
96
+ * server stops at the owner's setting, so null here is genuinely common and simply means the caller
97
+ * keeps whatever looping default it already had.
98
+ */
99
+ export declare function resolveLoopEnabledFromTrackData(trackData?: TrackDataLike | null): boolean | null;
100
+ /**
101
+ * Resolve the saved loop region, in seconds, or null when no layer holds a complete one.
102
+ *
103
+ * Resolved as a PAIR, never edge by edge: a start borrowed from one layer and an end from another
104
+ * describes a loop nobody set, and can invert. `start` of 0 is a real value, so the guard is a
105
+ * finiteness test rather than a truthiness one.
106
+ *
107
+ * The caller must still clamp this against the audio's real duration before using it — a region
108
+ * saved against an earlier version of the track can outlive the audio it was drawn on.
109
+ */
110
+ export declare function resolveLoopRangeSecFromTrackData(trackData?: TrackDataLike | null): {
111
+ startSec: number;
112
+ endSec: number;
113
+ } | null;
@@ -20,6 +20,22 @@ function firstResolvedValue(candidates, parser) {
20
20
  }
21
21
  return null;
22
22
  }
23
+ /**
24
+ * The first candidate that is an actual boolean, or null when none of them is.
25
+ *
26
+ * Deliberately separate from `firstResolvedValue`: that one folds "absent" and "invalid" together
27
+ * and returns a number, which is exactly the wrong shape here. A warp preference has THREE states —
28
+ * on, off, and nobody-said — and `false` is the middle one, not the last. Any `||`, any truthiness
29
+ * test, any `?? ` against a default collapses off into nobody-said and loses the user's choice.
30
+ */
31
+ function firstBoolean(candidates) {
32
+ for (const candidate of candidates) {
33
+ if (typeof candidate === 'boolean') {
34
+ return candidate;
35
+ }
36
+ }
37
+ return null;
38
+ }
23
39
  /**
24
40
  * Resolve the effective track BPM. Precedence (first finite, > 0 wins):
25
41
  * owner `trackBpm` → owner `effectiveSettings.sync.trackBpm` → per-user `trackUserSettings.sync.trackBpm`
@@ -64,3 +80,71 @@ export function resolvePrimaryGridMarkerTimeSecFromTrackData(trackData = null) {
64
80
  trackData?.metadata?.wrap?.gridStartTimeSec,
65
81
  ], toNonNegativeNumber);
66
82
  }
83
+ /**
84
+ * The shared walk behind the boolean preferences below.
85
+ *
86
+ * The server already applied the real precedence — the viewer's own saved setting, then the owner's,
87
+ * then (for warp) a smart default — and publishes the answer at `effectiveSettings.sync`. So that is
88
+ * read FIRST: it is the resolved value, not a competing opinion. The per-user path below it covers
89
+ * the shapes where the server has not resolved anything (a raw `trackUserSettings` fetch that has not
90
+ * been merged into a track payload yet).
91
+ *
92
+ * One walk, not one per field: warp and loop differ in what they MEAN, never in where they are found,
93
+ * and two hand-maintained copies of this list would drift the moment a payload shape changed.
94
+ */
95
+ function resolveSyncFlagFromTrackData(trackData, field) {
96
+ return firstBoolean([
97
+ trackData?.track?.metadata?.effectiveSettings?.sync?.[field],
98
+ trackData?.release?.metadata?.effectiveSettings?.sync?.[field],
99
+ trackData?.metadata?.effectiveSettings?.sync?.[field],
100
+ trackData?.effectiveSettings?.sync?.[field],
101
+ trackData?.trackUserSettings?.sync?.[field],
102
+ ]);
103
+ }
104
+ /**
105
+ * Resolve whether this track should warp (time-stretch onto the grid) for the viewer.
106
+ *
107
+ * Returns null when nothing anywhere expressed a preference. Null means "no opinion", NOT "off" —
108
+ * the caller supplies its own default, because only the caller knows its runtime constraints (in
109
+ * orbiters, a deck with no tempo cannot warp at all, whatever was saved).
110
+ */
111
+ export function resolveWarpEnabledFromTrackData(trackData = null) {
112
+ return resolveSyncFlagFromTrackData(trackData, 'warpEnabled');
113
+ }
114
+ /**
115
+ * Resolve whether this track should play looped for the viewer.
116
+ *
117
+ * Same three states as warp — on, off, nobody-said — but no smart default anywhere behind it: the
118
+ * server stops at the owner's setting, so null here is genuinely common and simply means the caller
119
+ * keeps whatever looping default it already had.
120
+ */
121
+ export function resolveLoopEnabledFromTrackData(trackData = null) {
122
+ return resolveSyncFlagFromTrackData(trackData, 'loopEnabled');
123
+ }
124
+ /**
125
+ * Resolve the saved loop region, in seconds, or null when no layer holds a complete one.
126
+ *
127
+ * Resolved as a PAIR, never edge by edge: a start borrowed from one layer and an end from another
128
+ * describes a loop nobody set, and can invert. `start` of 0 is a real value, so the guard is a
129
+ * finiteness test rather than a truthiness one.
130
+ *
131
+ * The caller must still clamp this against the audio's real duration before using it — a region
132
+ * saved against an earlier version of the track can outlive the audio it was drawn on.
133
+ */
134
+ export function resolveLoopRangeSecFromTrackData(trackData = null) {
135
+ const candidates = [
136
+ trackData?.track?.metadata?.effectiveSettings?.sync,
137
+ trackData?.release?.metadata?.effectiveSettings?.sync,
138
+ trackData?.metadata?.effectiveSettings?.sync,
139
+ trackData?.effectiveSettings?.sync,
140
+ trackData?.trackUserSettings?.sync,
141
+ ];
142
+ for (const candidate of candidates) {
143
+ const startSec = toNonNegativeNumber(candidate?.loopStartSec);
144
+ const endSec = toNonNegativeNumber(candidate?.loopEndSec);
145
+ if (startSec !== null && endSec !== null && endSec > startSec) {
146
+ return { startSec, endSec };
147
+ }
148
+ }
149
+ return null;
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,7 +8,6 @@
8
8
  "exports": {
9
9
  ".": "./src/index.js",
10
10
  "./world": "./src/world/index.js",
11
- "./minimal": "./src/minimal/index.js",
12
11
  "./moons": "./src/moons/index.js",
13
12
  "./loaders": "./src/loaders/index.js",
14
13
  "./render": "./src/render/index.js",
@@ -92,8 +91,7 @@
92
91
  "typecheck": "tsc -p tsconfig.json --noEmit",
93
92
  "test:clock": "node --test --import tsx src/clock/*.test.ts",
94
93
  "test:render": "node --test src/render/*.test.js",
95
- "test:minimal": "node --test src/minimal/*.test.js",
96
- "test": "npm run test:clock && npm run test:render && npm run test:minimal && npm run test:moons",
94
+ "test": "npm run test:clock && npm run test:render && npm run test:moons",
97
95
  "test:moons": "node --test src/moons/*.test.js"
98
96
  }
99
97
  }
@@ -18,6 +18,16 @@ export interface SyncSettingsLike {
18
18
  primaryGridMarkerTimeSec?: unknown;
19
19
  gridMarkers?: ReadonlyArray<{ sourceTimeSec?: unknown } | null | undefined> | null;
20
20
  gridStartTimeSec?: unknown;
21
+ /** Whether this track time-stretches onto the grid. A BOOLEAN, so `false` is an answer rather than
22
+ * an absence — see `resolveWarpEnabledFromTrackData`. */
23
+ warpEnabled?: unknown;
24
+ /** Whether this track plays looped. Same boolean rule as `warpEnabled`, and the same three states:
25
+ * on, off, and nobody-said — see `resolveLoopEnabledFromTrackData`. */
26
+ loopEnabled?: unknown;
27
+ /** The loop region in SECONDS (storage's time unit; the audio engine works in ms and converts at
28
+ * its edge). Only ever meaningful as a pair — see `resolveLoopRangeSecFromTrackData`. */
29
+ loopStartSec?: unknown;
30
+ loopEndSec?: unknown;
21
31
  }
22
32
 
23
33
  export interface TrackMetadataLike {
@@ -72,6 +82,23 @@ function firstResolvedValue(
72
82
  return null;
73
83
  }
74
84
 
85
+ /**
86
+ * The first candidate that is an actual boolean, or null when none of them is.
87
+ *
88
+ * Deliberately separate from `firstResolvedValue`: that one folds "absent" and "invalid" together
89
+ * and returns a number, which is exactly the wrong shape here. A warp preference has THREE states —
90
+ * on, off, and nobody-said — and `false` is the middle one, not the last. Any `||`, any truthiness
91
+ * test, any `?? ` against a default collapses off into nobody-said and loses the user's choice.
92
+ */
93
+ function firstBoolean(candidates: ReadonlyArray<unknown>): boolean | null {
94
+ for (const candidate of candidates) {
95
+ if (typeof candidate === 'boolean') {
96
+ return candidate;
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
75
102
  /**
76
103
  * Resolve the effective track BPM. Precedence (first finite, > 0 wins):
77
104
  * owner `trackBpm` → owner `effectiveSettings.sync.trackBpm` → per-user `trackUserSettings.sync.trackBpm`
@@ -125,3 +152,85 @@ export function resolvePrimaryGridMarkerTimeSecFromTrackData(
125
152
  toNonNegativeNumber,
126
153
  );
127
154
  }
155
+
156
+ /**
157
+ * The shared walk behind the boolean preferences below.
158
+ *
159
+ * The server already applied the real precedence — the viewer's own saved setting, then the owner's,
160
+ * then (for warp) a smart default — and publishes the answer at `effectiveSettings.sync`. So that is
161
+ * read FIRST: it is the resolved value, not a competing opinion. The per-user path below it covers
162
+ * the shapes where the server has not resolved anything (a raw `trackUserSettings` fetch that has not
163
+ * been merged into a track payload yet).
164
+ *
165
+ * One walk, not one per field: warp and loop differ in what they MEAN, never in where they are found,
166
+ * and two hand-maintained copies of this list would drift the moment a payload shape changed.
167
+ */
168
+ function resolveSyncFlagFromTrackData(
169
+ trackData: TrackDataLike | null,
170
+ field: 'warpEnabled' | 'loopEnabled',
171
+ ): boolean | null {
172
+ return firstBoolean([
173
+ trackData?.track?.metadata?.effectiveSettings?.sync?.[field],
174
+ trackData?.release?.metadata?.effectiveSettings?.sync?.[field],
175
+ trackData?.metadata?.effectiveSettings?.sync?.[field],
176
+ trackData?.effectiveSettings?.sync?.[field],
177
+ trackData?.trackUserSettings?.sync?.[field],
178
+ ]);
179
+ }
180
+
181
+ /**
182
+ * Resolve whether this track should warp (time-stretch onto the grid) for the viewer.
183
+ *
184
+ * Returns null when nothing anywhere expressed a preference. Null means "no opinion", NOT "off" —
185
+ * the caller supplies its own default, because only the caller knows its runtime constraints (in
186
+ * orbiters, a deck with no tempo cannot warp at all, whatever was saved).
187
+ */
188
+ export function resolveWarpEnabledFromTrackData(
189
+ trackData: TrackDataLike | null = null,
190
+ ): boolean | null {
191
+ return resolveSyncFlagFromTrackData(trackData, 'warpEnabled');
192
+ }
193
+
194
+ /**
195
+ * Resolve whether this track should play looped for the viewer.
196
+ *
197
+ * Same three states as warp — on, off, nobody-said — but no smart default anywhere behind it: the
198
+ * server stops at the owner's setting, so null here is genuinely common and simply means the caller
199
+ * keeps whatever looping default it already had.
200
+ */
201
+ export function resolveLoopEnabledFromTrackData(
202
+ trackData: TrackDataLike | null = null,
203
+ ): boolean | null {
204
+ return resolveSyncFlagFromTrackData(trackData, 'loopEnabled');
205
+ }
206
+
207
+ /**
208
+ * Resolve the saved loop region, in seconds, or null when no layer holds a complete one.
209
+ *
210
+ * Resolved as a PAIR, never edge by edge: a start borrowed from one layer and an end from another
211
+ * describes a loop nobody set, and can invert. `start` of 0 is a real value, so the guard is a
212
+ * finiteness test rather than a truthiness one.
213
+ *
214
+ * The caller must still clamp this against the audio's real duration before using it — a region
215
+ * saved against an earlier version of the track can outlive the audio it was drawn on.
216
+ */
217
+ export function resolveLoopRangeSecFromTrackData(
218
+ trackData: TrackDataLike | null = null,
219
+ ): { startSec: number; endSec: number } | null {
220
+ const candidates: Array<SyncSettingsLike | null | undefined> = [
221
+ trackData?.track?.metadata?.effectiveSettings?.sync,
222
+ trackData?.release?.metadata?.effectiveSettings?.sync,
223
+ trackData?.metadata?.effectiveSettings?.sync,
224
+ trackData?.effectiveSettings?.sync,
225
+ trackData?.trackUserSettings?.sync,
226
+ ];
227
+
228
+ for (const candidate of candidates) {
229
+ const startSec = toNonNegativeNumber(candidate?.loopStartSec);
230
+ const endSec = toNonNegativeNumber(candidate?.loopEndSec);
231
+ if (startSec !== null && endSec !== null && endSec > startSec) {
232
+ return { startSec, endSec };
233
+ }
234
+ }
235
+ return null;
236
+ }
@@ -1,26 +0,0 @@
1
- /**
2
- * The minimal ("performance mode") look: one cheap lit sphere, one key light, a weak ambient fill,
3
- * and nothing else — no texture, no halo, no cloud shell, no skybox.
4
- *
5
- * Its own entry point rather than a few more names on `./world`, because both consumers want exactly
6
- * this and nothing around it: the Orbiters renderer and the Entangled Worlds editor's performance
7
- * preview. Keeping it separate means importing the look never drags in the texture/GLB/cloud loaders
8
- * that the look exists to avoid.
9
- */
10
- export {
11
- createMinimalWorld,
12
- resolvePlanetColor,
13
- MINIMAL_WORLD_MODE,
14
- MINIMAL_MATERIAL_KINDS,
15
- MINIMAL_PLANET_COLOR_HEX,
16
- } from './minimalWorld.js';
17
-
18
- export {
19
- applyMinimalLighting,
20
- removeMinimalLighting,
21
- updateMinimalKeyLight,
22
- takeSceneLights,
23
- DEFAULT_LIGHT_NAMES,
24
- MINIMAL_LIGHT_NAME,
25
- MINIMAL_FILL_NAME,
26
- } from './minimalLighting.js';
@@ -1,137 +0,0 @@
1
- /**
2
- * @file src/minimal/minimalLighting.js
3
- * @description The one light the minimal look keeps.
4
- *
5
- * Shared for the same reason as `minimalWorld.js`, and more sharply: lighting is what makes two
6
- * renders of the same world look like the same world. The editor's performance preview and the
7
- * Orbiters render MUST take their key light, their fill and their restore behaviour from this one
8
- * module, or an author would tune a world against lighting the public never sees.
9
- *
10
- * Minimal is not an unlit scene. The moons are a lit material, so a scene with no lights renders them
11
- * as black discs, and the planet needs a light for its shape to be legible at all. What it does not
12
- * need is the world's usual pair — a hemisphere and an ambient, both white-on-white at full strength,
13
- * which light every surface identically and are themselves part of why a sphere reads as a flat disc.
14
- *
15
- * So: exactly one directional light, no shadows. Its colour and direction come from the world's own
16
- * authored lighting where the world provides them, which is how a minimal scene still carries some of
17
- * the world's identity without loading anything.
18
- */
19
- import * as THREE from 'three';
20
-
21
- /** The shared default lights, by the names the shared package gives them. */
22
- export const DEFAULT_LIGHT_NAMES = Object.freeze(['ew_HemisphereLight', 'ew_AmbientLight']);
23
- /** The single light the minimal look keeps. */
24
- export const MINIMAL_LIGHT_NAME = 'orb_MinimalKeyLight';
25
- /** The weak ambient that keeps the unlit side from going pure black. */
26
- export const MINIMAL_FILL_NAME = 'orb_MinimalFillLight';
27
-
28
- const DEFAULT_INTENSITY = 1.8;
29
- const DEFAULT_DIRECTION = Object.freeze({ x: 1, y: 0.6, z: 1 });
30
- /**
31
- * The fill. A single directional light alone puts the unlit side at pure black, which cuts the
32
- * silhouette in half instead of rounding it — the terminator reads as a hard edge rather than a
33
- * curve. A weak ambient lifts that side just enough for the shape to close.
34
- *
35
- * Ambient is the cheapest light there is: a constant added per fragment, no direction, no attenuation,
36
- * no extra pass, and no shadow map. It is the right way to soften this and the only one worth its cost.
37
- */
38
- const FILL_INTENSITY = 0.55;
39
-
40
- function removeLightByName(scene, name) {
41
- const light = scene.getObjectByName(name);
42
- if (!light) return;
43
- scene.remove(light);
44
- // Lights hold no geometry or texture, but they do hold a shadow map + camera once shadows have
45
- // been on, and `dispose` is what releases it.
46
- light.dispose?.();
47
- }
48
-
49
- /**
50
- * Detach the world's default lights and hand them back — WITHOUT disposing them.
51
- *
52
- * They carry the world's authored colour, direction and intensities, and nothing re-applies those
53
- * when the mode is switched off inside Studio. Keeping the objects means leaving minimal restores
54
- * exactly what the world had, rather than a generic pair at 1.0/1.0.
55
- *
56
- * @param {THREE.Scene} scene
57
- * @returns {THREE.Light[]} the detached lights, in the order they were found
58
- */
59
- export function takeSceneLights(scene) {
60
- if (!scene) return [];
61
- const taken = [];
62
- DEFAULT_LIGHT_NAMES.forEach((name) => {
63
- const light = scene.getObjectByName(name);
64
- if (!light) return;
65
- scene.remove(light);
66
- taken.push(light);
67
- });
68
- return taken;
69
- }
70
-
71
- /**
72
- * Add the key light plus a weak ambient fill. Idempotent. Two lights, both of the cheapest kinds,
73
- * and neither casting a shadow.
74
- *
75
- * The world's own lights are NOT removed here — that is `takeSceneLights`, so the caller decides
76
- * whether it is keeping them to put back.
77
- */
78
- export function applyMinimalLighting(scene) {
79
- if (!scene) return null;
80
-
81
- if (!scene.getObjectByName(MINIMAL_FILL_NAME)) {
82
- const fill = new THREE.AmbientLight(0xffffff, FILL_INTENSITY);
83
- fill.name = MINIMAL_FILL_NAME;
84
- scene.add(fill);
85
- }
86
-
87
- const existing = scene.getObjectByName(MINIMAL_LIGHT_NAME);
88
- if (existing) return existing;
89
- const key = new THREE.DirectionalLight(0xffffff, DEFAULT_INTENSITY);
90
- key.position.set(DEFAULT_DIRECTION.x, DEFAULT_DIRECTION.y, DEFAULT_DIRECTION.z);
91
- key.castShadow = false;
92
- key.name = MINIMAL_LIGHT_NAME;
93
- scene.add(key);
94
- return key;
95
- }
96
-
97
- export function removeMinimalLighting(scene) {
98
- if (!scene) return;
99
- removeLightByName(scene, MINIMAL_LIGHT_NAME);
100
- removeLightByName(scene, MINIMAL_FILL_NAME);
101
- }
102
-
103
- /**
104
- * Point the key light the way the world says its light points.
105
- *
106
- * DIRECTION only — the world's light COLOUR is deliberately not applied. Minimal is a monochrome
107
- * look, and a world that authored a strongly tinted light would drag the whole scene into that hue
108
- * and break the consistency the mode is for. Direction gives the shape somewhere to be lit from,
109
- * which is what actually varies usefully between worlds.
110
- *
111
- * A no-op when the key light is not in the scene, so a full-profile world load never has to check
112
- * which mode it is in.
113
- *
114
- * @param {THREE.Scene} scene
115
- * @param {{ lightDirection?: object, direction?: object,
116
- * diffuseIntensity?: number, diffuse?: number }} lighting
117
- */
118
- export function updateMinimalKeyLight(scene, lighting = {}) {
119
- const key = scene?.getObjectByName(MINIMAL_LIGHT_NAME);
120
- if (!key) return;
121
-
122
- const direction = lighting.lightDirection ?? lighting.direction;
123
- if (direction && typeof direction === 'object') {
124
- const x = Number.isFinite(direction.x) ? direction.x : DEFAULT_DIRECTION.x;
125
- const y = Number.isFinite(direction.y) ? direction.y : DEFAULT_DIRECTION.y;
126
- const z = Number.isFinite(direction.z) ? direction.z : DEFAULT_DIRECTION.z;
127
- // A direction of all zeros would put the light inside the planet and black the scene out.
128
- if (x !== 0 || y !== 0 || z !== 0) key.position.set(x, y, z);
129
- }
130
-
131
- const intensity = lighting.diffuseIntensity ?? lighting.diffuse;
132
- if (Number.isFinite(intensity) && intensity > 0) {
133
- // The world's intensities are authored against TWO lights at full strength; this is one, so it
134
- // keeps the default as a floor rather than dimming the only light in the scene.
135
- key.intensity = Math.max(DEFAULT_INTENSITY, intensity);
136
- }
137
- }
@@ -1,179 +0,0 @@
1
- /**
2
- * @file src/minimal/minimalWorld.js
3
- * @description The planet as performance mode draws it: one sphere, one cheap lit material, no
4
- * texture, no halo, no cloud shell.
5
- *
6
- * Shared, not Orbiters-local, because TWO surfaces draw this look: Orbiters renders it in
7
- * performance mode, and the Entangled Worlds editor previews it so an author can see what they are
8
- * authoring. If each kept its own copy the preview would drift from the render and start lying about
9
- * what the public will see — the one failure this module exists to prevent.
10
- *
11
- * Why this exists rather than reusing the shared normal-material sphere: `MeshNormalMaterial` colours
12
- * each pixel by the direction the surface faces, which is a gradient, not shading. It cannot respond
13
- * to a light at all — so however many lights the scene has, the planet reads as a flat disc while the
14
- * moons beside it (a lit material) read as spheres. A lit material is what makes the shape legible.
15
- *
16
- * The cost of that is close to nothing. Lambert is one dot product per fragment against a single
17
- * directional light — no specular, no roughness, no environment lookup, no texture fetch. What it
18
- * costs over the normal material is far less than what a texture, a halo or a cloud shell cost, all
19
- * of which this mode drops.
20
- *
21
- * `kind` exists so the options can be compared on real hardware without a rebuild
22
- * (`?minimalMaterial=lambert|phong|normal`), and so the choice is one line if a cheaper one wins.
23
- */
24
- import * as THREE from 'three';
25
-
26
- export const MINIMAL_WORLD_MODE = 90;
27
-
28
- /**
29
- * The minimal planet's colour when the world names none.
30
- *
31
- * Deliberately neutral, and deliberately NOT read from the `--color*` theme variables: those are the
32
- * app chrome's palette (white, black, the UI accent), not the world's, so reading them would paint
33
- * every planet the same interface colour. A world that DOES name a colour is honoured — see
34
- * `resolvePlanetColor` — and what carries the identity of one that does not is its own authored
35
- * light (`minimalLighting.js`).
36
- */
37
- const DEFAULT_PLANET_COLOR = 0xb8c2cc;
38
-
39
- /**
40
- * The same neutral as a CSS hex, for surfaces that have to SHOW it rather than render it — the
41
- * editor's colour swatch displays this when a world has authored nothing, so the control never
42
- * claims a colour the renderer is not actually using. Exported so there is one constant, not an
43
- * editor copy that has to be kept equal to this one.
44
- */
45
- export const MINIMAL_PLANET_COLOR_HEX = `#${DEFAULT_PLANET_COLOR.toString(16).padStart(6, '0')}`;
46
-
47
- /**
48
- * The floor an authored colour is lifted to, as HSL lightness in the sRGB the author picked in.
49
- *
50
- * Minimal drops the skybox, so the planet is drawn against black (`SceneSpace` sets the background to
51
- * BLACK when there is no cubemap). The scene's whole light budget is one key at 1.8 plus a 0.55
52
- * ambient fill, so a near-black body reflects almost nothing and disappears into that background
53
- * entirely — not a dark planet, no planet. Lifting the lightness while keeping hue and saturation
54
- * means a world that authored near-black still reads as its own hue, just at the darkest level that
55
- * is still a visible sphere.
56
- *
57
- * 0.10 sits below anything an author would recognise as a colour rather than as black: `#101030`, a
58
- * genuinely dark navy, measures 0.125 and passes through untouched.
59
- */
60
- const MIN_PLANET_LIGHTNESS = 0.1;
61
-
62
- export const MINIMAL_MATERIAL_KINDS = Object.freeze(['lambert', 'phong', 'normal']);
63
-
64
- /**
65
- * Segment counts used only when the world names none.
66
- *
67
- * The world's OWN resolution is never overridden, including when it is very low. A low-segment world
68
- * is a faceted world on purpose — that faceting is the world's shape, not a defect to be smoothed
69
- * away — and performance mode changes what is DRAWN, never the world's configuration.
70
- */
71
- const FALLBACK_WIDTH_SEGMENTS = 48;
72
- const FALLBACK_HEIGHT_SEGMENTS = 32;
73
-
74
- function resolveSegments(value, fallback) {
75
- const numeric = Number(value);
76
- if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
77
- return Math.round(numeric);
78
- }
79
-
80
- /**
81
- * Parse a colour, or report that it could not be parsed.
82
- *
83
- * `new THREE.Color(x)` does NOT throw on a value it cannot read: `Color.set` ignores anything that is
84
- * not a Color, a number or a string, and `setStyle` warns and leaves the colour untouched. Either way
85
- * the object keeps whatever it was constructed with, so a `try/catch` around it catches nothing and
86
- * the caller silently gets that starting value — white, by default.
87
- *
88
- * That matters here because the colour arrives from world metadata, which is `Schema.Types.Mixed` and
89
- * validated nowhere: any non-empty string persists, so `'blueish'` would paint a WHITE planet rather
90
- * than falling back to the neutral one.
91
- *
92
- * So: probe from black. If the result is black the value was either genuinely black or not read at
93
- * all — probe again from white to tell those apart. The common case parses on the first probe and
94
- * costs one object.
95
- *
96
- * @returns {THREE.Color|null} null when the value could not be read as a colour
97
- */
98
- function parseColor(value) {
99
- const fromBlack = new THREE.Color(0x000000).set(value);
100
- if (fromBlack.getHex() !== 0x000000) return fromBlack;
101
- const fromWhite = new THREE.Color(0xffffff).set(value);
102
- return fromWhite.getHex() === 0x000000 ? fromBlack : null;
103
- }
104
-
105
- /**
106
- * The planet colour to use, given the colour the world authored (if any).
107
- *
108
- * The only thing done to an authored colour is the legibility floor above. Hue and saturation are
109
- * never touched: the point of the control is that the world picks its own colour, so anything that
110
- * moved it toward a house palette would defeat it.
111
- *
112
- * @param {THREE.Color|number|string|null|undefined} override
113
- * @returns {THREE.Color}
114
- */
115
- export function resolvePlanetColor(override) {
116
- if (override === undefined || override === null) return new THREE.Color(DEFAULT_PLANET_COLOR);
117
- // An unreadable colour is never worth failing a world load over — the world just keeps the neutral.
118
- const color = parseColor(override);
119
- if (!color) return new THREE.Color(DEFAULT_PLANET_COLOR);
120
- // Read and write HSL in sRGB — the space the author picked the colour in. The working space is
121
- // linear, where the same swatch measures several times darker, so a floor applied there would
122
- // lift colours the author would not call dark at all.
123
- const hsl = { h: 0, s: 0, l: 0 };
124
- color.getHSL(hsl, THREE.SRGBColorSpace);
125
- if (hsl.l < MIN_PLANET_LIGHTNESS) {
126
- color.setHSL(hsl.h, hsl.s, MIN_PLANET_LIGHTNESS, THREE.SRGBColorSpace);
127
- }
128
- return color;
129
- }
130
-
131
- function createMaterial(kind, color) {
132
- switch (kind) {
133
- case 'normal':
134
- // The previous look, kept so it can be compared directly rather than from memory.
135
- return new THREE.MeshNormalMaterial();
136
- case 'phong':
137
- // One specular highlight on top of Lambert. Reads glossier; costs a little more.
138
- return new THREE.MeshPhongMaterial({ color, shininess: 12, specular: 0x222222 });
139
- default:
140
- return new THREE.MeshLambertMaterial({ color });
141
- }
142
- }
143
-
144
- /**
145
- * Build the minimal planet. Signature matches what `WorldManager.setMode` hands a custom creator.
146
- *
147
- * @param {{
148
- * sphereRadius?: number,
149
- * sphereWidthSegments?: number,
150
- * sphereHeightSegments?: number,
151
- * materialKind?: 'lambert'|'phong'|'normal',
152
- * planetColor?: THREE.Color|number|string,
153
- * }} args
154
- * @returns {Promise<THREE.Mesh>}
155
- */
156
- export async function createMinimalWorld({
157
- sphereRadius = 1,
158
- sphereWidthSegments,
159
- sphereHeightSegments,
160
- materialKind = 'lambert',
161
- planetColor,
162
- } = {}) {
163
- const kind = MINIMAL_MATERIAL_KINDS.includes(materialKind) ? materialKind : 'lambert';
164
- const color = resolvePlanetColor(planetColor);
165
-
166
- // The world's own radius and segment counts, as authored. Same shape as the full look — this mode
167
- // changes the material and what surrounds the planet, never the geometry the world describes.
168
- const geometry = new THREE.SphereGeometry(
169
- sphereRadius,
170
- resolveSegments(sphereWidthSegments, FALLBACK_WIDTH_SEGMENTS),
171
- resolveSegments(sphereHeightSegments, FALLBACK_HEIGHT_SEGMENTS),
172
- );
173
- const material = createMaterial(kind, color);
174
- const mesh = new THREE.Mesh(geometry, material);
175
- mesh.name = 'orb_MinimalWorld';
176
- mesh.castShadow = false;
177
- mesh.receiveShadow = false;
178
- return mesh;
179
- }