lecodes-cli 0.6.2 → 0.6.4

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,331 @@
1
+ // Locale-aware dates, dayjs-style: `date(value).format(...)`. Pure JS (no `Intl` — QuickJS hosts
2
+ // don't ship it reliably), so output is byte-identical on every platform. Two locales: English and
3
+ // Russian.
4
+ //
5
+ // ── Designed for chisel fusion ──────────────────────────────────────────────────────────────────
6
+ // A date is a *single-scalar* value (epoch milliseconds), which is the 1-component analog of a Vec3
7
+ // chain. The wrapper is written so a future chisel date-fuser can lower `date(root).<chain>()` to
8
+ // scalar ms arithmetic and drop the wrapper allocation, the same way Vec3 chains fuse:
9
+ // • IMMUTABLE — every method returns a NEW `DateValue`, never mutates `t` (aliasing safety).
10
+ // • ONE canonical scalar — `t` (epoch ms), also reachable via `valueOf()`; that's the "component".
11
+ // • Terminals DELEGATE to the module-private free functions `formatImpl` / `timeAgoImpl`, taking
12
+ // `(ms, …)`. So `date(x).format(p)` can fuse to `formatImpl(toMs(x), p)` with no wrapper.
13
+ // • Linear-unit arithmetic (ms…week) is closed-form scalar math → fusable; calendar units
14
+ // (month/year) and local-tz `startOf`/`endOf` use `Date` and are left to the real method.
15
+ // Even before that pass lands, each method is a class method, so chisel's method-granular DCE drops
16
+ // whichever ones a project doesn't use.
17
+
18
+ export type Locale = "en" | "ru"
19
+ export type DateInput = Date | number | string | DateValue
20
+ export type Unit = "ms" | "second" | "minute" | "hour" | "day" | "week" | "month" | "year"
21
+
22
+ // ---- locale data -------------------------------------------------------------------------------
23
+ // Russian needs the month in two grammatical cases: genitive ("5 сентября") when a day number is
24
+ // present, nominative ("Сентябрь 2024") when it stands alone. `format` picks by whether the pattern
25
+ // contains a day (`D`) token — the same rule day.js's ru locale uses.
26
+
27
+ type LocaleData = {
28
+ months: readonly string[]
29
+ monthsGenitive: readonly string[]
30
+ monthsShort: readonly string[]
31
+ weekdays: readonly string[] // Sunday..Saturday (JS getDay() order)
32
+ weekdaysShort: readonly string[]
33
+ am: string
34
+ pm: string
35
+ }
36
+
37
+ const EN: LocaleData = {
38
+ months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
39
+ monthsGenitive: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
40
+ monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
41
+ weekdays: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
42
+ weekdaysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
43
+ am: "AM",
44
+ pm: "PM",
45
+ }
46
+
47
+ const RU: LocaleData = {
48
+ months: ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"],
49
+ monthsGenitive: ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"],
50
+ monthsShort: ["янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"],
51
+ weekdays: ["Воскресенье", "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"],
52
+ weekdaysShort: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"],
53
+ am: "AM",
54
+ pm: "PM",
55
+ }
56
+
57
+ const DATA: Record<Locale, LocaleData> = { en: EN, ru: RU }
58
+
59
+ const resolveLocale = (locale?: Locale): Locale => {
60
+ if (locale) return locale === "ru" ? "ru" : "en"
61
+ const lang = typeof _creatorUtils !== "undefined" ? _creatorUtils.language : ""
62
+ return lang && lang.toLowerCase().indexOf("ru") === 0 ? "ru" : "en"
63
+ }
64
+
65
+ // Milliseconds per linear unit. Absent keys (month/year) mark the non-fusable calendar units.
66
+ const MS: Partial<Record<Unit, number>> = {
67
+ ms: 1,
68
+ second: 1000,
69
+ minute: 60_000,
70
+ hour: 3_600_000,
71
+ day: 86_400_000,
72
+ week: 604_800_000,
73
+ }
74
+
75
+ const toMs = (value: DateInput): number => {
76
+ if (value instanceof DateValue) return value.t
77
+ if (value instanceof Date) return value.getTime()
78
+ if (typeof value === "number") return value
79
+ return new Date(value).getTime()
80
+ }
81
+
82
+ // ---- formatting (the fusion escape target: takes a scalar ms, holds the heavy tables) -----------
83
+
84
+ const pad2 = (n: number): string => (n < 10 ? "0" + n : "" + n)
85
+
86
+ // Longest tokens first so `MMMM` matches before `MM`, `YYYY` before `YY`, etc.
87
+ const TOKEN = /YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|A|a/g
88
+
89
+ const formatImpl = (ms: number, pattern: string, locale?: Locale): string => {
90
+ const d = new Date(ms)
91
+ const L = DATA[resolveLocale(locale)]
92
+ const hasDay = /D/.test(pattern) // genitive month only when a day number accompanies it
93
+ const months = hasDay ? L.monthsGenitive : L.months
94
+
95
+ const year = d.getFullYear()
96
+ const month = d.getMonth()
97
+ const day = d.getDate()
98
+ const weekday = d.getDay()
99
+ const hours = d.getHours()
100
+ const hour12 = hours % 12 || 12
101
+ const minutes = d.getMinutes()
102
+ const seconds = d.getSeconds()
103
+
104
+ return pattern.replace(TOKEN, (t) => {
105
+ switch (t) {
106
+ case "YYYY": return "" + year
107
+ case "YY": return pad2(year % 100)
108
+ case "MMMM": return months[month]
109
+ case "MMM": return L.monthsShort[month]
110
+ case "MM": return pad2(month + 1)
111
+ case "M": return "" + (month + 1)
112
+ case "DD": return pad2(day)
113
+ case "D": return "" + day
114
+ case "dddd": return L.weekdays[weekday]
115
+ case "ddd": return L.weekdaysShort[weekday]
116
+ case "HH": return pad2(hours)
117
+ case "H": return "" + hours
118
+ case "hh": return pad2(hour12)
119
+ case "h": return "" + hour12
120
+ case "mm": return pad2(minutes)
121
+ case "m": return "" + minutes
122
+ case "ss": return pad2(seconds)
123
+ case "s": return "" + seconds
124
+ case "A": return hours < 12 ? L.am : L.pm
125
+ case "a": return (hours < 12 ? L.am : L.pm).toLowerCase()
126
+ default: return t
127
+ }
128
+ })
129
+ }
130
+
131
+ // Russian plural picks one of three forms by the number: 1 минута / 2 минуты / 5 минут.
132
+ const ruPlural = (n: number, one: string, few: string, many: string): string => {
133
+ const mod10 = n % 10
134
+ const mod100 = n % 100
135
+ if (mod10 === 1 && mod100 !== 11) return one
136
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few
137
+ return many
138
+ }
139
+
140
+ type RelUnit = { limit: number; secs: number; en: [string, string]; ru: [string, string, string] }
141
+
142
+ // Ordered thresholds. `limit` is the upper bound (in seconds) this unit covers; `secs` is the unit's
143
+ // length used to compute the count.
144
+ const REL_UNITS: readonly RelUnit[] = [
145
+ { limit: 60, secs: 1, en: ["second", "seconds"], ru: ["секунду", "секунды", "секунд"] },
146
+ { limit: 3600, secs: 60, en: ["minute", "minutes"], ru: ["минуту", "минуты", "минут"] },
147
+ { limit: 86400, secs: 3600, en: ["hour", "hours"], ru: ["час", "часа", "часов"] },
148
+ { limit: 2592000, secs: 86400, en: ["day", "days"], ru: ["день", "дня", "дней"] },
149
+ { limit: 31536000, secs: 2592000, en: ["month", "months"], ru: ["месяц", "месяца", "месяцев"] },
150
+ { limit: Infinity, secs: 31536000, en: ["year", "years"], ru: ["год", "года", "лет"] },
151
+ ]
152
+
153
+ const timeAgoImpl = (ms: number, locale: Locale | undefined, nowMs: number): string => {
154
+ const loc = resolveLocale(locale)
155
+ const diffSecs = (nowMs - ms) / 1000
156
+ const future = diffSecs < 0
157
+ const abs = Math.abs(diffSecs)
158
+
159
+ if (abs < 45) return loc === "ru" ? "только что" : "just now"
160
+
161
+ let count = 1
162
+ let unit = REL_UNITS[REL_UNITS.length - 1]
163
+ for (const u of REL_UNITS) {
164
+ if (abs < u.limit) { unit = u; count = Math.max(1, Math.round(abs / u.secs)); break }
165
+ }
166
+
167
+ if (loc === "ru") {
168
+ const word = ruPlural(count, unit.ru[0], unit.ru[1], unit.ru[2])
169
+ return future ? `через ${count} ${word}` : `${count} ${word} назад`
170
+ }
171
+ const word = count === 1 ? unit.en[0] : unit.en[1]
172
+ return future ? `in ${count} ${word}` : `${count} ${word} ago`
173
+ }
174
+
175
+ // ---- the immutable wrapper ---------------------------------------------------------------------
176
+
177
+ /**
178
+ * An immutable moment in time — the value returned by [`date()`]. Wraps a single epoch-millisecond
179
+ * scalar (`t`); every method returns a new `DateValue` or a plain scalar, never mutating. Construct
180
+ * it with `date(...)`, not `new`.
181
+ */
182
+ export class DateValue {
183
+ /** Epoch milliseconds — the one canonical scalar. Prefer `valueOf()`; this is public so `date(a)`
184
+ * can read it back cheaply and so tooling can treat it as the value's single component. */
185
+ readonly t: number
186
+
187
+ constructor(ms: number) {
188
+ this.t = ms
189
+ }
190
+
191
+ // -- materialize (delegate to the free functions; the fuser can inline these to `*Impl(t, …)`) --
192
+
193
+ /**
194
+ * Format against a `day.js`-style token pattern (default `"D MMMM YYYY"`). Locale defaults to the
195
+ * current device language.
196
+ *
197
+ * Tokens: `YYYY`/`YY` year · `MMMM`/`MMM`/`MM`/`M` month · `DD`/`D` day · `dddd`/`ddd` weekday ·
198
+ * `HH`/`H` 24-hour · `hh`/`h` 12-hour · `mm`/`m` minute · `ss`/`s` second · `A`/`a` AM/PM.
199
+ *
200
+ * ```ts
201
+ * date().format() // "10 July 2026" / "10 июля 2026"
202
+ * date(ts).format("DD.MM.YYYY HH:mm") // "10.07.2026 14:05"
203
+ * date(ts).format("MMMM YYYY", "ru") // "Июль 2026" (nominative — no day token)
204
+ * ```
205
+ */
206
+ format(pattern = "D MMMM YYYY", locale?: Locale): string {
207
+ return formatImpl(this.t, pattern, locale)
208
+ }
209
+
210
+ /**
211
+ * Human relative time vs `now` (default: the current time) — "5 minutes ago", "in 2 days",
212
+ * "5 минут назад", "через 2 дня". Reads as "just now" under ~45 seconds; handles Russian number
213
+ * agreement (`1 минуту` / `2 минуты` / `5 минут`). Locale defaults to the device language.
214
+ */
215
+ timeAgo(locale?: Locale, now: DateInput = Date.now()): string {
216
+ return timeAgoImpl(this.t, locale, toMs(now))
217
+ }
218
+
219
+ // -- arithmetic (pure, chainable; linear units are closed-form scalar math → fusable) -----------
220
+
221
+ /** Add `n` of a unit, returning a new value. Linear units (`ms`…`week`) are scalar ms math;
222
+ * `month`/`year` are calendar-aware (clamp on short months, like day.js). */
223
+ add(n: number, unit: Unit): DateValue {
224
+ const ms = MS[unit]
225
+ if (ms !== undefined) return new DateValue(this.t + n * ms)
226
+ // Calendar units: shift month/year but clamp the day so Jan 31 + 1 month → Feb 28 (not Mar 3).
227
+ const d = new Date(this.t)
228
+ const day = d.getDate()
229
+ d.setDate(1)
230
+ if (unit === "year") d.setFullYear(d.getFullYear() + n)
231
+ else d.setMonth(d.getMonth() + n) // "month"
232
+ const daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
233
+ d.setDate(Math.min(day, daysInMonth))
234
+ return new DateValue(d.getTime())
235
+ }
236
+
237
+ /** Subtract `n` of a unit. Equivalent to `add(-n, unit)`. */
238
+ subtract(n: number, unit: Unit): DateValue {
239
+ return this.add(-n, unit)
240
+ }
241
+
242
+ /** Snap down to the start of a unit (local time): start of day/hour/month/year, etc. */
243
+ startOf(unit: Unit): DateValue {
244
+ const d = new Date(this.t)
245
+ switch (unit) {
246
+ case "year": d.setMonth(0)
247
+ // falls through
248
+ case "month": d.setDate(1)
249
+ // falls through
250
+ case "day": d.setHours(0, 0, 0, 0); break
251
+ case "hour": d.setMinutes(0, 0, 0); break
252
+ case "minute": d.setSeconds(0, 0); break
253
+ case "second": d.setMilliseconds(0); break
254
+ case "week": {
255
+ d.setHours(0, 0, 0, 0)
256
+ d.setDate(d.getDate() - d.getDay()) // week starts Sunday
257
+ break
258
+ }
259
+ }
260
+ return new DateValue(d.getTime())
261
+ }
262
+
263
+ /** Snap up to the end of a unit (local time): the last millisecond of the day/hour/month/… */
264
+ endOf(unit: Unit): DateValue {
265
+ return this.startOf(unit).add(1, unit).add(-1, "ms")
266
+ }
267
+
268
+ // -- scalar terminals (bare number / boolean → allocate nothing when fused) ---------------------
269
+
270
+ /** Epoch milliseconds. Enables `+date(x)`, `date(a) < date(b)`, `date(a) - date(b)` via coercion. */
271
+ valueOf(): number {
272
+ return this.t
273
+ }
274
+
275
+ /** Whole seconds since the epoch (Unix time). */
276
+ unix(): number {
277
+ return Math.floor(this.t / 1000)
278
+ }
279
+
280
+ /** Signed difference to `other`, in whole `unit`s (default `ms`), truncated toward zero. */
281
+ diff(other: DateInput, unit: Unit = "ms"): number {
282
+ const raw = this.t - toMs(other)
283
+ const per = MS[unit]
284
+ if (per !== undefined) return Math.trunc(raw / per)
285
+ // month/year: count calendar steps.
286
+ const a = new Date(this.t)
287
+ const b = new Date(toMs(other))
288
+ let months = (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth())
289
+ if (a.getDate() < b.getDate()) months -= Math.sign(months) || 0
290
+ return unit === "year" ? Math.trunc(months / 12) : months
291
+ }
292
+
293
+ isBefore(other: DateInput): boolean { return this.t < toMs(other) }
294
+ isAfter(other: DateInput): boolean { return this.t > toMs(other) }
295
+ isSame(other: DateInput): boolean { return this.t === toMs(other) }
296
+
297
+ // -- field accessors (scalar terminals) ---------------------------------------------------------
298
+
299
+ year(): number { return new Date(this.t).getFullYear() }
300
+ /** 0-based month (0 = January), matching `Date.getMonth`. */
301
+ month(): number { return new Date(this.t).getMonth() }
302
+ /** Day of the month, 1–31. */
303
+ day(): number { return new Date(this.t).getDate() }
304
+ /** Day of the week, 0 (Sunday)–6 (Saturday), matching `Date.getDay`. */
305
+ weekday(): number { return new Date(this.t).getDay() }
306
+ hour(): number { return new Date(this.t).getHours() }
307
+ minute(): number { return new Date(this.t).getMinutes() }
308
+ second(): number { return new Date(this.t).getSeconds() }
309
+
310
+ /** A native `Date` snapshot of this value. */
311
+ toDate(): Date {
312
+ return new Date(this.t)
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Create a `DateValue` — a locale-aware, immutable moment, dayjs-style. Accepts a `Date`, a
318
+ * millisecond timestamp (`Date.now()`), a parseable string, or another `DateValue`; with no argument
319
+ * it's the current time. Chain arithmetic and finish with `format` / `timeAgo`, both of which
320
+ * understand **English** and **Russian** and default to the current device language
321
+ * ([`device.language`](../runtime/device.ts)).
322
+ *
323
+ * ```ts
324
+ * date().format("dddd, D MMMM") // "Friday, 10 July" / "пятница, 10 июля"
325
+ * date(ts).timeAgo() // "5 minutes ago" / "5 минут назад"
326
+ * date().add(3, "day").startOf("day").format("D MMMM")
327
+ * date(a).diff(b, "hour") // signed whole hours
328
+ * date(a).isBefore(b) // boolean
329
+ * ```
330
+ */
331
+ export const date = (value: DateInput = Date.now()): DateValue => new DateValue(toMs(value))
@@ -0,0 +1,269 @@
1
+ // Scenes as data: the runtime behind `.scene.ts` files (docs/scene-editor-plan.md in the repo root).
2
+ //
3
+ // A scene file default-exports one `defineScene({...})` call whose argument is a plain literal —
4
+ // nodes keyed by name, each with one source block (mesh / model / light, or none = group), a
5
+ // transform, an optional material, `aspects: [use(Ctor, props), …]` and `children`. The visual
6
+ // editor parses and rewrites that literal; at runtime it lowers to ordinary SDK calls (Mesh.box,
7
+ // node.aspect, scene.add), so scenes run identically on every platform with no loader ABI.
8
+ //
9
+ // // city.scene.ts
10
+ // export default defineScene({
11
+ // env: { skybox: '#10131a' },
12
+ // nodes: {
13
+ // ground: {
14
+ // mesh: { kind: 'box', size: [20, 1, 20] },
15
+ // material: { lit: { color: '#444444' } },
16
+ // aspects: [use(Shape, { box: [10, 0.5, 10] }), use(Physics, { motion: 'static' })],
17
+ // },
18
+ // },
19
+ // })
20
+ //
21
+ // // main.ts
22
+ // import city from './city.scene'
23
+ // const { scene, nodes } = await city.open()
24
+ //
25
+ // Aspects are referenced by class — the import IS the registration (typechecked, DCE-safe, zero
26
+ // ceremony for user aspects). Behavior never lives in the scene file: write a custom Aspect and
27
+ // attach it via `use(...)`.
28
+ //
29
+ // EDIT MODE (`globalThis.__lecodesSceneEdit`, set by the scene editor before running the bundle):
30
+ // sources are instantiated for real so the viewport shows the scene, but aspects are held as data
31
+ // (`node._sceneAspects`) WITHOUT attaching — no onAttach side effects (physics bodies, timers), no
32
+ // update() ticks. Defined handles register on `globalThis.__lecodesScenes` for the host to pick up.
33
+
34
+ import { Aspect, type AspectCtor, type With } from "../core/Aspect"
35
+ import { describeAspect, type AspectClassInfo } from "../core/fields"
36
+ import type { ColorInput } from "../core/color"
37
+ import type { Vec3Like } from "../math/vec"
38
+ import { Scene, type SceneOptions } from "../gl/Scene"
39
+ import { Node } from "../gl/Node"
40
+ import { Mesh } from "../gl/Mesh"
41
+ import { Model } from "../gl/Model"
42
+ import { Light, type SunOptions } from "../gl/Light"
43
+ import { Material, type LitMaterialOptions, type MaterialColorOptions } from "../gl/Material"
44
+ import type { CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
45
+
46
+ // ---- the literal grammar (what the visual editor reads and writes) -----------
47
+
48
+ export type MeshDef =
49
+ | { kind: "box", size?: Vec3Like | number }
50
+ | ({ kind: "sphere" } & SphereOptions)
51
+ | ({ kind: "cylinder" } & CylinderOptions)
52
+ | ({ kind: "plane" } & PlaneOptions)
53
+
54
+ export type LightDef = { kind: "sun" } & SunOptions
55
+
56
+ export type MaterialDef =
57
+ | { lit: LitMaterialOptions }
58
+ | { unlit: MaterialColorOptions }
59
+ | { shadow: ColorInput }
60
+ // An imported/shared Material instance — valid at runtime; the editor shows it read-only.
61
+ | Material
62
+
63
+ /** One aspect to attach, created by `use(Ctor, props)`. */
64
+ export type AspectEntry<A extends Aspect<any, any> = Aspect<any, any>> = {
65
+ /** @internal grammar marker. */
66
+ readonly __use: true
67
+ ctor: AspectCtor<A>
68
+ props?: Partial<A>
69
+ }
70
+
71
+ /** Reference an aspect in a scene file: `aspects: [use(Physics, { motion: 'static' })]`. Props are
72
+ * typechecked against the aspect's fields, exactly like `node.aspect(Ctor, props)`. */
73
+ export const use = <A extends Aspect<any, any>>(ctor: AspectCtor<A>, props?: Partial<A>): AspectEntry<A> =>
74
+ ({ __use: true, ctor, props })
75
+
76
+ export type SceneNodeDef = {
77
+ // -- source (at most one; none = plain group node) --
78
+ mesh?: MeshDef
79
+ /** GLB url — `asset('./hero.glb')`. */
80
+ model?: string
81
+ light?: LightDef
82
+ /** Material for a `mesh` source. */
83
+ material?: MaterialDef
84
+ // -- transform / render --
85
+ position?: Vec3Like
86
+ eulerAngles?: Vec3Like
87
+ scale?: Vec3Like | number
88
+ visible?: boolean
89
+ castShadows?: boolean
90
+ receiveShadows?: boolean
91
+ // -- capabilities / hierarchy --
92
+ aspects?: readonly AspectEntry<any>[]
93
+ children?: Record<string, SceneNodeDef>
94
+ }
95
+
96
+ export type SceneCameraDef = {
97
+ position?: Vec3Like
98
+ /** Point the camera looks at. */
99
+ target?: Vec3Like
100
+ }
101
+
102
+ export type SceneDef = {
103
+ env?: SceneOptions
104
+ camera?: SceneCameraDef
105
+ nodes?: Record<string, SceneNodeDef>
106
+ }
107
+
108
+ // ---- typed handle -------------------------------------------------------------
109
+
110
+ type SourceNodeOf<N extends SceneNodeDef> =
111
+ N extends { model: string } ? Model
112
+ : N extends { mesh: MeshDef } ? Mesh
113
+ : N extends { light: LightDef } ? Light
114
+ : Node
115
+
116
+ type AspectsOf<N extends SceneNodeDef> =
117
+ N extends { aspects: readonly AspectEntry<infer A>[] } ? A : never
118
+
119
+ type NodeOf<N extends SceneNodeDef> =
120
+ [AspectsOf<N>] extends [never] ? SourceNodeOf<N> : With<SourceNodeOf<N>, AspectsOf<N>>
121
+
122
+ type UnionToIntersection<U> =
123
+ (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
124
+
125
+ // The child maps of a def level, as a union (never when no node has children — guarded below,
126
+ // since `unknown` would absorb the union and `never` would poison the intersection).
127
+ type ChildMapsOf<T extends Record<string, SceneNodeDef>> =
128
+ { [K in keyof T]: T[K] extends { children: infer C extends Record<string, SceneNodeDef> } ? NodesOf<C> : never }[keyof T]
129
+
130
+ // All nodes of a def tree, flattened into one name → node map (names are unique per scene file —
131
+ // the editor enforces it; at runtime a duplicate key simply overwrites in the map).
132
+ type NodesOf<T extends Record<string, SceneNodeDef>> =
133
+ { [K in keyof T]: NodeOf<T[K]> } &
134
+ ([ChildMapsOf<T>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T>>)
135
+
136
+ export type SceneNodes<D extends SceneDef> =
137
+ D["nodes"] extends Record<string, SceneNodeDef> ? NodesOf<D["nodes"]> : Record<string, Node>
138
+
139
+ export type LoadedScene<D extends SceneDef> = {
140
+ scene: Scene
141
+ nodes: SceneNodes<D>
142
+ }
143
+
144
+ // ---- runtime -------------------------------------------------------------------
145
+
146
+ const EDIT_FLAG = "__lecodesSceneEdit"
147
+ const isEditMode = (): boolean => (globalThis as Record<string, unknown>)[EDIT_FLAG] === true
148
+
149
+ const resolveMaterial = (def: MaterialDef): Material => {
150
+ if (def instanceof Material) return def
151
+ if ("lit" in def) return Material.lit(def.lit)
152
+ if ("unlit" in def) return Material.unlit(def.unlit)
153
+ return Material.shadow(def.shadow)
154
+ }
155
+
156
+ const createMesh = (def: MeshDef, material?: MaterialDef): Mesh => {
157
+ const mat = material !== undefined ? resolveMaterial(material) : undefined
158
+ switch (def.kind) {
159
+ case "box": return Mesh.box({ size: def.size, material: mat })
160
+ case "sphere": { const { kind: _k, ...opts } = def; return Mesh.sphere({ ...opts, material: mat }) }
161
+ case "cylinder": { const { kind: _k, ...opts } = def; return Mesh.cylinder({ ...opts, material: mat }) }
162
+ case "plane": { const { kind: _k, ...opts } = def; return Mesh.plane({ ...opts, material: mat }) }
163
+ }
164
+ }
165
+
166
+ const createSource = (name: string, def: SceneNodeDef): Node | Promise<Node> => {
167
+ const sources = [ def.mesh, def.model, def.light ].filter((s) => s !== undefined).length
168
+ if (sources > 1) throw new Error(`Scene node "${name}" declares more than one source (mesh/model/light)`)
169
+ if (def.model !== undefined) return Model.load(def.model)
170
+ if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
171
+ if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
172
+ return new Node()
173
+ }
174
+
175
+ const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
176
+ node.name = name
177
+ if (def.position) node.position = def.position
178
+ if (def.eulerAngles) node.eulerAngles = def.eulerAngles
179
+ if (def.scale !== undefined) node.scale = def.scale
180
+ if (def.visible !== undefined) node.visible = def.visible
181
+ if (node instanceof Mesh) {
182
+ if (def.castShadows !== undefined) node.castShadows = def.castShadows
183
+ if (def.receiveShadows !== undefined) node.receiveShadows = def.receiveShadows
184
+ }
185
+ }
186
+
187
+ const attachAspects = (node: Node, def: SceneNodeDef): void => {
188
+ if (isEditMode()) {
189
+ // Data-only: the inspector reads [ctor, props] from here; nothing attaches, so no onAttach side
190
+ // effects (physics bodies, loops) run while editing. Play mode = the normal branch below.
191
+ ;(node as unknown as { _sceneAspects: readonly AspectEntry<any>[] })._sceneAspects = def.aspects ?? []
192
+ return
193
+ }
194
+ for (const entry of def.aspects ?? []) {
195
+ ;(node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, entry.props)
196
+ }
197
+ }
198
+
199
+ const instantiate = async (def: SceneDef): Promise<{ scene: Scene, nodes: Record<string, Node> }> => {
200
+ const scene = new Scene(def.env)
201
+ const nodes: Record<string, Node> = {}
202
+
203
+ const build = async (name: string, nd: SceneNodeDef, parent: Node | null): Promise<void> => {
204
+ const node = await createSource(name, nd)
205
+ if (parent) parent.add(node)
206
+ // Draw-set membership is separate from parenting (see docs/3d/node.md) — every def node joins.
207
+ scene.add(node)
208
+ applyNode(node, name, nd)
209
+ attachAspects(node, nd)
210
+ nodes[name] = node
211
+ await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node)))
212
+ }
213
+
214
+ await Promise.all(Object.entries(def.nodes ?? {}).map(([ name, nd ]) => build(name, nd, null)))
215
+
216
+ if (def.camera) {
217
+ if (def.camera.position) scene.camera.position = def.camera.position
218
+ if (def.camera.target) scene.camera.lookAt(def.camera.target)
219
+ }
220
+
221
+ return { scene, nodes }
222
+ }
223
+
224
+ export class SceneHandle<D extends SceneDef = SceneDef> {
225
+ readonly def: D
226
+ private _loading?: Promise<LoadedScene<D>>
227
+
228
+ constructor(def: D) { this.def = def }
229
+
230
+ /** Instantiate the scene (idempotent — subsequent calls return the same instance). Does not open. */
231
+ load(): Promise<LoadedScene<D>> {
232
+ if (!this._loading) this._loading = instantiate(this.def) as Promise<LoadedScene<D>>
233
+ return this._loading
234
+ }
235
+
236
+ /** Load and make active. */
237
+ async open(): Promise<LoadedScene<D>> {
238
+ const loaded = await this.load()
239
+ loaded.scene.open()
240
+ return loaded
241
+ }
242
+
243
+ /** @internal Editor: describe every aspect class this scene references (fields + defaults). */
244
+ _describeAspects(): AspectClassInfo[] {
245
+ const ctors = new Set<AspectCtor<any>>()
246
+ const walk = (defs?: Record<string, SceneNodeDef>): void => {
247
+ for (const nd of Object.values(defs ?? {})) {
248
+ for (const e of nd.aspects ?? []) ctors.add(e.ctor)
249
+ walk(nd.children)
250
+ }
251
+ }
252
+ walk(this.def.nodes)
253
+ return [ ...ctors ].map((c) => describeAspect(c))
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Define a scene as data — the default export of a `.scene.ts` file. Returns a typed handle:
259
+ * `const { scene, nodes } = await handle.open()` gives `nodes.<name>` typed by its source block
260
+ * (Mesh / Model / Light / Node) with its `use(...)`d aspects attached.
261
+ */
262
+ export const defineScene = <const D extends SceneDef>(def: D): SceneHandle<D> => {
263
+ const handle = new SceneHandle(def)
264
+ const g = globalThis as unknown as { [EDIT_FLAG]?: boolean, __lecodesScenes?: SceneHandle[] }
265
+ // Editor hook: expose defined handles to the host (the scene editor runs the bundle, then picks
266
+ // up the handle to load it in edit mode and drive the inspector).
267
+ if (g[EDIT_FLAG]) (g.__lecodesScenes ??= []).push(handle as SceneHandle)
268
+ return handle
269
+ }