lecodes-sdk 2.0.9 → 2.0.10

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/src/gl/Light.ts CHANGED
@@ -1,270 +1,257 @@
1
- // Lights. A directional sun (the engine's primary light), punctual point lights and spot lights. Add one to a
2
- // scene like any node; `intensity` and `color` stay writable so a light can be animated.
3
-
4
- import { Color, type ColorInput } from "../core/color"
5
- import type { Vec3Like } from "../math/vec"
6
- import { Node, type NodeTweenProps } from "./Node"
7
- import { DOM_GL_LIGHT, colorValue, floatValue, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
8
- import type { Animation } from "../animate/tween/Animation"
9
-
10
- /** Animatable light props on top of the node transform. */
11
- export type LightTweenProps = NodeTweenProps & {
12
- intensity?: number | number[],
13
- color?: ColorInput | ColorInput[],
14
- }
15
-
16
- export type SunOptions = {
17
- /** Light direction (points where the light travels). Defaults to a typical key-light angle. */
18
- direction?: Vec3Like
19
- /** Luminous intensity. */
20
- intensity?: number
21
- color?: ColorInput
22
- /**
23
- * Shadow quality (default 1):
24
- * 0 - no shadows
25
- * 1 - 1024 map, hard edges (one filtered tap; the cheap level, ~3 % of a frame on an iGPU)
26
- * 2 - 1024 map, soft edges (variance shadows + blur; ~+15 % of a frame on an iGPU)
27
- * 3 - 2048 map, contact-hardening soft edges (PCSS: sharp where the caster touches, softer
28
- * away; discrete-GPU territory, ~+60 % of a frame on an iGPU)
29
- * A lightmapped level already carries every static shadow, so 0 is a legitimate choice there.
30
- * On a level with a baked light grid the variance / PCSS filters cannot be used (they need every
31
- * receiver in the shadow map, and the baked statics are kept out of it): 3 renders as PCSS on the
32
- * depth map - the same contact-hardening look - and 2 renders as 1.
33
- */
34
- shadowsQuality?: 0 | 1 | 2 | 3
35
- /**
36
- * Metres from the camera within which the sun casts shadows (default 100). Shadows fade out by
37
- * this distance and the shadow map covers only this range, so a shorter distance is crisper for
38
- * the same quality and puts fewer casters in the shadow pass. 30-40 is plenty in first person.
39
- */
40
- shadowDistance?: number
41
- /**
42
- * How many shadow-map cascades split `shadowDistance` (default 1). One map over the whole range gives a tall
43
- * tree 5-10 cm shadow texels on its own trunk; 3 cascades put a tight map on the first metres and coarser
44
- * ones behind, fit to their texel grids so the shadow does not swim as the camera moves. Each cascade is
45
- * another caster pass, so it costs in proportion to what casts: forests, not a lightmapped level.
46
- */
47
- shadowCascades?: 1 | 2 | 3 | 4
48
- }
49
-
50
- export type PointOptions = {
51
- /**
52
- * Luminous POWER in lumens — the same physical scale as the sun's lux and `environmentIntensity`,
53
- * so a light keeps its look when the scene's exposure changes. A candle is ~12 lm, a bare 60 W
54
- * bulb ~800, a car headlight ~1500, a fireball millions. Default 1000.
55
- */
56
- intensity?: number
57
- color?: ColorInput
58
- /**
59
- * Metres of influence — past it the light contributes nothing. This is the performance knob:
60
- * overlapping point lights are the expensive case, so keep it as small as the look allows.
61
- * Default 10.
62
- */
63
- range?: number
64
- /** Point-light shadows are a cubemap render per light; off by default. */
65
- castShadows?: boolean
66
- /**
67
- * Whether `lecodes lightmap bake` bakes this light into the level's lightmap. A baked lamp lights the statics
68
- * from the atlas and the MOVERS from the level's light grid, and is held dark in real time while that grid is
69
- * loaded (without a grid it keeps lighting the movers live); a light
70
- * that must reach the statics live too — a flicker, a lamp the player can shoot out, a muzzle flash —
71
- * says `baked: false`: it is kept out of the bake and put on the statics' light channel. Default true.
72
- */
73
- baked?: boolean
74
- /**
75
- * THE BAKE'S SHAPE of this lamp: `[width, height]` in metres = an AREA light - a rectangle in the light's local XZ
76
- * plane, emitting along its local -Y (down, for an unrotated node) with the same lumens: a ceiling panel. The bake
77
- * lights from the whole rectangle (soft shadows, light from where the panel is and not from a point inside the
78
- * fixture). Real time it is still the point light above. Ignored with `baked: false`.
79
- */
80
- bakeArea?: readonly [number, number]
81
- }
82
-
83
- export type SpotOptions = {
84
- /**
85
- * Luminous power in lumens, as a point light's. The cone does not change the brightness: widen it and the
86
- * same lumens light a larger patch at the same level. On its axis a spot is 4x brighter than a point light of
87
- * the same lumens (its light is not spread over the whole sphere). Default 1000.
88
- */
89
- intensity?: number
90
- color?: ColorInput
91
- /** Metres of influence along the cone, the performance knob as for a point light. Default 10. */
92
- range?: number
93
- /** The cone's full angle in degrees, up to 180: past it the light contributes nothing. Default 45. */
94
- angle?: number
95
- /** The full angle in degrees of the cone's fully lit core; the light fades from it out to `angle`. Default 0.75 x `angle`. */
96
- innerAngle?: number
97
- /** A spot's shadow is one 2D shadow map (a point light's is a cubemap); off by default. */
98
- castShadows?: boolean
99
- }
100
-
101
- export class Light extends Node {
102
- private _intensity = 0
103
-
104
- /** Tween `intensity` / `color` (a flash, a sunrise) and the transform — see {@link Node.animateTo}. */
105
- override animateTo(props: LightTweenProps & TweenMeta): Animation { return super.animateTo(props) }
106
- override animateFrom(props: LightTweenProps & TweenMeta): Animation { return super.animateFrom(props) }
107
- /** @internal */
108
- override _tweenChannel(prop: string): TweenChannel | null {
109
- const id = () => this.id
110
- if (prop === "intensity") return { domain: DOM_GL_LIGHT, id, value: floatValue, commit: (v) => { this._intensity = v as number } }
111
- if (prop === "color") return { domain: DOM_GL_LIGHT, id, value: (v) => colorValue(v, Color.toRgba01) }
112
- return super._tweenChannel(prop)
113
- }
114
- /** @internal PointOptions.baked — a candidate for the lightmap bake (default true). */
115
- _baked = true
116
- /** @internal every live point light, for the bake / the switch-off; a destroyed one leaves. */
117
- static _points = new Set<Light>()
118
- /** @internal sun direction as created (the engine keeps it). */
119
- _direction: [number, number, number] = [ 0, -1, 0 ]
120
- /** The most recently created sun. */
121
- static lastSun: Light | null = null
122
- /** @internal the colour as created / last set (a settings menu recreates a sun from these). */
123
- _color: ColorInput = 0xffffff
124
- /** @internal sun shadow options as created (creation-time in the engine). */
125
- _shadowsQuality = 1
126
- _shadowDistance = 100
127
- _shadowCascades = 1
128
-
129
- /** A directional sun light. */
130
- static sun(options: SunOptions = {}): Light {
131
- const light = new Light()
132
- const [ dx, dy, dz ] = options.direction ?? [ 0.548267, -0.473983, -0.689016 ]
133
- light._direction = [ dx, dy, dz ]
134
- light._intensity = options.intensity ?? 100000
135
- light._color = options.color ?? 0xffffff
136
- light._shadowsQuality = options.shadowsQuality ?? 1
137
- light._shadowDistance = options.shadowDistance ?? 100
138
- light._shadowCascades = options.shadowCascades ?? 1
139
- Light.lastSun = light
140
- _creator.createSunLight(
141
- light.id, dx, dy, dz,
142
- light._intensity,
143
- Color.toPackedRgb(options.color ?? 0xffffff),
144
- options.shadowsQuality ?? 1,
145
- options.shadowDistance ?? 100,
146
- light._shadowCascades,
147
- )
148
- return light
149
- }
150
-
151
- /**
152
- * A point light — a lamp, a muzzle flash, a fireball. Position it like any node.
153
- *
154
- * Feature-detected: hosts that predate it create the node and light nothing, which keeps a scene
155
- * that adds atmosphere on top of its sun renderable everywhere. Check `Light.supportsPoint`
156
- * before making one carry the scene.
157
- */
158
- static point(options: PointOptions = {}): Light {
159
- const light = new Light()
160
- light._intensity = options.intensity ?? 1000
161
- light._color = options.color ?? 0xffffff
162
- light._baked = options.baked ?? true
163
- Light._points.add(light)
164
- _creator.createPointLight?.(
165
- light.id,
166
- light._intensity,
167
- Color.toPackedRgb(options.color ?? 0xffffff),
168
- options.range ?? 10,
169
- options.castShadows ?? false,
170
- )
171
- if (options.bakeArea && light._baked) _creator.setLightBakeArea?.(light.id, options.bakeArea[0], options.bakeArea[1])
172
- // an unbaked lamp lights the baked statics live: Filament channel 1 (see Lightmap)
173
- if (!light._baked) _creator.setLightChannel?.(light.id, 1, true)
174
- return light
175
- }
176
-
177
- /** Whether this host can create point lights at all. */
178
- static get supportsPoint(): boolean { return _creator.createPointLight !== undefined }
179
-
180
- /** @internal a spot's cone, full angles in degrees (the engine takes half-angles in radians). */
181
- _angle = 0
182
- _innerAngle = 0
183
-
184
- /**
185
- * A spot light — a flashlight, a headlight, a stage light. It shines along the node's forward (-Z): position
186
- * it like any node and aim it with `lookAt`. Real time only: the lightmap bake takes no spot lights, so a spot
187
- * lights the baked statics live too.
188
- *
189
- * Feature-detected like `Light.point`: on a host without it the node exists and lights nothing
190
- * (`Light.supportsSpot`).
191
- */
192
- static spot(options: SpotOptions = {}): Light {
193
- const light = new Light()
194
- light._intensity = options.intensity ?? 1000
195
- light._color = options.color ?? 0xffffff
196
- light._baked = false
197
- light._angle = options.angle ?? 45
198
- light._innerAngle = options.innerAngle ?? light._angle * 0.75
199
- const [ inner, outer ] = light._cone()
200
- _creator.createSpotLight?.(
201
- light.id,
202
- light._intensity,
203
- Color.toPackedRgb(light._color),
204
- options.range ?? 10,
205
- inner, outer,
206
- options.castShadows ?? false,
207
- )
208
- // the statics of a lightmapped level are on Filament channel 1 alone (see Lightmap)
209
- _creator.setLightChannel?.(light.id, 1, true)
210
- return light
211
- }
212
-
213
- /** Whether this host can create spot lights. */
214
- static get supportsSpot(): boolean { return _creator.createSpotLight !== undefined }
215
-
216
- /** @internal the cone as the engine takes it: half-angles in radians, outer in (0, 90°], inner in [0, outer]. */
217
- _cone(): [number, number] {
218
- const rad = Math.PI / 360
219
- const outer = Math.min(Math.max(this._angle, 0.1), 180) * rad
220
- const inner = Math.min(Math.max(this._innerAngle, 0), this._angle) * rad
221
- return [ Math.min(inner, outer), outer ]
222
- }
223
-
224
- /** A spot's full cone angle in degrees — live (a flashlight's focus). */
225
- get angle(): number { return this._angle }
226
- set angle(value: number) {
227
- this._innerAngle *= this._angle > 0 ? value / this._angle : 0
228
- this._angle = value
229
- _creator.setLightCone?.(this.id, ...this._cone())
230
- }
231
- /** A spot's fully lit core, full angle in degrees — live. Setting `angle` scales it along. */
232
- get innerAngle(): number { return this._innerAngle }
233
- set innerAngle(value: number) {
234
- this._innerAngle = value
235
- _creator.setLightCone?.(this.id, ...this._cone())
236
- }
237
-
238
- /** Live intensity (sun: lux, point / spot: lumens) — animate a flash without rebuilding the light. */
239
- get intensity(): number { return this._intensity }
240
- set intensity(value: number) {
241
- this._intensity = value
242
- if (!this._heldDark) _creator.setLightIntensity?.(this.id, value)
243
- }
244
-
245
- /** @internal A BAKED lamp is held dark while the level's LIGHT GRID is loaded (Lightmap): the statics have it in the
246
- * atlas and the movers in the grid's ambient cubes - left burning it lit every mover and unbaked prop a second time,
247
- * without a shadow (through walls), and put a hot spot on its own fixture. `intensity` keeps the authored value. */
248
- _heldDark = false
249
- _holdDark(dark: boolean): void {
250
- if (this._heldDark === dark) return
251
- this._heldDark = dark
252
- _creator.setLightIntensity?.(this.id, dark ? 0 : this._intensity)
253
- }
254
-
255
- destroy(): void {
256
- Light._points.delete(this)
257
- super.destroy()
258
- }
259
-
260
- set color(value: ColorInput) {
261
- this._color = value
262
- _creator.setLightColor?.(this.id, Color.toPackedRgb(value))
263
- }
264
- get color(): ColorInput { return this._color }
265
- /** Sun direction as created (the engine keeps it; a settings menu rebuilds a sun from it). */
266
- get direction(): [number, number, number] { return this._direction }
267
- /** Sun shadow options as created — creation-time in the engine, so a change means a new sun. */
268
- get shadowsQuality(): number { return this._shadowsQuality }
269
- get shadowDistance(): number { return this._shadowDistance }
270
- }
1
+ // Lights. A directional sun (the engine's primary light), punctual point lights and spot lights. Add one to a
2
+ // scene like any node; `intensity` and `color` stay writable so a light can be animated.
3
+
4
+ import { Color, type ColorInput } from "../core/color"
5
+ import type { Vec3Like } from "../math/vec"
6
+ import { Node, type NodeTweenProps } from "./Node"
7
+ import { DOM_GL_LIGHT, colorValue, floatValue, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
8
+ import type { Animation } from "../animate/tween/Animation"
9
+
10
+ /** Animatable light props on top of the node transform. */
11
+ export type LightTweenProps = NodeTweenProps & {
12
+ intensity?: number | number[],
13
+ color?: ColorInput | ColorInput[],
14
+ }
15
+
16
+ export type SunOptions = {
17
+ /** Light direction (points where the light travels). Defaults to a typical key-light angle. */
18
+ direction?: Vec3Like
19
+ /** Luminous intensity. */
20
+ intensity?: number
21
+ color?: ColorInput
22
+ /**
23
+ * Shadow quality (default 1):
24
+ * 0 - no shadows
25
+ * 1 - 1024 map, hard edges (one filtered tap; the cheap level, ~3 % of a frame on an iGPU)
26
+ * 2 - 1024 map, soft edges (variance shadows + blur; ~+15 % of a frame on an iGPU)
27
+ * 3 - 2048 map, contact-hardening soft edges (PCSS: sharp where the caster touches, softer
28
+ * away; discrete-GPU territory, ~+60 % of a frame on an iGPU)
29
+ * A lightmapped level already carries every static shadow, so 0 is a legitimate choice there.
30
+ * On a level with a baked light grid the variance / PCSS filters cannot be used (they need every
31
+ * receiver in the shadow map, and the baked statics are kept out of it): 3 renders as PCSS on the
32
+ * depth map - the same contact-hardening look - and 2 renders as 1.
33
+ */
34
+ shadowsQuality?: 0 | 1 | 2 | 3
35
+ /**
36
+ * Metres from the camera within which the sun casts shadows (default 100). Shadows fade out by
37
+ * this distance and the shadow map covers only this range, so a shorter distance is crisper for
38
+ * the same quality and puts fewer casters in the shadow pass. 30-40 is plenty in first person.
39
+ */
40
+ shadowDistance?: number
41
+ /**
42
+ * How many shadow-map cascades split `shadowDistance` (default 1). One map over the whole range gives a tall
43
+ * tree 5-10 cm shadow texels on its own trunk; 3 cascades put a tight map on the first metres and coarser
44
+ * ones behind, fit to their texel grids so the shadow does not swim as the camera moves. Each cascade is
45
+ * another caster pass, so it costs in proportion to what casts: forests, not a lightmapped level.
46
+ */
47
+ shadowCascades?: 1 | 2 | 3 | 4
48
+ }
49
+
50
+ export type PointOptions = {
51
+ /**
52
+ * Luminous POWER in lumens — the same physical scale as the sun's lux and `environmentIntensity`,
53
+ * so a light keeps its look when the scene's exposure changes. A candle is ~12 lm, a bare 60 W
54
+ * bulb ~800, a car headlight ~1500, a fireball millions. Default 1000.
55
+ */
56
+ intensity?: number
57
+ color?: ColorInput
58
+ /**
59
+ * Metres of influence — past it the light contributes nothing. This is the performance knob:
60
+ * overlapping point lights are the expensive case, so keep it as small as the look allows.
61
+ * Default 10.
62
+ */
63
+ range?: number
64
+ /** Point-light shadows are a cubemap render per light; off by default. */
65
+ castShadows?: boolean
66
+ /**
67
+ * Whether `lecodes lightmap bake` bakes this light into the level's lightmap. A baked lamp lights the statics
68
+ * from the atlas and the MOVERS from the level's light grid, and is held dark in real time while that grid is
69
+ * loaded (without a grid it keeps lighting the movers live); a light
70
+ * that must reach the statics live too — a flicker, a lamp the player can shoot out, a muzzle flash —
71
+ * says `baked: false`: it is kept out of the bake and put on the statics' light channel. Default true.
72
+ */
73
+ baked?: boolean
74
+ /**
75
+ * THE BAKE'S SHAPE of this lamp: `[width, height]` in metres = an AREA light - a rectangle in the light's local XZ
76
+ * plane, emitting along its local -Y (down, for an unrotated node) with the same lumens: a ceiling panel. The bake
77
+ * lights from the whole rectangle (soft shadows, light from where the panel is and not from a point inside the
78
+ * fixture). Real time it is still the point light above. Ignored with `baked: false`.
79
+ */
80
+ bakeArea?: readonly [number, number]
81
+ }
82
+
83
+ export type SpotOptions = {
84
+ /**
85
+ * Luminous power in lumens, as a point light's. The cone does not change the brightness: widen it and the
86
+ * same lumens light a larger patch at the same level. On its axis a spot is 4x brighter than a point light of
87
+ * the same lumens (its light is not spread over the whole sphere). Default 1000.
88
+ */
89
+ intensity?: number
90
+ color?: ColorInput
91
+ /** Metres of influence along the cone, the performance knob as for a point light. Default 10. */
92
+ range?: number
93
+ /** The cone's full angle in degrees, up to 180: past it the light contributes nothing. Default 45. */
94
+ angle?: number
95
+ /** The full angle in degrees of the cone's fully lit core; the light fades from it out to `angle`. Default 0.75 x `angle`. */
96
+ innerAngle?: number
97
+ /** A spot's shadow is one 2D shadow map (a point light's is a cubemap); off by default. */
98
+ castShadows?: boolean
99
+ }
100
+
101
+ export class Light extends Node {
102
+ private _intensity = 0
103
+
104
+ /** Tween `intensity` / `color` (a flash, a sunrise) and the transform — see {@link Node.animateTo}. */
105
+ override animateTo(props: LightTweenProps & TweenMeta): Animation { return super.animateTo(props) }
106
+ override animateFrom(props: LightTweenProps & TweenMeta): Animation { return super.animateFrom(props) }
107
+ /** @internal */
108
+ override _tweenChannel(prop: string): TweenChannel | null {
109
+ const id = () => this.id
110
+ if (prop === "intensity") return { domain: DOM_GL_LIGHT, id, value: floatValue, commit: (v) => { this._intensity = v as number } }
111
+ if (prop === "color") return { domain: DOM_GL_LIGHT, id, value: (v) => colorValue(v, Color.toRgba01) }
112
+ return super._tweenChannel(prop)
113
+ }
114
+ /** @internal PointOptions.baked — a candidate for the lightmap bake (default true). */
115
+ _baked = true
116
+ /** @internal every live point light, for the bake / the switch-off; a destroyed one leaves. */
117
+ static _points = new Set<Light>()
118
+ /** @internal sun direction as created (the engine keeps it). */
119
+ _direction: [number, number, number] = [ 0, -1, 0 ]
120
+ /** The most recently created sun. */
121
+ static lastSun: Light | null = null
122
+ /** @internal the colour as created / last set (a settings menu recreates a sun from these). */
123
+ _color: ColorInput = 0xffffff
124
+ /** @internal sun shadow options as created (creation-time in the engine). */
125
+ _shadowsQuality = 1
126
+ _shadowDistance = 100
127
+ _shadowCascades = 1
128
+
129
+ /** A directional sun light. */
130
+ static sun(options: SunOptions = {}): Light {
131
+ const light = new Light()
132
+ const [ dx, dy, dz ] = options.direction ?? [ 0.548267, -0.473983, -0.689016 ]
133
+ light._direction = [ dx, dy, dz ]
134
+ light._intensity = options.intensity ?? 100000
135
+ light._color = options.color ?? 0xffffff
136
+ light._shadowsQuality = options.shadowsQuality ?? 1
137
+ light._shadowDistance = options.shadowDistance ?? 100
138
+ light._shadowCascades = options.shadowCascades ?? 1
139
+ Light.lastSun = light
140
+ _creator.createSunLight(
141
+ light.id, dx, dy, dz,
142
+ light._intensity,
143
+ Color.toPackedRgb(options.color ?? 0xffffff),
144
+ options.shadowsQuality ?? 1,
145
+ options.shadowDistance ?? 100,
146
+ light._shadowCascades,
147
+ )
148
+ return light
149
+ }
150
+
151
+ /**
152
+ * A point light — a lamp, a muzzle flash, a fireball. Position it like any node.
153
+ */
154
+ static point(options: PointOptions = {}): Light {
155
+ const light = new Light()
156
+ light._intensity = options.intensity ?? 1000
157
+ light._color = options.color ?? 0xffffff
158
+ light._baked = options.baked ?? true
159
+ Light._points.add(light)
160
+ _creator.createPointLight?.(
161
+ light.id,
162
+ light._intensity,
163
+ Color.toPackedRgb(options.color ?? 0xffffff),
164
+ options.range ?? 10,
165
+ options.castShadows ?? false,
166
+ )
167
+ if (options.bakeArea && light._baked) _creator.setLightBakeArea?.(light.id, options.bakeArea[0], options.bakeArea[1])
168
+ // an unbaked lamp lights the baked statics live: Filament channel 1 (see Lightmap)
169
+ if (!light._baked) _creator.setLightChannel?.(light.id, 1, true)
170
+ return light
171
+ }
172
+
173
+ /** @internal a spot's cone, full angles in degrees (the engine takes half-angles in radians). */
174
+ _angle = 0
175
+ _innerAngle = 0
176
+
177
+ /**
178
+ * A spot light — a flashlight, a headlight, a stage light. It shines along the node's forward (-Z): position
179
+ * it like any node and aim it with `lookAt`. Real time only: the lightmap bake takes no spot lights, so a spot
180
+ * lights the baked statics live too.
181
+ */
182
+ static spot(options: SpotOptions = {}): Light {
183
+ const light = new Light()
184
+ light._intensity = options.intensity ?? 1000
185
+ light._color = options.color ?? 0xffffff
186
+ light._baked = false
187
+ light._angle = options.angle ?? 45
188
+ light._innerAngle = options.innerAngle ?? light._angle * 0.75
189
+ const [ inner, outer ] = light._cone()
190
+ _creator.createSpotLight?.(
191
+ light.id,
192
+ light._intensity,
193
+ Color.toPackedRgb(light._color),
194
+ options.range ?? 10,
195
+ inner, outer,
196
+ options.castShadows ?? false,
197
+ )
198
+ // the statics of a lightmapped level are on Filament channel 1 alone (see Lightmap)
199
+ _creator.setLightChannel?.(light.id, 1, true)
200
+ return light
201
+ }
202
+
203
+ /** @internal the cone as the engine takes it: half-angles in radians, outer in (0, 90°], inner in [0, outer]. */
204
+ _cone(): [number, number] {
205
+ const rad = Math.PI / 360
206
+ const outer = Math.min(Math.max(this._angle, 0.1), 180) * rad
207
+ const inner = Math.min(Math.max(this._innerAngle, 0), this._angle) * rad
208
+ return [ Math.min(inner, outer), outer ]
209
+ }
210
+
211
+ /** A spot's full cone angle in degrees — live (a flashlight's focus). */
212
+ get angle(): number { return this._angle }
213
+ set angle(value: number) {
214
+ this._innerAngle *= this._angle > 0 ? value / this._angle : 0
215
+ this._angle = value
216
+ _creator.setLightCone?.(this.id, ...this._cone())
217
+ }
218
+ /** A spot's fully lit core, full angle in degrees — live. Setting `angle` scales it along. */
219
+ get innerAngle(): number { return this._innerAngle }
220
+ set innerAngle(value: number) {
221
+ this._innerAngle = value
222
+ _creator.setLightCone?.(this.id, ...this._cone())
223
+ }
224
+
225
+ /** Live intensity (sun: lux, point / spot: lumens) — animate a flash without rebuilding the light. */
226
+ get intensity(): number { return this._intensity }
227
+ set intensity(value: number) {
228
+ this._intensity = value
229
+ if (!this._heldDark) _creator.setLightIntensity?.(this.id, value)
230
+ }
231
+
232
+ /** @internal A BAKED lamp is held dark while the level's LIGHT GRID is loaded (Lightmap): the statics have it in the
233
+ * atlas and the movers in the grid's ambient cubes - left burning it lit every mover and unbaked prop a second time,
234
+ * without a shadow (through walls), and put a hot spot on its own fixture. `intensity` keeps the authored value. */
235
+ _heldDark = false
236
+ _holdDark(dark: boolean): void {
237
+ if (this._heldDark === dark) return
238
+ this._heldDark = dark
239
+ _creator.setLightIntensity?.(this.id, dark ? 0 : this._intensity)
240
+ }
241
+
242
+ destroy(): void {
243
+ Light._points.delete(this)
244
+ super.destroy()
245
+ }
246
+
247
+ set color(value: ColorInput) {
248
+ this._color = value
249
+ _creator.setLightColor?.(this.id, Color.toPackedRgb(value))
250
+ }
251
+ get color(): ColorInput { return this._color }
252
+ /** Sun direction as created (the engine keeps it; a settings menu rebuilds a sun from it). */
253
+ get direction(): [number, number, number] { return this._direction }
254
+ /** Sun shadow options as created — creation-time in the engine, so a change means a new sun. */
255
+ get shadowsQuality(): number { return this._shadowsQuality }
256
+ get shadowDistance(): number { return this._shadowDistance }
257
+ }
package/src/host.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  // JS-runtime globals provided by the host engine (web viewer / desktop / iOS), NOT by the SDK.
2
2
  // Declared only so SDK and user code type-check — never bundled or injected.
3
3
 
4
+ // The one SDK type the macros return: an SVG image source, the same `SvgSource` a project has as
5
+ // a global (build-types.ts points this import into dist/types when it copies the file).
6
+ import type { SvgSource } from "./runtime/misc"
7
+
4
8
  declare global {
5
9
  function setTimeout(handler: (...args: any[]) => void, timeout?: number): number
6
10
  function setInterval(handler: (...args: any[]) => void, timeout?: number): number
@@ -33,7 +37,7 @@ declare global {
33
37
  function asset(path: `${string}.json`): any
34
38
  /** Compile-time macro: `asset('./logo.svg')` yields the file as an SVG image source — for
35
39
  * `UIImage(...)` and `bgImage`. */
36
- function asset(path: `${string}.svg`): { readonly svg: string, tintColor: string | null }
40
+ function asset(path: `${string}.svg`): SvgSource
37
41
  /** Compile-time macro: `asset('./hero.png')` is desugared by the bundler into the module import
38
42
  * for that resource. The file must exist — a path that resolves to nothing fails the compile
39
43
  * (`asset not found: ./hero.png (main.ts:3)`); there is no runtime fallback. Calls inside
@@ -53,7 +57,7 @@ declare global {
53
57
  * (e.g. `"lucide:bell"`), string literal only. Recolor via `{ color }`: a hex LITERAL is baked
54
58
  * into the SVG at compile time, a token/expression is applied as a tint (as is the `tintColor`
55
59
  * style prop). */
56
- function assetIcon(id: string, opts?: { color?: string }): { readonly svg: string, tintColor: string | null }
60
+ function assetIcon(id: string, opts?: { color?: string }): SvgSource
57
61
 
58
62
  /** Type-only editor convenience: the style object type of a UI element. `Style<UIButton>` (or
59
63
  * `Style<typeof myButton>`) is what you'd pass to `el.style(...)` — for typing reusable style
@@ -18,3 +18,7 @@ export type SvgSourceValue = {
18
18
 
19
19
  /** Wrap raw SVG XML so it can be used as an image source. */
20
20
  export const SvgSource = (svg: string): SvgSourceValue => ({ svg, tintColor: null })
21
+ /** The type of the value: what `SvgSource(xml)`, `assetIcon("lucide:bell")` and `asset("./logo.svg")`
22
+ * all return, and what `UIImage` / `bgImage` take — ONE name for an SVG image source, global to a
23
+ * project like the function (host.d.ts names it for the macros). */
24
+ export type SvgSource = SvgSourceValue
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
5
5
  // and a host's embedded version must agree on the major for the host to run the bundle; minor
6
6
  // and patch never gate anything (features are detected, never versioned).
7
- export const SDK_VERSION = "2.0.9"
7
+ export const SDK_VERSION = "2.0.10"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {