lecodes-sdk 2.0.12 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,450 @@
1
+ // Post-processing of a 3D scene: the look, as ONE object (`scene.postProcessing`,
2
+ // `SceneOptions.postProcessing`) — what Unity's volume and Unreal's post-process volume hold, so it
3
+ // is where a developer looks for it: tone mapping and colour grading, a LUT, bloom, vignette,
4
+ // ambient occlusion, screen-space reflections, depth of field. The scene's own light (ibl,
5
+ // exposureCompensation, fog) and its QUALITY (antialias, renderScale) stay on the scene.
6
+ //
7
+ // The object is read-only on the scene: a field is written (`scene.postProcessing.contrast = 1.1`),
8
+ // several at once with `apply({ ... })` — NAMED FIELDS ONLY, at every level (`apply({ bloom:
9
+ // { intensity: 0.4 } })` leaves the bloom's other knobs and everything else as they are; `false`
10
+ // turns a feature off) — and `reset()` puts every field back to its default.
11
+ //
12
+ // Two bands inside, because the engine treats them differently:
13
+ //
14
+ // - The GRADING band — tone mapping, exposure, white balance, contrast / saturation / vibrance,
15
+ // shadows / midtones / highlights, slope / offset / power, curves, the .cube LUT — is baked by the
16
+ // engine into the ONE 3D lookup table the frame always samples. Free per frame; a CHANGE re-bakes
17
+ // a 32³ table on the CPU. So writes here are COALESCED: a field write marks the band dirty and the
18
+ // whole band goes to the engine once, in a microtask (the end of the current handler), and the
19
+ // values are quantised to 1/1000 so a tween that barely moves does not re-bake. Not a thing to
20
+ // animate every frame; a blend between two looks over a second is fine (a few dozen bakes).
21
+ // - The LIVE band — bloom, vignette, ambient occlusion, screen-space reflections, depth of field —
22
+ // is a set of view options: written to the engine at once, free to animate (a focus pull every
23
+ // frame is what depthOfField is for).
24
+ //
25
+ // The wire of the grading band is a fixed float layout (`CG`), mirrored by creator.h's
26
+ // ColorGradingParam: the engine reads by index, so an index here never moves — a field is added at
27
+ // the end. Overlay scenes take their parent's grading (one look per picture); the live band is per
28
+ // scene.
29
+
30
+ import { Color, type ColorInput } from "../core/color"
31
+ import type { Texture } from "./Texture"
32
+
33
+ export type ToneMappingOperator = "aces" | "neutral" | "linear" | "filmic"
34
+ /** A linear RGB triple. */
35
+ export type Rgb = readonly [number, number, number]
36
+ export type Quality = "low" | "medium" | "high" | "ultra"
37
+
38
+ export type BloomOptions = {
39
+ /** How much of the blurred highlights is added back, 0..1 (default 0.2). */
40
+ intensity?: number
41
+ /** Only what is brighter than 1.0 after exposure blooms (default true); false makes everything
42
+ * glow a little — the soft "dreamy" look. */
43
+ threshold?: boolean
44
+ /** Clamp of one pixel's contribution (default 1000); lower it when a few emissive pixels flicker. */
45
+ highlight?: number
46
+ /** Depth of the blur chain, 1..12 (default 6): how far the glow spreads. */
47
+ levels?: number
48
+ /** The blur's sample count (default 'medium'). */
49
+ quality?: Quality
50
+ /** Ghosts + halo around the brightest spots (default false). */
51
+ lensFlare?: boolean
52
+ /** A "dirty lens": this texture multiplied into the bloom (default none). */
53
+ dirt?: Texture | null
54
+ /** How much of the dirt shows, 0..1 (default 0.2). */
55
+ dirtStrength?: number
56
+ }
57
+
58
+ export type VignetteOptions = {
59
+ /** How far from the centre the darkening starts, 0..1 (default 0.5). */
60
+ midPoint?: number
61
+ /** 0 follows the aspect ratio, 1 is a circle (default 0.5). */
62
+ roundness?: number
63
+ /** Softness of the edge, 0..1 (default 0.5). */
64
+ feather?: number
65
+ /** The colour at the edge (default black). */
66
+ color?: ColorInput
67
+ }
68
+
69
+ /** Screen-space ambient occlusion — the contact darkening in creases and where props meet the
70
+ * ground. Without it an IBL lights a crease exactly as brightly as an open face, so everything
71
+ * reads as pasted onto the floor rather than standing on it. `true` takes defaults tuned for
72
+ * human-scale props; `radius` is world-space metres and is the one knob that must follow the
73
+ * scene's scale (~0.3 for objects on a table, ~0.6-1 for a yard of crates and containers). */
74
+ export type AmbientOcclusionOptions = {
75
+ /** Strength of the darkening (default 1). */
76
+ intensity?: number
77
+ /** How far the occlusion reaches, in metres (default 0.3). */
78
+ radius?: number
79
+ /** Falloff contrast; >1 tightens it into the crease (default 1). */
80
+ power?: number
81
+ /** Sample count + filtering (default 'medium'). Not the buffer resolution — that stays half. */
82
+ quality?: Quality
83
+ }
84
+
85
+ /** Screen-space reflections: glossy surfaces reflect what is ON SCREEN, by a ray march through the
86
+ * depth buffer (a wet floor reflecting the lamps above it). Nothing off screen reflects and a
87
+ * reflection ends at the frame's edge — the classic limit. A per-pixel ray: a real cost on a
88
+ * phone, not measured yet. */
89
+ export type ScreenSpaceReflectionsOptions = {
90
+ /** Metres a ray travels before giving up (default 3). */
91
+ maxDistance?: number
92
+ /** Metres a surface is assumed to extend behind its depth — a hit inside it counts (default 0.1). */
93
+ thickness?: number
94
+ /** Pixels per ray step: 1 = exact and slow, 2..4 the usual (default 2). */
95
+ stride?: number
96
+ }
97
+
98
+ /** Depth of field: the camera focuses at `focusDistance` and everything else blurs as the lens
99
+ * would — as if it were at f/`aperture`, WITHOUT touching the exposure (the camera's own aperture,
100
+ * f/16 by default, would show next to no blur; the engine scales the blur instead of opening the
101
+ * lens). The physics are honest and therefore mild: a game camera is a WIDE lens (fov 60° is a
102
+ * 21 mm lens), and a wide lens at f/1.4 still blurs a prop 9 m behind a 2.5 m focus by only ~2 px
103
+ * of 600 — `strength` multiplies the blur for the look a 50 mm lens would give (3-4 reads as
104
+ * "cinematic" at fov 60°). All live: a focus pull is a `focusDistance` written every frame. */
105
+ export type DepthOfFieldOptions = {
106
+ /** Metres from the camera to the plane in focus (default 10). */
107
+ focusDistance?: number
108
+ /** The f-number the blur should look like (default 2.8; smaller = shallower). */
109
+ aperture?: number
110
+ /** Multiplier on the blur (default 1 = the physics of the camera's lens). */
111
+ strength?: number
112
+ }
113
+
114
+ export type WhiteBalance = {
115
+ /** Blue / yellow axis, -1 (cool, 50 000 K) .. 1 (warm, 2 000 K); 0 = none. */
116
+ temperature?: number
117
+ /** Green / magenta axis, -1 .. 1; 0 = none. */
118
+ tint?: number
119
+ }
120
+
121
+ /** Where the tonal zones of `shadows` / `midtones` / `highlights` fade into each other, in 0..1 of
122
+ * the (linear) luminance: `shadows` = (start, end) of the shadows → midtones fade (default 0, 0.333),
123
+ * `highlights` = (start, end) of the midtones → highlights fade (default 0.55, 1). */
124
+ export type TonalRanges = {
125
+ shadows?: readonly [number, number]
126
+ highlights?: readonly [number, number]
127
+ }
128
+
129
+ /** Per-channel curves: a gamma on the shadows, the point where shadows stop and highlights start,
130
+ * and a scale on the highlights — each per RGB channel, `[1, 1, 1]` = none. */
131
+ export type ColorCurves = {
132
+ shadowGamma?: Rgb
133
+ midPoint?: Rgb
134
+ highlightScale?: Rgb
135
+ }
136
+
137
+ export type PostProcessingOptions = {
138
+ /** The operator that folds the scene's HDR light into the display's range (default 'aces').
139
+ * `'aces'` (filament's ACES legacy) desaturates bright colours towards white — HDR fire reads
140
+ * pale; `'neutral'` (Khronos PBR Neutral) keeps hue and saturation until very bright; `'linear'`
141
+ * clips each channel (what an engine without a tonemapper shows — saturated, Unity-without-post
142
+ * look); `'filmic'` is the Uncharted curve. */
143
+ toneMapping?: ToneMappingOperator
144
+ /** Post-exposure in stops, applied AFTER bloom to the finished picture (default 0). Not the
145
+ * camera's `exposureCompensation`, which scales the light itself — what bloom and fog see. */
146
+ exposure?: number
147
+ whiteBalance?: WhiteBalance
148
+ /** 0..2, 1 = none. Applied in log space. */
149
+ contrast?: number
150
+ /** 0..2, 1 = none. */
151
+ saturation?: number
152
+ /** Saturation that spares what is already saturated, 0..2, 1 = none. */
153
+ vibrance?: number
154
+ /** Linear RGB multiplier of the shadows zone, `[1, 1, 1]` = none. */
155
+ shadows?: Rgb
156
+ midtones?: Rgb
157
+ highlights?: Rgb
158
+ ranges?: TonalRanges
159
+ /** ASC CDL — the lift / gamma / gain of a grading suite, per channel, applied in log space:
160
+ * `slope` multiplies (gain; `[1, 1, 1]` = none), `offset` adds (lift; `[0, 0, 0]` = none),
161
+ * `power` is the exponent (gamma; `[1, 1, 1]` = none). */
162
+ slope?: Rgb
163
+ offset?: Rgb
164
+ power?: Rgb
165
+ curves?: ColorCurves
166
+ /** A 3D LUT from a grading tool (Resolve, Photoshop, Unity's Color Lookup) applied last, after
167
+ * the operator, in display-referred sRGB — a `.cube` file: `asset('./look.cube')`, or a bare
168
+ * staged filename. `null` removes it. */
169
+ lut?: string | null
170
+ /** `true` = defaults (intensity 0.2), an object = its knobs, `false` = off. */
171
+ bloom?: boolean | BloomOptions
172
+ /** `true` = defaults (a soft black edge), an object = its knobs, `false` = off. */
173
+ vignette?: boolean | VignetteOptions
174
+ /** `true` = defaults (radius 0.3 m), an object = its knobs, `false` = off. */
175
+ ambientOcclusion?: boolean | AmbientOcclusionOptions
176
+ /** `true` = defaults, an object = its knobs, `false` = off. */
177
+ screenSpaceReflections?: boolean | ScreenSpaceReflectionsOptions
178
+ /** `true` = focus at 10 m as f/2.8, an object = its knobs, `false` = off. */
179
+ depthOfField?: boolean | DepthOfFieldOptions
180
+ }
181
+
182
+ /** The float layout of `_creator.setColorGrading` — creator.h's ColorGradingParam. Indices never
183
+ * move; a new field goes at the end and COUNT grows. */
184
+ export const CG = {
185
+ TONE_MAPPING: 0, EXPOSURE: 1, TEMPERATURE: 2, TINT: 3, CONTRAST: 4, VIBRANCE: 5, SATURATION: 6,
186
+ SHADOWS: 7, MIDTONES: 10, HIGHLIGHTS: 13, RANGES: 16, SLOPE: 20, OFFSET: 23, POWER: 26,
187
+ SHADOW_GAMMA: 29, MID_POINT: 32, HIGHLIGHT_SCALE: 35, LUT: 38,
188
+ COUNT: 39,
189
+ } as const
190
+
191
+ const TONE_MAPPING_MODE: Record<ToneMappingOperator, number> = { aces: 0, neutral: 1, linear: 2, filmic: 3 }
192
+ const QUALITY = { low: 0, medium: 1, high: 2, ultra: 3 } as const
193
+ const NO_TEXTURE = 0xFFFFFFFF
194
+
195
+ const ONE: Rgb = [ 1, 1, 1 ]
196
+ const ZERO: Rgb = [ 0, 0, 0 ]
197
+ const rgb = (v: Rgb | undefined, d: Rgb): Rgb => v ? [ v[0], v[1], v[2] ] : d
198
+ const pair = (v: readonly [number, number] | undefined, d: readonly [number, number]): readonly [number, number] => v ? [ v[0], v[1] ] : d
199
+
200
+ /** The grading band, normalised: every field present. */
201
+ type Grading = {
202
+ toneMapping: ToneMappingOperator
203
+ exposure: number
204
+ temperature: number
205
+ tint: number
206
+ contrast: number
207
+ saturation: number
208
+ vibrance: number
209
+ shadows: Rgb
210
+ midtones: Rgb
211
+ highlights: Rgb
212
+ rangeShadows: readonly [number, number]
213
+ rangeHighlights: readonly [number, number]
214
+ slope: Rgb
215
+ offset: Rgb
216
+ power: Rgb
217
+ shadowGamma: Rgb
218
+ midPoint: Rgb
219
+ highlightScale: Rgb
220
+ lut: string | null
221
+ }
222
+
223
+ const defaultGrading = (): Grading => ({
224
+ toneMapping: "aces", exposure: 0, temperature: 0, tint: 0, contrast: 1, saturation: 1, vibrance: 1,
225
+ shadows: ONE, midtones: ONE, highlights: ONE, rangeShadows: [ 0, 0.333 ], rangeHighlights: [ 0.55, 1 ],
226
+ slope: ONE, offset: ZERO, power: ONE, shadowGamma: ONE, midPoint: ONE, highlightScale: ONE, lut: null,
227
+ })
228
+
229
+ const BLOOM_DEFAULTS: Required<BloomOptions> = { intensity: 0.2, threshold: true, highlight: 1000, levels: 6, quality: "medium", lensFlare: false, dirt: null, dirtStrength: 0.2 }
230
+ const VIGNETTE_DEFAULTS: Required<VignetteOptions> = { midPoint: 0.5, roundness: 0.5, feather: 0.5, color: "#000000" }
231
+ const AO_DEFAULTS: Required<AmbientOcclusionOptions> = { intensity: 1, radius: 0.3, power: 1, quality: "medium" }
232
+ const SSR_DEFAULTS: Required<ScreenSpaceReflectionsOptions> = { maxDistance: 3, thickness: 0.1, stride: 2 }
233
+ const DOF_DEFAULTS: Required<DepthOfFieldOptions> = { focusDistance: 10, aperture: 2.8, strength: 1 }
234
+
235
+ /** A live feature's new state from a write: `false` = off, `true` = on (the current knobs, or the
236
+ * defaults when it was off), an object = on with THOSE knobs changed. */
237
+ const merge = <T extends object>(current: Required<T> | null, v: boolean | T, defaults: Required<T>): Required<T> | null =>
238
+ v === false ? null : { ...(current ?? defaults), ...(v === true ? {} : v) }
239
+
240
+ const q = (v: number): number => Math.round(v * 1000) / 1000
241
+ const equal = (a: Float32Array, b: Float32Array): boolean => { for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true }
242
+ let identityPacked: Float32Array | null = null
243
+ const isIdentity = (p: Float32Array): boolean => equal(p, identityPacked ??= new PostProcessing(0)._pack())
244
+
245
+ /** The scene's post-processing (`scene.postProcessing`). Read a field for the current value, write
246
+ * one to change it; `apply(options)` writes the named ones; `reset()` is the defaults. */
247
+ export class PostProcessing {
248
+ private _g: Grading = defaultGrading()
249
+ private _bloom: Required<BloomOptions> | null = null
250
+ private _vignette: Required<VignetteOptions> | null = null
251
+ private _ao: Required<AmbientOcclusionOptions> | null = null
252
+ private _ssr: Required<ScreenSpaceReflectionsOptions> | null = null
253
+ private _dof: Required<DepthOfFieldOptions> | null = null
254
+ private _dirty = false
255
+ private _lutDirty = false
256
+ private _sentLut: string | null = null
257
+ private _sent: Float32Array | null = null // the last array the engine got; a scene nobody graded sends none
258
+
259
+ private readonly _sceneId: number
260
+ /** @internal */
261
+ constructor(sceneId: number) { this._sceneId = sceneId }
262
+
263
+ /** Write the fields named in `options`, nothing else — at every level: `{ bloom: { intensity:
264
+ * 0.4 } }` changes one bloom knob, `{ whiteBalance: { tint: 0.1 } }` keeps the temperature.
265
+ * The grading band goes to the engine at once (one bake), not in a microtask. */
266
+ apply(options: PostProcessingOptions): this {
267
+ if (options.toneMapping !== undefined) this.toneMapping = options.toneMapping
268
+ if (options.exposure !== undefined) this.exposure = options.exposure
269
+ if (options.whiteBalance !== undefined) this.whiteBalance = options.whiteBalance
270
+ if (options.contrast !== undefined) this.contrast = options.contrast
271
+ if (options.saturation !== undefined) this.saturation = options.saturation
272
+ if (options.vibrance !== undefined) this.vibrance = options.vibrance
273
+ if (options.shadows !== undefined) this.shadows = options.shadows
274
+ if (options.midtones !== undefined) this.midtones = options.midtones
275
+ if (options.highlights !== undefined) this.highlights = options.highlights
276
+ if (options.ranges !== undefined) this.ranges = options.ranges
277
+ if (options.slope !== undefined) this.slope = options.slope
278
+ if (options.offset !== undefined) this.offset = options.offset
279
+ if (options.power !== undefined) this.power = options.power
280
+ if (options.curves !== undefined) this.curves = options.curves
281
+ if (options.lut !== undefined) this.lut = options.lut
282
+ this.flush()
283
+ if (options.bloom !== undefined) this.bloom = options.bloom
284
+ if (options.vignette !== undefined) this.vignette = options.vignette
285
+ if (options.ambientOcclusion !== undefined) this.ambientOcclusion = options.ambientOcclusion
286
+ if (options.screenSpaceReflections !== undefined) this.screenSpaceReflections = options.screenSpaceReflections
287
+ if (options.depthOfField !== undefined) this.depthOfField = options.depthOfField
288
+ return this
289
+ }
290
+
291
+ /** Every field back to its default: the engine's own look, every live feature off. */
292
+ reset(): this {
293
+ const g = defaultGrading()
294
+ this._lutDirty = g.lut !== this._sentLut
295
+ this._g = g
296
+ this._dirty = true
297
+ this.flush()
298
+ if (this._bloom) this.bloom = false
299
+ if (this._vignette) this.vignette = false
300
+ if (this._ao) this.ambientOcclusion = false
301
+ if (this._ssr) this.screenSpaceReflections = false
302
+ if (this._dof) this.depthOfField = false
303
+ return this
304
+ }
305
+
306
+ // ---- the grading band: coalesced, quantised, baked by the engine ----
307
+
308
+ get toneMapping(): ToneMappingOperator { return this._g.toneMapping }
309
+ set toneMapping(v: ToneMappingOperator) { this._g.toneMapping = v; this._touch() }
310
+ get exposure(): number { return this._g.exposure }
311
+ set exposure(v: number) { this._g.exposure = v; this._touch() }
312
+ get whiteBalance(): Required<WhiteBalance> { return { temperature: this._g.temperature, tint: this._g.tint } }
313
+ set whiteBalance(v: WhiteBalance) {
314
+ if (v.temperature !== undefined) this._g.temperature = v.temperature
315
+ if (v.tint !== undefined) this._g.tint = v.tint
316
+ this._touch()
317
+ }
318
+ get contrast(): number { return this._g.contrast }
319
+ set contrast(v: number) { this._g.contrast = v; this._touch() }
320
+ get saturation(): number { return this._g.saturation }
321
+ set saturation(v: number) { this._g.saturation = v; this._touch() }
322
+ get vibrance(): number { return this._g.vibrance }
323
+ set vibrance(v: number) { this._g.vibrance = v; this._touch() }
324
+ get shadows(): Rgb { return this._g.shadows }
325
+ set shadows(v: Rgb) { this._g.shadows = rgb(v, ONE); this._touch() }
326
+ get midtones(): Rgb { return this._g.midtones }
327
+ set midtones(v: Rgb) { this._g.midtones = rgb(v, ONE); this._touch() }
328
+ get highlights(): Rgb { return this._g.highlights }
329
+ set highlights(v: Rgb) { this._g.highlights = rgb(v, ONE); this._touch() }
330
+ get ranges(): Required<TonalRanges> { return { shadows: this._g.rangeShadows, highlights: this._g.rangeHighlights } }
331
+ set ranges(v: TonalRanges) {
332
+ this._g.rangeShadows = pair(v.shadows, this._g.rangeShadows)
333
+ this._g.rangeHighlights = pair(v.highlights, this._g.rangeHighlights)
334
+ this._touch()
335
+ }
336
+ get slope(): Rgb { return this._g.slope }
337
+ set slope(v: Rgb) { this._g.slope = rgb(v, ONE); this._touch() }
338
+ get offset(): Rgb { return this._g.offset }
339
+ set offset(v: Rgb) { this._g.offset = rgb(v, ZERO); this._touch() }
340
+ get power(): Rgb { return this._g.power }
341
+ set power(v: Rgb) { this._g.power = rgb(v, ONE); this._touch() }
342
+ get curves(): Required<ColorCurves> { return { shadowGamma: this._g.shadowGamma, midPoint: this._g.midPoint, highlightScale: this._g.highlightScale } }
343
+ set curves(v: ColorCurves) {
344
+ this._g.shadowGamma = rgb(v.shadowGamma, this._g.shadowGamma)
345
+ this._g.midPoint = rgb(v.midPoint, this._g.midPoint)
346
+ this._g.highlightScale = rgb(v.highlightScale, this._g.highlightScale)
347
+ this._touch()
348
+ }
349
+ get lut(): string | null { return this._g.lut }
350
+ set lut(v: string | null) { this._g.lut = v; this._lutDirty = v !== this._sentLut; this._touch() }
351
+
352
+ // ---- the live band: view options, written at once ----
353
+
354
+ get bloom(): Required<BloomOptions> | false { return this._bloom ?? false }
355
+ set bloom(v: boolean | BloomOptions) {
356
+ const b = this._bloom = merge(this._bloom, v, BLOOM_DEFAULTS)
357
+ if (b) {
358
+ _creator.setBloomOptions(this._sceneId, true, b.intensity, b.threshold, b.highlight, b.levels,
359
+ QUALITY[b.quality], b.lensFlare, b.dirt ? b.dirt._h.id : NO_TEXTURE, b.dirtStrength)
360
+ } else {
361
+ _creator.setBloomOptions(this._sceneId, false, 0, true, 1000, 6, 1, false, NO_TEXTURE, 0)
362
+ }
363
+ }
364
+
365
+ get vignette(): Required<VignetteOptions> | false { return this._vignette ?? false }
366
+ set vignette(v: boolean | VignetteOptions) {
367
+ const g = this._vignette = merge(this._vignette, v, VIGNETTE_DEFAULTS)
368
+ if (g) _creator.setVignetteOptions?.(this._sceneId, true, g.midPoint, g.roundness, g.feather, Color.toPackedRgba(g.color))
369
+ else _creator.setVignetteOptions?.(this._sceneId, false, 0.5, 0.5, 0.5, 0x000000FF)
370
+ }
371
+
372
+ get ambientOcclusion(): Required<AmbientOcclusionOptions> | false { return this._ao ?? false }
373
+ set ambientOcclusion(v: boolean | AmbientOcclusionOptions) {
374
+ const o = this._ao = merge(this._ao, v, AO_DEFAULTS)
375
+ if (o) _creator.setAmbientOcclusionOptions?.(this._sceneId, true, o.intensity, o.radius, o.power, QUALITY[o.quality])
376
+ else _creator.setAmbientOcclusionOptions?.(this._sceneId, false, 1, 0.3, 1, 1)
377
+ }
378
+
379
+ get screenSpaceReflections(): Required<ScreenSpaceReflectionsOptions> | false { return this._ssr ?? false }
380
+ set screenSpaceReflections(v: boolean | ScreenSpaceReflectionsOptions) {
381
+ const o = this._ssr = merge(this._ssr, v, SSR_DEFAULTS)
382
+ if (o) _creator.setScreenSpaceReflections?.(this._sceneId, true, o.maxDistance, o.thickness, o.stride)
383
+ else _creator.setScreenSpaceReflections?.(this._sceneId, false, 3, 0.1, 2)
384
+ }
385
+
386
+ get depthOfField(): Required<DepthOfFieldOptions> | false { return this._dof ?? false }
387
+ set depthOfField(v: boolean | DepthOfFieldOptions) {
388
+ const o = this._dof = merge(this._dof, v, DOF_DEFAULTS)
389
+ if (o) _creator.setDepthOfField?.(this._sceneId, true, o.focusDistance, o.aperture, o.strength)
390
+ else _creator.setDepthOfField?.(this._sceneId, false, 0, 0, 1)
391
+ }
392
+
393
+ // ---- the flush ----
394
+
395
+ private _touch(): void {
396
+ if (this._dirty) return
397
+ this._dirty = true
398
+ Promise.resolve().then(() => this.flush())
399
+ }
400
+
401
+ /** Send the grading band now (it is sent on its own at the end of the current handler). */
402
+ flush(): void {
403
+ if (!this._dirty) return
404
+ this._dirty = false
405
+ const g = this._g
406
+ if (this._lutDirty) {
407
+ // The LUT goes first, under the OLD flag, so the engine stores it and bakes ONCE on the
408
+ // parameter write that follows (creator.h setColorLut). Removal is the flag alone.
409
+ this._lutDirty = false
410
+ this._sentLut = g.lut
411
+ if (g.lut !== null) {
412
+ // `asset()` hands back "id:<n>" (already staged); a bare name is resolved on the spot.
413
+ const id = g.lut.startsWith("id:") ? Number(g.lut.slice(3)) : _creatorFetch.local(g.lut)
414
+ _creator.setColorLut?.(this._sceneId, id)
415
+ }
416
+ }
417
+ const packed = this._pack()
418
+ if (this._sent === null && isIdentity(packed)) return // never graded and still the default: nothing to say
419
+ if (this._sent !== null && equal(this._sent, packed)) return
420
+ this._sent = packed
421
+ _creator.setColorGrading?.(this._sceneId, packed)
422
+ }
423
+
424
+ /** @internal The wire form of the grading band (tests). */
425
+ _pack(): Float32Array {
426
+ const g = this._g
427
+ const p = new Float32Array(CG.COUNT)
428
+ const put3 = (at: number, v: Rgb) => { p[at] = q(v[0]); p[at + 1] = q(v[1]); p[at + 2] = q(v[2]) }
429
+ p[CG.TONE_MAPPING] = TONE_MAPPING_MODE[g.toneMapping] ?? 0
430
+ p[CG.EXPOSURE] = q(g.exposure)
431
+ p[CG.TEMPERATURE] = q(g.temperature)
432
+ p[CG.TINT] = q(g.tint)
433
+ p[CG.CONTRAST] = q(g.contrast)
434
+ p[CG.VIBRANCE] = q(g.vibrance)
435
+ p[CG.SATURATION] = q(g.saturation)
436
+ put3(CG.SHADOWS, g.shadows)
437
+ put3(CG.MIDTONES, g.midtones)
438
+ put3(CG.HIGHLIGHTS, g.highlights)
439
+ p[CG.RANGES] = q(g.rangeShadows[0]); p[CG.RANGES + 1] = q(g.rangeShadows[1])
440
+ p[CG.RANGES + 2] = q(g.rangeHighlights[0]); p[CG.RANGES + 3] = q(g.rangeHighlights[1])
441
+ put3(CG.SLOPE, g.slope)
442
+ put3(CG.OFFSET, g.offset)
443
+ put3(CG.POWER, g.power)
444
+ put3(CG.SHADOW_GAMMA, g.shadowGamma)
445
+ put3(CG.MID_POINT, g.midPoint)
446
+ put3(CG.HIGHLIGHT_SCALE, g.highlightScale)
447
+ p[CG.LUT] = g.lut !== null ? 1 : 0
448
+ return p
449
+ }
450
+ }
@@ -27,11 +27,50 @@ type AppEventMap = {
27
27
  keyboard: (height: number, duration: number) => void
28
28
  }
29
29
 
30
+ /** The installed app this bundle runs in — see `app.host`. */
31
+ export interface AppHost {
32
+ /** The store build's version string (Android versionName, iOS CFBundleShortVersionString; app.json
33
+ * `version` in a `lecodes app` shell). `undefined` where the host is not an installed app. */
34
+ version?: string
35
+ /** The store build's build number (versionCode / CFBundleVersion; app.json `buildNumber` /
36
+ * `versionCode`). `undefined` where the host is not an installed app, `0` when not numeric. */
37
+ build?: number
38
+ /** The SDK embedded in the host — the lecodes-ios-sdk / lecodes-android-sdk release a shell pins,
39
+ * the runtime's version elsewhere. */
40
+ sdk: string
41
+ }
42
+
43
+ /** The bundle that is running — see `app.bundle`. */
44
+ export interface AppBundle {
45
+ /** The SDK this bundle was compiled with (`app.sdkVersion`). */
46
+ sdk: string
47
+ /** When it was compiled (ISO 8601) — the `// last-updated:` header line; `undefined` on a bundle
48
+ * without it. */
49
+ updatedAt?: string
50
+ }
51
+
30
52
  export const app = {
31
53
  /** The SDK this app was compiled with (semver, e.g. `"2.0.0"`). Its major is the bundle ↔ runtime
32
54
  * contract: a host runs only bundles of its own major, and the launchers send it as `?sdk=` when
33
- * they fetch a published bundle — the platform keeps one bundle per major. */
55
+ * they fetch a published bundle — the platform keeps one bundle per major. Also `app.bundle.sdk`. */
34
56
  sdkVersion: SDK_VERSION,
57
+ /** The INSTALLED app: the store build this bundle runs in. After an over-the-air update the code
58
+ * is newer than the binary, so a feature that needs native support added in a later store build
59
+ * (a plugin, an SDK method) branches on `app.host.sdk` / `app.host.build` — and on `typeof` of
60
+ * the method itself, which is the final word. Minor and patch of the SDK never gate anything. */
61
+ get host(): AppHost {
62
+ return {
63
+ version: _creatorApp.appVersion?.(),
64
+ build: _creatorApp.appBuild?.(),
65
+ sdk: _creatorApp.runtimeVersion?.() ?? SDK_VERSION,
66
+ }
67
+ },
68
+ /** The RUNNING code: what the compiler stamped into this bundle's header. `updatedAt` is the
69
+ * compile time — what the updater compares before replacing a bundle (an older one never
70
+ * replaces a newer one), useful for an "about" screen or a support log. */
71
+ get bundle(): AppBundle {
72
+ return { sdk: SDK_VERSION, updatedAt: _creatorApp.bundleUpdatedAt?.() ?? undefined }
73
+ },
35
74
  /** Current lifecycle state. `"background"` while the app is not the foreground app / the tab is
36
75
  * hidden. `"active"` on hosts that don't track it. */
37
76
  get state(): AppState {
@@ -339,6 +339,7 @@ export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>,
339
339
  Object.defineProperties(db, {
340
340
  $schema: { get: () => seal().schema },
341
341
  $models: { get: () => seal().models },
342
+ $given: { get: () => own },
342
343
  $auth: { get: () => seal().auth },
343
344
  $files: { get: () => seal().files },
344
345
  })
@@ -203,7 +203,11 @@ type DbBase<S extends Schema> = {
203
203
  $transaction<P extends readonly Op<any>[]>(ops: [...P]): Promise<{ [K in keyof P]: P[K] extends Op<infer T> ? T : never }>
204
204
  /** The `.marci` schema text this db syncs with. */
205
205
  readonly $schema: string
206
- /** @internal */ readonly $models: Record<string, Model>
206
+ /** @internal the models as the schema has them — a model with a `t.file()` field carries the files' relation. */
207
+ readonly $models: Record<string, Model>
208
+ /** @internal the models as `defineDb` received them, by their names here — the objects the project holds,
209
+ * so a host can tell which name the project gave a model it knows by identity. */
210
+ readonly $given: Record<string, Model>
207
211
  /** @internal the collections sign-in works on; null = a db without `withAuth`. */
208
212
  readonly $auth: AuthBinding | null
209
213
  /** @internal the schema's `t.file()` fields (../files/models.ts). */
@@ -40,9 +40,12 @@ export const loadServerModules = (
40
40
  if (!reg) throw new Error("loadServerModules: no server bundle registered (globalThis.__lecodesServer)")
41
41
  const out: LoadedServer = { endpoints: new Map(), channels: new Map(), dbs: [], manifest: reg.manifest }
42
42
  for (const [mod, exports] of Object.entries(reg.modules)) {
43
+ // the compile's word on each export (serverSplit.ts): a function that is not async is the
44
+ // server's helper, never an endpoint — an old manifest without kinds counts every function
45
+ const kinds = new Map((reg.manifest?.modules?.[mod] ?? []).map(e => [e.name, e.kind]))
43
46
  for (const [name, value] of Object.entries(exports)) {
44
47
  const id = `${mod}#${name}`
45
- if (typeof value === "function") out.endpoints.set(id, value as (...args: any[]) => unknown)
48
+ if (typeof value === "function") { if (kinds.get(name) !== "other") out.endpoints.set(id, value as (...args: any[]) => unknown) }
46
49
  else if (isChannel(value)) { value.__id = id; out.channels.set(id, value) }
47
50
  else if (isDb(value)) out.dbs.push(value)
48
51
  }
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.12"
7
+ export const SDK_VERSION = "2.1.0"
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 => {
@@ -15,13 +15,22 @@ export const storageStub: any = {}
15
15
  export const mediaStub: any = {}
16
16
  export const inputStub: any = {}
17
17
 
18
- const g = globalThis as any
19
- g._creatorApp = appStub
20
- g._creatorDevice = deviceStub
21
- g._creatorFetch = fetchStub
22
- g._creatorStorage = storageStub
23
- g._creatorMedia = mediaStub
24
- g._creatorInput = inputStub
18
+ // Installed as accessors whose setter THROWS: a file that assigns the global (instead of mutating
19
+ // the stub) fails on its own line, not fifteen files later in whoever reads `_creatorFetch.fetch`
20
+ // next (the symptom of 2026-10-10: a replaced `_creatorFetch` broke rpc, channel, image and scene
21
+ // tests under every other seed).
22
+ const tables: [string, any][] = [
23
+ ["_creatorApp", appStub], ["_creatorDevice", deviceStub], ["_creatorFetch", fetchStub],
24
+ ["_creatorStorage", storageStub], ["_creatorMedia", mediaStub], ["_creatorInput", inputStub],
25
+ ]
26
+ for (const [name, stub] of tables) {
27
+ Object.defineProperty(globalThis, name, {
28
+ get: () => stub,
29
+ set: () => { throw new Error(`${name} is a shared stub: mutate it (Object.assign / set a member, resetHostStubs()), never replace the global — tests/helpers/hostStubs.ts`) },
30
+ configurable: false,
31
+ enumerable: true,
32
+ })
33
+ }
25
34
 
26
35
  /** Empty every stub table (the members a previous test / file installed). */
27
36
  export const resetHostStubs = () => {