lecodes-sdk 2.0.8 → 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.
Files changed (51) hide show
  1. package/dist/global.d.ts +3 -0
  2. package/dist/host.d.ts +6 -2
  3. package/dist/types/g2/Scene2D.d.ts +4 -0
  4. package/dist/types/gl/Light.d.ts +31 -7
  5. package/dist/types/gl/Scene.d.ts +4 -0
  6. package/dist/types/inject.d.ts +1 -1
  7. package/dist/types/runtime/misc.d.ts +4 -0
  8. package/dist/types/ui/NativeView.d.ts +3 -0
  9. package/dist/types/ui/UI.d.ts +1 -0
  10. package/dist/types/ui/UILayer.d.ts +25 -0
  11. package/dist/types/ui/UIModal.d.ts +20 -16
  12. package/dist/types/ui/UIPopover.d.ts +5 -3
  13. package/dist/types/ui/UIVideo.d.ts +3 -0
  14. package/dist/types/ui/UIWidget.d.ts +25 -24
  15. package/dist/types/ui/presentable.d.ts +10 -0
  16. package/dist/types/ui/transitions.d.ts +2 -1
  17. package/dist/types/ui/tree.d.ts +1 -0
  18. package/dist/types/version.d.ts +1 -1
  19. package/dist/types.json +1 -1
  20. package/package.json +1 -1
  21. package/prompts/3d-scene-files.md +3 -3
  22. package/prompts/3d-scene.md +1 -1
  23. package/prompts/core.md +1 -1
  24. package/prompts/dist/3d-app.md +4 -4
  25. package/prompts/ui.md +3 -7
  26. package/src/bridges/gl.d.ts +5 -0
  27. package/src/bridges/tree.d.ts +24 -12
  28. package/src/chisel.ts +1 -1
  29. package/src/compile/assetMacro.ts +69 -4
  30. package/src/compile/bundler.ts +10 -3
  31. package/src/g2/Scene2D.ts +10 -0
  32. package/src/gl/Light.ts +257 -194
  33. package/src/gl/Material.ts +3 -0
  34. package/src/gl/Scene.ts +11 -1
  35. package/src/host.d.ts +6 -2
  36. package/src/inject.ts +1 -0
  37. package/src/runtime/misc.ts +4 -0
  38. package/src/ui/NativeView.ts +11 -0
  39. package/src/ui/UI.ts +1 -0
  40. package/src/ui/UIBottomSheet.ts +8 -7
  41. package/src/ui/UILayer.ts +88 -0
  42. package/src/ui/UIModal.ts +59 -43
  43. package/src/ui/UINode.ts +9 -1
  44. package/src/ui/UIPopover.ts +23 -9
  45. package/src/ui/UIVideo.ts +11 -0
  46. package/src/ui/UIWidget.ts +70 -44
  47. package/src/ui/presentable.ts +11 -1
  48. package/src/ui/transitions.ts +36 -7
  49. package/src/ui/tree.ts +4 -0
  50. package/src/version.ts +1 -1
  51. package/tests/helpers/fakeTree.ts +7 -3
package/src/gl/Light.ts CHANGED
@@ -1,194 +1,257 @@
1
- // Lights. A directional sun (the engine's primary light) and punctual point 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 class Light extends Node {
84
- private _intensity = 0
85
-
86
- /** Tween `intensity` / `color` (a flash, a sunrise) and the transform — see {@link Node.animateTo}. */
87
- override animateTo(props: LightTweenProps & TweenMeta): Animation { return super.animateTo(props) }
88
- override animateFrom(props: LightTweenProps & TweenMeta): Animation { return super.animateFrom(props) }
89
- /** @internal */
90
- override _tweenChannel(prop: string): TweenChannel | null {
91
- const id = () => this.id
92
- if (prop === "intensity") return { domain: DOM_GL_LIGHT, id, value: floatValue, commit: (v) => { this._intensity = v as number } }
93
- if (prop === "color") return { domain: DOM_GL_LIGHT, id, value: (v) => colorValue(v, Color.toRgba01) }
94
- return super._tweenChannel(prop)
95
- }
96
- /** @internal PointOptions.baked — a candidate for the lightmap bake (default true). */
97
- _baked = true
98
- /** @internal every live point light, for the bake / the switch-off; a destroyed one leaves. */
99
- static _points = new Set<Light>()
100
- /** @internal sun direction as created (the engine keeps it). */
101
- _direction: [number, number, number] = [ 0, -1, 0 ]
102
- /** The most recently created sun. */
103
- static lastSun: Light | null = null
104
- /** @internal the colour as created / last set (a settings menu recreates a sun from these). */
105
- _color: ColorInput = 0xffffff
106
- /** @internal sun shadow options as created (creation-time in the engine). */
107
- _shadowsQuality = 1
108
- _shadowDistance = 100
109
- _shadowCascades = 1
110
-
111
- /** A directional sun light. */
112
- static sun(options: SunOptions = {}): Light {
113
- const light = new Light()
114
- const [ dx, dy, dz ] = options.direction ?? [ 0.548267, -0.473983, -0.689016 ]
115
- light._direction = [ dx, dy, dz ]
116
- light._intensity = options.intensity ?? 100000
117
- light._color = options.color ?? 0xffffff
118
- light._shadowsQuality = options.shadowsQuality ?? 1
119
- light._shadowDistance = options.shadowDistance ?? 100
120
- light._shadowCascades = options.shadowCascades ?? 1
121
- Light.lastSun = light
122
- _creator.createSunLight(
123
- light.id, dx, dy, dz,
124
- light._intensity,
125
- Color.toPackedRgb(options.color ?? 0xffffff),
126
- options.shadowsQuality ?? 1,
127
- options.shadowDistance ?? 100,
128
- light._shadowCascades,
129
- )
130
- return light
131
- }
132
-
133
- /**
134
- * A point light — a lamp, a muzzle flash, a fireball. Position it like any node.
135
- *
136
- * Feature-detected: hosts that predate it create the node and light nothing, which keeps a scene
137
- * that adds atmosphere on top of its sun renderable everywhere. Check `Light.supportsPoint`
138
- * before making one carry the scene.
139
- */
140
- static point(options: PointOptions = {}): Light {
141
- const light = new Light()
142
- light._intensity = options.intensity ?? 1000
143
- light._color = options.color ?? 0xffffff
144
- light._baked = options.baked ?? true
145
- Light._points.add(light)
146
- _creator.createPointLight?.(
147
- light.id,
148
- light._intensity,
149
- Color.toPackedRgb(options.color ?? 0xffffff),
150
- options.range ?? 10,
151
- options.castShadows ?? false,
152
- )
153
- if (options.bakeArea && light._baked) _creator.setLightBakeArea?.(light.id, options.bakeArea[0], options.bakeArea[1])
154
- // an unbaked lamp lights the baked statics live: Filament channel 1 (see Lightmap)
155
- if (!light._baked) _creator.setLightChannel?.(light.id, 1, true)
156
- return light
157
- }
158
-
159
- /** Whether this host can create point lights at all. */
160
- static get supportsPoint(): boolean { return _creator.createPointLight !== undefined }
161
-
162
- /** Live intensity (sun: lux, point: lumens) — animate a flash without rebuilding the light. */
163
- get intensity(): number { return this._intensity }
164
- set intensity(value: number) {
165
- this._intensity = value
166
- if (!this._heldDark) _creator.setLightIntensity?.(this.id, value)
167
- }
168
-
169
- /** @internal A BAKED lamp is held dark while the level's LIGHT GRID is loaded (Lightmap): the statics have it in the
170
- * atlas and the movers in the grid's ambient cubes - left burning it lit every mover and unbaked prop a second time,
171
- * without a shadow (through walls), and put a hot spot on its own fixture. `intensity` keeps the authored value. */
172
- _heldDark = false
173
- _holdDark(dark: boolean): void {
174
- if (this._heldDark === dark) return
175
- this._heldDark = dark
176
- _creator.setLightIntensity?.(this.id, dark ? 0 : this._intensity)
177
- }
178
-
179
- destroy(): void {
180
- Light._points.delete(this)
181
- super.destroy()
182
- }
183
-
184
- set color(value: ColorInput) {
185
- this._color = value
186
- _creator.setLightColor?.(this.id, Color.toPackedRgb(value))
187
- }
188
- get color(): ColorInput { return this._color }
189
- /** Sun direction as created (the engine keeps it; a settings menu rebuilds a sun from it). */
190
- get direction(): [number, number, number] { return this._direction }
191
- /** Sun shadow options as created — creation-time in the engine, so a change means a new sun. */
192
- get shadowsQuality(): number { return this._shadowsQuality }
193
- get shadowDistance(): number { return this._shadowDistance }
194
- }
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
+ }
@@ -187,6 +187,9 @@ export class Material {
187
187
  } else if ("_h" in (shader as any)) {
188
188
  this.shader = shader as FetchResponse
189
189
  this._h = _creator.createMaterial((shader as any)._h.id) // owned: dropped and unused → freed
190
+ // 0xFFFFFFFF = the engine refused the package: no shader in it for the running backend (a
191
+ // phone on Vulkan handed the mobile `_opengl` artifact — the host's log names what is missing).
192
+ if (this._h.id === 0xFFFFFFFF) throw new Error("Material: the engine refused the shader (no variant for its backend — see the host log)")
190
193
  } else {
191
194
  this.shader = "unknown"
192
195
  this._h = shader as Handle // an instance the runtime already wrapped for us
package/src/gl/Scene.ts CHANGED
@@ -10,6 +10,7 @@ import { _installAspectFrames, _installTimeScale, _attachSystem, _detachSystem,
10
10
  import { _bumpNavEpoch, _navEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "../ui/presentable"
11
11
  import { _transitionId, TRANSITION_NONE } from "../ui/transitions"
12
12
  import { TREE_DEST_SCENE3D, TREE_FLAG_BACK, ensureEmitter, registerDest, unregisterDest } from "../ui/tree"
13
+ import { _setSurfaceContent, type LayerElement, type UIWidgetContent } from "../ui/UILayer"
13
14
  import { device } from "../runtime/device"
14
15
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
15
16
  import type { FetchResponse } from "../runtime/fetch"
@@ -478,10 +479,19 @@ export class Scene implements Presentable {
478
479
 
479
480
  /** @internal The destination tuple (ui/presentable.ts). */
480
481
  _dest(): DestTuple {
481
- // Addressed by the router / an attached widget: make sure the (kind, id) resolves back to us.
482
+ // Addressed by the router / its content layer: make sure the (kind, id) resolves back to us.
482
483
  registerDest(TREE_DEST_SCENE3D, this._id, this)
483
484
  return [TREE_DEST_SCENE3D, this._id, "", ""]
484
485
  }
486
+ /** @internal The scene's content layer (ui/UILayer.ts), made by the first setContent. */
487
+ _contentLayer?: LayerElement
488
+ /** The UI over the scene — widgets (a HUD, a dialog), laid out over the scene while it is
489
+ * presented; touches outside a widget's box reach the scene. `scene.setContent([hud])`. */
490
+ setContent(content: UIWidgetContent): this {
491
+ ensureEmitter()
492
+ _setSurfaceContent(this, content)
493
+ return this
494
+ }
485
495
  /** @internal presentation lifecycle from the runtime */
486
496
  _emitOpen(): void { for (const cb of this.ol.slice()) cb() }
487
497
  /** @internal */
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
package/src/inject.ts CHANGED
@@ -102,6 +102,7 @@ export {
102
102
  UITabs, type UITabDef,
103
103
  defineTabs, type TabDef, type TabsHandle,
104
104
  UIWidget, type UIWidgetStyle,
105
+ UIOverlay, type UIWidgetContent,
105
106
  UIModal, type UIModalStyle,
106
107
  UIBottomSheet, type UIBottomSheetStyle,
107
108
  UIPopover, type UIPopoverStyle,
@@ -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
@@ -10,6 +10,7 @@
10
10
  // wrapper convention.
11
11
 
12
12
  import { _channelCall, _channelEmit, _channelOff, _channelOn, type ChannelListeners } from "../runtime/channel"
13
+ import { _setSurfaceContent, type LayerElement, type UIWidgetContent } from "./UILayer"
13
14
  import { _bumpNavEpoch, _setCurrent, Presentable, type DestTuple, type DismissOptions, type PresentOptions } from "./presentable"
14
15
  import { _transitionId, TRANSITION_NONE } from "./transitions"
15
16
  import { Element, type BaseStyle, type DrawableStyle, type ElementStyle, type UIElementBase } from "./UINode"
@@ -45,6 +46,8 @@ export interface NativeView extends UIElementBase<UINativeViewStyle, DrawableSty
45
46
  /** Subscribe to an event the native view emits (`map.on("markerTap", cb)`). */
46
47
  on(event: string, callback: (data?: any) => void): this
47
48
  off(event: string, callback: (data?: any) => void): this
49
+ /** The UI over the view when presented: widgets over it (`Presentable.setContent`). */
50
+ setContent(content: UIWidgetContent): this
48
51
  /** @internal The destination tuple (Presentable). */
49
52
  _dest(): DestTuple
50
53
  }
@@ -85,6 +88,14 @@ export class NativeViewElement extends Element<"native"> implements Presentable
85
88
 
86
89
  /** @internal */
87
90
  _dest(): DestTuple { return [TREE_DEST_NATIVE, this._viewId, this.viewName, this._paramsJson] }
91
+ /** @internal The view's content layer (ui/UILayer.ts), made by the first setContent. */
92
+ _contentLayer?: LayerElement
93
+ /** The UI over the view when it is presented full screen — widgets (a header, a card, a sheet
94
+ * over a map), laid out over it; touches outside a widget's box reach the view. */
95
+ setContent(content: UIWidgetContent): this {
96
+ _setSurfaceContent(this, content)
97
+ return this
98
+ }
88
99
 
89
100
  open(options?: PresentOptions): void {
90
101
  if (!tree().isViewSupported(this.viewName)) {
package/src/ui/UI.ts CHANGED
@@ -10,6 +10,7 @@ export { UIVideo, type UIVideoStyle } from './UIVideo'
10
10
  export { UIInput, UITextArea, type UIInputStyle } from './UIInput'
11
11
 
12
12
  export { UIWidget, type UIWidgetStyle } from './UIWidget'
13
+ export { UIOverlay, type UIWidgetContent } from './UILayer'
13
14
  export { UIModal, type UIModalStyle } from './UIModal'
14
15
  export { UIBottomSheet, type UIBottomSheetStyle } from './UIBottomSheet'
15
16
  export { UIPopover, type UIPopoverStyle, type UIPopoverAnchor } from './UIPopover'
@@ -67,6 +67,7 @@ export class BottomSheetElement extends ModalElement {
67
67
  // The inherited show/hide pose animates ONLY the scrim: sheet geometry rides the
68
68
  // sheetDetent style key, never the pose system.
69
69
  this._transition = { duration: SHEET_ANIM_MS }
70
+ this._syncPose()
70
71
  this._addFlags(TREE_FLAG_DETENT)
71
72
  }
72
73
 
@@ -97,7 +98,7 @@ export class BottomSheetElement extends ModalElement {
97
98
  setDetent(index: number): void {
98
99
  const clamped = Math.max(0, Math.min(Math.floor(index), this._detents.length - 1))
99
100
  this._detent = clamped
100
- if (!this._open) return // applied by the next show()
101
+ if (!this._shown) return // applied by the next show()
101
102
  this.style({ sheetDetent: clamped } as UIBottomSheetStyle)
102
103
  for (const callback of this._detentListeners) callback(clamped)
103
104
  }
@@ -107,20 +108,20 @@ export class BottomSheetElement extends ModalElement {
107
108
  return this
108
109
  }
109
110
 
110
- show(): void {
111
- if (this._open) return
111
+ override show(): void {
112
+ if (this._shown) return
112
113
  // Before the mount, so the built node carries the target and hosts play the entrance to it.
113
114
  this.style({ sheetDetent: this._detent } as UIBottomSheetStyle)
114
115
  super.show()
115
116
  }
116
117
 
117
- hide(): void {
118
- if (!this._open) return
119
- this.style({ sheetDetent: -1 } as UIBottomSheetStyle) // hosts slide out; unmount follows
118
+ override hide(): void {
119
+ if (!this._shown) return
120
+ this.style({ sheetDetent: -1 } as UIBottomSheetStyle) // hosts slide out; the hide follows
120
121
  super.hide()
121
122
  }
122
123
 
123
- dismissible(enabled: boolean): this {
124
+ override dismissible(enabled: boolean): this {
124
125
  super.dismissible(enabled)
125
126
  this.style({ sheetDismissible: enabled ? 1 : 0 } as UIBottomSheetStyle)
126
127
  return this