lecodes-sdk 0.20.2 → 1.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.
- package/dist/global.d.ts +35 -9
- package/dist/types/animate/tween/Animation.d.ts +69 -0
- package/dist/types/animate/tween/Timeline.d.ts +55 -0
- package/dist/types/animate/tween/animateValue.d.ts +27 -0
- package/dist/types/animate/tween/easing.d.ts +29 -0
- package/dist/types/animate/tween/spec.d.ts +178 -0
- package/dist/types/canvas/Canvas.d.ts +2 -0
- package/dist/types/g2/Node2D.d.ts +16 -0
- package/dist/types/g2/Sprite.d.ts +11 -1
- package/dist/types/gl/Camera.d.ts +15 -1
- package/dist/types/gl/DecalSet.d.ts +48 -3
- package/dist/types/gl/Foliage.d.ts +47 -0
- package/dist/types/gl/Geometry.d.ts +36 -0
- package/dist/types/gl/Light.d.ts +25 -7
- package/dist/types/gl/Lightmap.d.ts +90 -51
- package/dist/types/gl/Material.d.ts +32 -20
- package/dist/types/gl/Mesh.d.ts +7 -1
- package/dist/types/gl/Model.d.ts +41 -5
- package/dist/types/gl/Node.d.ts +18 -0
- package/dist/types/gl/Particles.d.ts +53 -1
- package/dist/types/gl/Scene.d.ts +21 -1
- package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
- package/dist/types/gl/animation/Animator.d.ts +27 -0
- package/dist/types/gl/animation/DynamicBone.d.ts +184 -0
- package/dist/types/gl/animation/IK.d.ts +109 -0
- package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
- package/dist/types/gl/animation/Warp.d.ts +2 -1
- package/dist/types/gl/animation/core.d.ts +35 -4
- package/dist/types/gl/{AudioSource.d.ts → audio/AudioSource.d.ts} +6 -6
- package/dist/types/gl/{AudioZone.d.ts → audio/AudioZone.d.ts} +4 -4
- package/dist/types/gl/{SceneAudio.d.ts → audio/SceneAudio.d.ts} +1 -1
- package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
- package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
- package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
- package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
- package/dist/types/gl/physics/Ragdoll.d.ts +161 -0
- package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
- package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
- package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +10 -8
- package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
- package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
- package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
- package/dist/types/inject.d.ts +35 -27
- package/dist/types/runtime/files.d.ts +24 -1
- package/dist/types/scene/defineScene.d.ts +50 -31
- package/dist/types/ui/UIButton.d.ts +3 -1
- package/dist/types/ui/UIInput.d.ts +5 -1
- package/dist/types/ui/UINode.d.ts +24 -24
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/README.md +142 -142
- package/prompts/core-design.md +27 -4
- package/prompts/core.md +35 -6
- package/prompts/select.ts +19 -4
- package/src/animate/tween/Animation.ts +378 -0
- package/src/animate/tween/Timeline.ts +175 -0
- package/src/animate/tween/animateValue.ts +100 -0
- package/src/animate/tween/easing.ts +172 -0
- package/src/animate/tween/spec.ts +479 -0
- package/src/bridges.d.ts +1760 -1481
- package/src/canvas/Canvas.ts +21 -0
- package/src/compile/__tests__/assetMacro.test.ts +26 -0
- package/src/compile/__tests__/compile.test.ts +11 -0
- package/src/compile/__tests__/detectEntry.test.ts +19 -0
- package/src/compile/__tests__/serverSplit.test.ts +27 -0
- package/src/compile/bundler.ts +34 -4
- package/src/compile/compileProject.ts +31 -1
- package/src/compile/detectEntry.ts +8 -3
- package/src/compile/header.ts +6 -3
- package/src/compile/index.ts +3 -0
- package/src/compile/sceneEditor.ts +42 -1
- package/src/compile/serverSplit.ts +9 -3
- package/src/g2/Node2D.ts +38 -0
- package/src/g2/Sprite.ts +20 -1
- package/src/gl/Camera.ts +34 -1
- package/src/gl/DecalSet.ts +132 -5
- package/src/gl/Foliage.ts +102 -0
- package/src/gl/Geometry.ts +109 -0
- package/src/gl/Light.ts +46 -16
- package/src/gl/Lightmap.ts +440 -249
- package/src/gl/Material.ts +69 -36
- package/src/gl/Mesh.ts +120 -102
- package/src/gl/Model.ts +167 -124
- package/src/gl/Node.ts +40 -1
- package/src/gl/Particles.ts +82 -5
- package/src/gl/Scene.ts +35 -2
- package/src/gl/animation/AnimationClip.ts +52 -0
- package/src/gl/animation/Animator.ts +42 -2
- package/src/gl/animation/DynamicBone.ts +482 -0
- package/src/gl/animation/IK.ts +214 -0
- package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
- package/src/gl/animation/Playback.ts +5 -4
- package/src/gl/animation/Warp.ts +5 -2
- package/src/gl/animation/core.ts +65 -4
- package/src/gl/{AudioSource.ts → audio/AudioSource.ts} +7 -7
- package/src/gl/{AudioZone.ts → audio/AudioZone.ts} +75 -75
- package/src/gl/{SceneAudio.ts → audio/SceneAudio.ts} +2 -2
- package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
- package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
- package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
- package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
- package/src/gl/physics/Ragdoll.ts +451 -0
- package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
- package/src/gl/{Trigger.ts → physics/Trigger.ts} +45 -45
- package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
- package/src/gl/{Terrain.ts → terrain/Terrain.ts} +14 -12
- package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
- package/src/gl/vehicle/Vehicle.ts +666 -0
- package/src/gl/vehicle/Wheel.ts +290 -0
- package/src/inject.ts +236 -224
- package/src/runtime/files.ts +32 -2
- package/src/scene/defineScene.ts +92 -66
- package/src/scene/level.ts +2 -2
- package/src/ui/UIButton.ts +2 -2
- package/src/ui/UIInput.ts +3 -3
- package/src/ui/UINode.ts +61 -36
- package/dist/types/animate/animate.d.ts +0 -20
- package/dist/types/gl/Gearbox.d.ts +0 -86
- package/dist/types/gl/IK.d.ts +0 -53
- package/dist/types/gl/Ragdoll.d.ts +0 -86
- package/dist/types/gl/Vehicle.d.ts +0 -191
- package/dist/types/gl/Wheel.d.ts +0 -95
- package/src/animate/animate.ts +0 -238
- package/src/gl/Gearbox.ts +0 -212
- package/src/gl/IK.ts +0 -193
- package/src/gl/Ragdoll.ts +0 -270
- package/src/gl/Vehicle.ts +0 -473
- package/src/gl/Wheel.ts +0 -240
- /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
package/src/canvas/Canvas.ts
CHANGED
|
@@ -35,6 +35,11 @@ const OP = {
|
|
|
35
35
|
DRAW_IMAGE: 30,
|
|
36
36
|
} as const
|
|
37
37
|
|
|
38
|
+
// Above this many buffered values, a buffer that is STILL growing between bakes is a leak, not a
|
|
39
|
+
// drawing (a dense static picture is a few thousand; a 15 Hz graph without reset() passes this in
|
|
40
|
+
// seconds and doubles its frame cost every minute).
|
|
41
|
+
const CMD_GROWTH_WARN = 20000
|
|
42
|
+
|
|
38
43
|
const ALIGN = { left: 0, center: 1, right: 2, start: 3, end: 4 } as const
|
|
39
44
|
const BASELINE = { alphabetic: 0, top: 1, middle: 2, bottom: 3, hanging: 4, ideographic: 5 } as const
|
|
40
45
|
const JOIN = { miter: 0, round: 1, bevel: 2 } as const
|
|
@@ -76,6 +81,11 @@ export class Canvas {
|
|
|
76
81
|
// also refreshes the on-screen image node(s) via _creatorUI — the UI consumer the 2D/3D texture
|
|
77
82
|
// hooks below don't cover. Stays a bare flag so a non-UI canvas never references the UI bridge.
|
|
78
83
|
private _uiBound = false
|
|
84
|
+
// Growth watch: the command buffer is a RECORDING that every bake replays in full, so a canvas
|
|
85
|
+
// redrawn every frame without `reset()` gets slower with every update. `_bakeSurface` compares the
|
|
86
|
+
// buffer against the previous bake and warns once when it keeps growing past the threshold.
|
|
87
|
+
private _lastBakeLen = 0
|
|
88
|
+
private _warnedGrowth = false
|
|
79
89
|
|
|
80
90
|
constructor(width: number, height: number, opts: { pixelRatio?: number } = {}) {
|
|
81
91
|
this.width = width
|
|
@@ -198,6 +208,17 @@ export class Canvas {
|
|
|
198
208
|
|
|
199
209
|
/** @internal — rasterize the recorded commands into the surface; returns the surface id. */
|
|
200
210
|
_bakeSurface(): number {
|
|
211
|
+
const len = this._cmd.length
|
|
212
|
+
if (!this._warnedGrowth && len > CMD_GROWTH_WARN && this._lastBakeLen > 0 && len > this._lastBakeLen) {
|
|
213
|
+
this._warnedGrowth = true
|
|
214
|
+
console.warn(
|
|
215
|
+
`[canvas] ${this.width}×${this.height}: the command buffer keeps growing (${len} values, ` +
|
|
216
|
+
`${this._lastBakeLen} at the previous bake) — a Canvas records every call and each update() ` +
|
|
217
|
+
`replays all of it, so a redraw that never calls reset() gets slower every frame. ` +
|
|
218
|
+
`Call canvas.reset() before redrawing (or toBitmap() + reset() to flatten a drawing).`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
this._lastBakeLen = len
|
|
201
222
|
const buf = new Float32Array(this._cmd)
|
|
202
223
|
this._surfaceId = _creatorCanvas.rasterize(this._surfaceId, this.width, this.height, this.pixelRatio, buf, buf.length, this._refs)
|
|
203
224
|
return this._surfaceId
|
|
@@ -34,6 +34,32 @@ test("the same asset present in the entries compiles to its URL", async () => {
|
|
|
34
34
|
expect(js).not.toContain("./assets/lightmap.bake")
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
+
// Only the modules the bundle reaches are checked: a project keeps one entry per level, and a
|
|
38
|
+
// level whose art is not on this machine must not fail an entry that never imports it.
|
|
39
|
+
test("a missing asset in a module the entry never imports does not fail the compile", async () => {
|
|
40
|
+
const js = await compileProject({
|
|
41
|
+
name: "Unreached",
|
|
42
|
+
publicUrl: "https://le.codes",
|
|
43
|
+
entries: [
|
|
44
|
+
{ path: "/main.ts", type: "text", text: "const scene = new Scene()\nconsole.log(scene)\n" },
|
|
45
|
+
{ path: "/levels/lost.ts", type: "text", text: "export const lost = asset('./lost.glb')\n" },
|
|
46
|
+
],
|
|
47
|
+
})
|
|
48
|
+
expect(js).not.toContain("lost.glb")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("the same missing asset fails once the entry imports its module", async () => {
|
|
52
|
+
const compile = compileProject({
|
|
53
|
+
name: "Reached",
|
|
54
|
+
publicUrl: "https://le.codes",
|
|
55
|
+
entries: [
|
|
56
|
+
{ path: "/main.ts", type: "text", text: "import { lost } from './levels/lost'\nconsole.log(lost)\n" },
|
|
57
|
+
{ path: "/levels/lost.ts", type: "text", text: "export const lost = asset('./lost.glb')\n" },
|
|
58
|
+
],
|
|
59
|
+
})
|
|
60
|
+
await expect(compile).rejects.toThrow("asset not found: ./lost.glb (levels/lost.ts:1)")
|
|
61
|
+
})
|
|
62
|
+
|
|
37
63
|
test("a commented-out asset() is not an asset at all", () => {
|
|
38
64
|
const src = [
|
|
39
65
|
"/** The atlas image — write `asset('./TileSet.png')`. */",
|
|
@@ -39,6 +39,17 @@ test("a 2D project (new Scene2D) is marked 2d: enable and gl: disable", async ()
|
|
|
39
39
|
expect(js).toContain("_creator2d.createScene")
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
+
test("a 3D project with a Canvas is NOT marked 2d: enable (Canvas keeps a `_creator2d.` texture path)", async () => {
|
|
43
|
+
const js = await compileProject({
|
|
44
|
+
name: "Car",
|
|
45
|
+
publicUrl: "https://le.codes",
|
|
46
|
+
entries: [ { path: "/main.ts", type: "text", text: "const c = new Canvas(64, 64)\nc.update()\nnew Scene().open()\n" } ],
|
|
47
|
+
})
|
|
48
|
+
expect(js).toContain("_creator2d.") // the canvas 2D-texture path survives tree-shaking
|
|
49
|
+
expect(js).not.toContain("// 2d: enable") // …but no Scene2D → the 2D engine is not in use
|
|
50
|
+
expect(js).not.toContain("// gl: disable")
|
|
51
|
+
})
|
|
52
|
+
|
|
42
53
|
test("2D and 3D bundles are DISJOINT (tree-shake drops the other engine)", async () => {
|
|
43
54
|
const twoD = await compileProject({
|
|
44
55
|
name: "2D", publicUrl: "https://le.codes", header: false,
|
|
@@ -130,3 +130,22 @@ test("compileProject auto-detects the importer as entry and runs the registratio
|
|
|
130
130
|
expect(js).toContain("app-side-effect")
|
|
131
131
|
expect(js).toContain("register-side-effect")
|
|
132
132
|
})
|
|
133
|
+
|
|
134
|
+
test("design/ screens are never the entry — a design-only project has none, app code next to a design wins", () => {
|
|
135
|
+
// A Figma import: screens on the board, shared modules, no app yet. The largest root used to win.
|
|
136
|
+
const designOnly = detectEntry([
|
|
137
|
+
text("/design/screens/a4-40.ts", "import { at } from '../shared/figma'\nexport default UIScreen([])\n" + "// big\n".repeat(50)),
|
|
138
|
+
text("/design/shared/figma.ts", "export const at = () => ({})\n"),
|
|
139
|
+
text("/app.server.ts", "export function ping() { return 1 }\n"),
|
|
140
|
+
])
|
|
141
|
+
expect(designOnly.entry).toBe("")
|
|
142
|
+
expect(designOnly.roots).toEqual([])
|
|
143
|
+
|
|
144
|
+
// The same project once the app exists: main.ts is the entry however small it is.
|
|
145
|
+
const withApp = detectEntry([
|
|
146
|
+
text("/design/screens/a4-40.ts", "export default UIScreen([])\n" + "// big\n".repeat(50)),
|
|
147
|
+
text("/main.ts", "Router.init(home)\n"),
|
|
148
|
+
])
|
|
149
|
+
expect(withApp.entry).toBe("/main.ts")
|
|
150
|
+
expect(withApp.roots).toEqual(["/main.ts"])
|
|
151
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test, expect } from "bun:test"
|
|
2
|
+
import { compileServerBundle } from "../serverSplit"
|
|
3
|
+
|
|
4
|
+
// The server bundle's file map is the whole project (a server module may import shared code), and
|
|
5
|
+
// the bundler checks every `asset()` in that map. Seen live 2026-09-17: a Figma-exported design next
|
|
6
|
+
// to app.server.ts failed the SERVER build with "asset not found: ../assets/figma/…" because the
|
|
7
|
+
// map had the screens but not the binaries. Binaries are stubbed, design/ stays out.
|
|
8
|
+
|
|
9
|
+
const text = (path: string, t: string) => ({ path, type: "text" as const, text: t })
|
|
10
|
+
const res = (path: string) => ({ path, type: "resource" as const, fileSrc: "/uploads/x.bin" })
|
|
11
|
+
|
|
12
|
+
test("a client screen's asset() does not break the server bundle", async () => {
|
|
13
|
+
const out = await compileServerBundle({
|
|
14
|
+
validate: false,
|
|
15
|
+
entries: [
|
|
16
|
+
text("/app.server.ts", "export const ping = async () => 1\n"),
|
|
17
|
+
text("/main.ts", "import { ping } from './app.server'\nconst hero = asset('./hero.png')\nping()\n"),
|
|
18
|
+
res("/hero.png"),
|
|
19
|
+
text("/design/screens/a4-40.ts", "export default UIScreen([ UIImage(asset('../assets/figma/a4-40/rectangle.png')), UIImage(asset('../assets/figma/a4-40/logo.svg')) ])\n"),
|
|
20
|
+
res("/design/assets/figma/a4-40/rectangle.png"),
|
|
21
|
+
text("/design/assets/figma/a4-40/logo.svg", "<svg/>"),
|
|
22
|
+
],
|
|
23
|
+
})
|
|
24
|
+
expect(out).not.toBeNull()
|
|
25
|
+
expect(out!.code).toContain("ping")
|
|
26
|
+
expect(Object.keys(out!.manifest.modules)).toEqual(["app.server.ts"])
|
|
27
|
+
})
|
package/src/compile/bundler.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { createRequire } from "node:module"
|
|
|
4
4
|
import { dirname, join, relative } from "node:path"
|
|
5
5
|
import { fileURLToPath } from "node:url"
|
|
6
6
|
import { collectAssetMacros, desugarAssetMacros, hasAssetMacro, type AssetMacroRef } from "./assetMacro"
|
|
7
|
-
import type
|
|
7
|
+
import { scanModulesFallback, type ScanModule } from "./detectEntry"
|
|
8
8
|
import { hasAspectExtends, injectAspectNames } from "./aspectMacro"
|
|
9
9
|
import { hasImportStatement, rewriteLibraryImports } from "./libraryImports"
|
|
10
10
|
|
|
@@ -132,8 +132,30 @@ export const scanModulesChisel = (files: MemoryFiles): Record<string, ScanModule
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
135
|
+
* The modules the bundle will actually contain: everything reachable from `entry` over static
|
|
136
|
+
* imports (chisel's scan, else the regex scanner — same graph detectEntry roots on). `sources` are
|
|
137
|
+
* the post-library-rewrite, pre-desugar texts, so a bare `from "acme-ui"` already points into
|
|
138
|
+
* `/__lib/<slug>/…` and follows through. A dynamic `import()` is not an edge for either scanner.
|
|
139
|
+
*/
|
|
140
|
+
const reachableFrom = (entry: string, sources: MemoryFiles): Set<string> => {
|
|
141
|
+
const graph = scanModulesChisel(sources) ?? scanModulesFallback(sources)
|
|
142
|
+
const seen = new Set<string>([ entry ])
|
|
143
|
+
const queue = [ entry ]
|
|
144
|
+
while (queue.length > 0) {
|
|
145
|
+
const file = queue.pop()!
|
|
146
|
+
for (const target of graph[file]?.imports ?? []) {
|
|
147
|
+
if (!seen.has(target)) { seen.add(target); queue.push(target) }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return seen
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Every `asset('…')` in a module the bundle REACHES must name a file that's actually in the
|
|
155
|
+
* compile input — a missing one is a COMPILE ERROR, never a fallback. Modules the entry never
|
|
156
|
+
* imports are not checked: a project keeps one entry per level, and a level whose GLBs are not on
|
|
157
|
+
* this machine (gitignored art) must not fail the compile of an entry that never loads it — it
|
|
158
|
+
* fails its own compile, with the same message (fps-demo demo-logo.ts vs chernobyl, 2026-09-17).
|
|
137
159
|
*
|
|
138
160
|
* chisel used to turn an asset import with no backing module into `const x = "<the specifier>"`:
|
|
139
161
|
* a relative source path nothing resolves at runtime, so the app got a silently broken reference
|
|
@@ -173,11 +195,15 @@ const bundleProjectChisel = async (
|
|
|
173
195
|
// - inject `static aspect = '<name>'` from `class … extends Aspect<'<name>', …>`
|
|
174
196
|
const projFiles: MemoryFiles = {}
|
|
175
197
|
const assetRefs: { file: string, ref: AssetMacroRef }[] = []
|
|
198
|
+
// What the module scanner sees: import-rewritten, NOT yet asset-desugared (a desugared asset is a
|
|
199
|
+
// default import of a non-module — the fallback scanner drops it, chisel's scan would not).
|
|
200
|
+
const scanSources: MemoryFiles = {}
|
|
176
201
|
for (const [ k, v ] of Object.entries(files)) {
|
|
177
202
|
const path = normalize(k)
|
|
178
203
|
let src = v
|
|
179
204
|
if (/\.[tj]sx?$/.test(path)) {
|
|
180
205
|
if (hasImportStatement(src)) src = rewriteLibraryImports(src, slugs)
|
|
206
|
+
scanSources[path] = src
|
|
181
207
|
if (hasAssetMacro(src)) {
|
|
182
208
|
const found = collectAssetMacros(src, path)
|
|
183
209
|
for (const ref of found) assetRefs.push({ file: path, ref })
|
|
@@ -198,6 +224,7 @@ const bundleProjectChisel = async (
|
|
|
198
224
|
const path = normalize(`/__lib/${slug}/${k}`)
|
|
199
225
|
let src = v
|
|
200
226
|
if (/\.[tj]sx?$/.test(path)) {
|
|
227
|
+
scanSources[path] = src
|
|
201
228
|
if (hasAssetMacro(src)) {
|
|
202
229
|
const found = collectAssetMacros(src, path)
|
|
203
230
|
for (const ref of found) assetRefs.push({ file: path, ref })
|
|
@@ -209,7 +236,10 @@ const bundleProjectChisel = async (
|
|
|
209
236
|
}
|
|
210
237
|
}
|
|
211
238
|
|
|
212
|
-
|
|
239
|
+
if (assetRefs.length > 0) {
|
|
240
|
+
const reachable = reachableFrom(entry, scanSources)
|
|
241
|
+
assertAssetsExist(assetRefs.filter(({ file }) => reachable.has(file)), projFiles)
|
|
242
|
+
}
|
|
213
243
|
|
|
214
244
|
const sdkFiles = collectSdkFiles(resolveSdkSrc())
|
|
215
245
|
// Shipped bundles minify by default; edit-mode bundles pass `false` — mangling erases class
|
|
@@ -74,17 +74,47 @@ export type CompileOptions = {
|
|
|
74
74
|
* unless `header: false`; `map` is already shifted past that header. */
|
|
75
75
|
export type CompileResult = { code: string, map: string | null, entry: string }
|
|
76
76
|
|
|
77
|
+
/** The basename a local resource would be looked up by. */
|
|
78
|
+
export const localAssetBase = (path: string): string => path.split(/[\\/]/).pop() ?? path
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Runtime lookup names for `localAssets` resources — what the bundle passes to `fetchLocal` and what
|
|
82
|
+
* a host caller must copy the file to.
|
|
83
|
+
*
|
|
84
|
+
* The basename, unless two project files SHARE one: then every sharer takes its whole root-relative
|
|
85
|
+
* path with the separators folded into `__`. A flat name space is fine until it isn't, and when it
|
|
86
|
+
* isn't the failure is silent and absurd — the files are staged into one directory, so the last one
|
|
87
|
+
* copied answers for every `asset()` that ends in that name. Which one wins depends on the scan
|
|
88
|
+
* order, i.e. on nothing. (2026-09-17: a viewer project carried two asset packs that each ship
|
|
89
|
+
* `SM_Sign_01..03.glb`; the school's floor-standing "CLEANING IN PROGRESS" A-frame was drawn inside
|
|
90
|
+
* the shooting range's wall, where a flat 4 mm plate belonged.)
|
|
91
|
+
*/
|
|
92
|
+
export const localAssetNames = (entries: readonly { path: string, type: string }[]): Map<string, string> => {
|
|
93
|
+
const seen = new Map<string, number>()
|
|
94
|
+
for (const e of entries) if (e.type === "resource") {
|
|
95
|
+
const b = localAssetBase(e.path)
|
|
96
|
+
seen.set(b, (seen.get(b) ?? 0) + 1)
|
|
97
|
+
}
|
|
98
|
+
const names = new Map<string, string>()
|
|
99
|
+
for (const e of entries) if (e.type === "resource") {
|
|
100
|
+
const b = localAssetBase(e.path)
|
|
101
|
+
names.set(e.path, (seen.get(b) ?? 0) > 1 ? e.path.replace(/^[./]+/, "").replace(/[\\/]/g, "__") : b)
|
|
102
|
+
}
|
|
103
|
+
return names
|
|
104
|
+
}
|
|
105
|
+
|
|
77
106
|
/** Turn compile entries into the bundler's module map — the per-kind rules of the deployable
|
|
78
107
|
* bundle (resource → URL export, shader → per-backend URL template, .svg → SvgSource, .json →
|
|
79
108
|
* data, code verbatim). Exported for callers that build a `libraries` map from entries. */
|
|
80
109
|
export const entriesToFiles = (entries: CompileEntry[], opts: { publicUrl: string, localAssets?: boolean }): Record<string, string> => {
|
|
81
110
|
const files: Record<string, string> = {}
|
|
111
|
+
const localNames = localAssetNames(entries)
|
|
82
112
|
for (const e of entries) {
|
|
83
113
|
if (e.type === "resource") {
|
|
84
114
|
if (opts.localAssets && !e.remote) {
|
|
85
115
|
// The host resolves the asset by name at runtime (reads the local file → systemId); the
|
|
86
116
|
// "id:" prefix is the host's local-resource URL scheme (parsed by the UI + 2D texture paths).
|
|
87
|
-
const name = e.path
|
|
117
|
+
const name = localNames.get(e.path) ?? localAssetBase(e.path)
|
|
88
118
|
files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(name)}))`
|
|
89
119
|
} else {
|
|
90
120
|
files[e.path] = `export default "${opts.publicUrl}${e.fileSrc ?? ""}"`
|
|
@@ -37,9 +37,14 @@ export type DetectedEntry = {
|
|
|
37
37
|
export const ENTRY_CONVENTION = "/main.ts"
|
|
38
38
|
|
|
39
39
|
const isCode = (path: string) => /\.[tj]s$/.test(path) && !path.endsWith(".d.ts")
|
|
40
|
-
/** Never entries: editor plugins
|
|
41
|
-
* imports is still not the app; server files compile into their own bundle)
|
|
42
|
-
|
|
40
|
+
/** Never entries: editor plugins, server modules (docs/backend-plan.md — a `*.server.ts` nobody
|
|
41
|
+
* imports is still not the app; server files compile into their own bundle) and anything under
|
|
42
|
+
* `design/` — the board's screens are import-graph roots by construction (nothing imports a screen
|
|
43
|
+
* file; the board runs them), so without this a project whose app code is still to come had its
|
|
44
|
+
* largest design screen picked as the app and failed deep inside it ("asset not found:
|
|
45
|
+
* ../assets/figma/…"). A design-only project has no entry, and says so. */
|
|
46
|
+
const isNonEntry = (path: string) =>
|
|
47
|
+
path.endsWith(".editor.ts") || /\.server\.[tj]s$/.test(path) || /^\/design\//.test(path)
|
|
43
48
|
|
|
44
49
|
const normalize = (path: string): string => {
|
|
45
50
|
const parts: string[] = []
|
package/src/compile/header.ts
CHANGED
|
@@ -33,8 +33,11 @@ export const buildHeader = (name: string, code: string, opts: HeaderOptions = {}
|
|
|
33
33
|
else if (code.includes("_creator.createEngine")) header += `// gl: defer\n`
|
|
34
34
|
|
|
35
35
|
// 2D (creator-2d) — non-exclusive: emitted alongside `gl: disable` so hosts skip Filament and a
|
|
36
|
-
// 2D-aware host brings up the lightweight sokol engine instead.
|
|
37
|
-
|
|
36
|
+
// 2D-aware host brings up the lightweight sokol engine instead. Keyed on the scene constructor
|
|
37
|
+
// (like GL above), NOT on any `_creator2d.` reference: Canvas carries a 2D-texture path
|
|
38
|
+
// (`canvas.texture()` → `_creator2d.createTextureFromCanvas`) that chisel keeps in a pure-3D
|
|
39
|
+
// bundle, and nothing 2D works without a Scene2D anyway.
|
|
40
|
+
if (code.includes("_creator2d.createScene")) header += `// 2d: enable\n`
|
|
38
41
|
|
|
39
42
|
if (code.includes("_creator.createARController")) header += `// ar: enable\n`
|
|
40
43
|
|
|
@@ -61,7 +64,7 @@ export const buildHeader = (name: string, code: string, opts: HeaderOptions = {}
|
|
|
61
64
|
/** Same engine signals the header uses, for callers that need the project kind (e.g. icon type). */
|
|
62
65
|
export const detectProjectKind = (code: string): "ar" | "ui" | "2d" | "3d" => {
|
|
63
66
|
if (code.includes("_creator.createARController")) return "ar"
|
|
64
|
-
if (code.includes("_creator2d.")) return "2d"
|
|
67
|
+
if (code.includes("_creator2d.createScene")) return "2d"
|
|
65
68
|
if (!code.includes("_creator.")) return "ui"
|
|
66
69
|
return "3d"
|
|
67
70
|
}
|
package/src/compile/index.ts
CHANGED
|
@@ -65,6 +65,8 @@ export {
|
|
|
65
65
|
compileProject,
|
|
66
66
|
compileProjectWithMap,
|
|
67
67
|
entriesToFiles,
|
|
68
|
+
localAssetNames,
|
|
69
|
+
localAssetBase,
|
|
68
70
|
type CompileEntry,
|
|
69
71
|
type CompileOptions,
|
|
70
72
|
type CompileFileType,
|
|
@@ -73,6 +75,7 @@ export {
|
|
|
73
75
|
export {
|
|
74
76
|
loadSceneHarness,
|
|
75
77
|
sceneEditorEntries,
|
|
78
|
+
sceneImportsOf,
|
|
76
79
|
SCENE_ENTRY_PATH,
|
|
77
80
|
SCENE_HARNESS_DIR,
|
|
78
81
|
} from "./sceneEditor"
|
|
@@ -47,10 +47,44 @@ export const loadSceneHarness = (fallbackDir?: string): Record<string, string> =
|
|
|
47
47
|
* the project's file list — `.editor.ts` entries are picked out of it. Compile with
|
|
48
48
|
* `entryOverride: SCENE_ENTRY_PATH`, `header: false`, `format: "iife"`, `editor: true`.
|
|
49
49
|
*/
|
|
50
|
+
/** Resolve a relative import specifier against a project path ("./src/map.scene.ts" + "./map/x.scene"
|
|
51
|
+
* → "./src/map/x.scene"). */
|
|
52
|
+
const resolveRelative = (fromPath: string, spec: string): string => {
|
|
53
|
+
const segs = (p: string) => p.split("/").filter((s) => s !== "" && s !== ".")
|
|
54
|
+
const out = segs(fromPath).slice(0, -1)
|
|
55
|
+
for (const seg of segs(spec)) {
|
|
56
|
+
if (seg === "..") out.pop()
|
|
57
|
+
else out.push(seg)
|
|
58
|
+
}
|
|
59
|
+
return "./" + out.join("/")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The other scene files a scene file imports as prefabs (`import x from './x.scene'`), as project
|
|
64
|
+
* paths present in `projectPaths`. The edit bundle registers their handles BY PATH
|
|
65
|
+
* (`__lecodesSceneFiles`), so the editor can live-patch a `prefab:` node — add, duplicate, undo —
|
|
66
|
+
* without a compile: the doc sees `prefab: X` as an identifier, the host maps it through the
|
|
67
|
+
* file's import table to this path, and the harness resolves the path to the running handle.
|
|
68
|
+
*/
|
|
69
|
+
export const sceneImportsOf = (sceneText: string, scenePath: string, projectPaths: string[]): string[] => {
|
|
70
|
+
const known = new Set(projectPaths)
|
|
71
|
+
const out: string[] = []
|
|
72
|
+
const re = /^\s*import\s+[A-Za-z_$][\w$]*\s+from\s+["']([^"']+\.scene(?:\.ts)?)["']/gm
|
|
73
|
+
for (const m of sceneText.matchAll(re)) {
|
|
74
|
+
const spec = m[1]
|
|
75
|
+
if (!spec.startsWith(".")) continue
|
|
76
|
+
let p = resolveRelative(scenePath, spec)
|
|
77
|
+
if (!p.endsWith(".ts")) p += ".ts"
|
|
78
|
+
if (known.has(p) && !out.includes(p)) out.push(p)
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
50
83
|
export const sceneEditorEntries = (
|
|
51
84
|
scenePath: string,
|
|
52
85
|
harness: Record<string, string>,
|
|
53
86
|
projectPaths: string[] = [],
|
|
87
|
+
sceneImports: string[] = [],
|
|
54
88
|
): CompileEntry[] => {
|
|
55
89
|
const entries: CompileEntry[] = Object.entries(harness).map(([ name, text ]) => ({
|
|
56
90
|
path: `${SCENE_HARNESS_DIR}/${name}`,
|
|
@@ -74,15 +108,22 @@ export const sceneEditorEntries = (
|
|
|
74
108
|
const materialImports = materialPaths.map((p, i) => `import __mat${i} from "${p}"\n`).join("")
|
|
75
109
|
const materialRegister = materialPaths.length === 0 ? ""
|
|
76
110
|
: `;(globalThis as any).__lecodesMaterials = { ${materialPaths.map((p, i) => `${JSON.stringify(p)}: __mat${i}`).join(", ")} }\n`
|
|
111
|
+
// The scene's prefab imports, registered by project path (see sceneImportsOf) — the same module
|
|
112
|
+
// instances the scene itself imported, so a live-patched prefab node is the same handle.
|
|
113
|
+
const sceneImportLines = sceneImports.map((p, i) => `import __scn${i} from "${p}"\n`).join("")
|
|
114
|
+
const sceneRegister = sceneImports.length === 0 ? ""
|
|
115
|
+
: `;(globalThis as any).__lecodesSceneFiles = { ${sceneImports.map((p, i) => `${JSON.stringify(p)}: __scn${i}`).join(", ")} }\n`
|
|
77
116
|
entries.push({
|
|
78
117
|
path: SCENE_ENTRY_PATH,
|
|
79
118
|
type: "text",
|
|
80
119
|
text: `import "${SCENE_HARNESS_DIR}/.edit-mode.ts"\n`
|
|
81
120
|
+ materialImports
|
|
121
|
+
+ sceneImportLines
|
|
82
122
|
+ `import "${scenePath}"\n`
|
|
83
123
|
+ editorImports
|
|
84
124
|
+ `import "${SCENE_HARNESS_DIR}/main.ts"\n`
|
|
85
|
-
+ materialRegister
|
|
125
|
+
+ materialRegister
|
|
126
|
+
+ sceneRegister,
|
|
86
127
|
})
|
|
87
128
|
return entries
|
|
88
129
|
}
|
|
@@ -210,11 +210,17 @@ export type CompileServerOptions = {
|
|
|
210
210
|
export const compileServerBundle = async (opts: CompileServerOptions): Promise<{ code: string, manifest: ServerManifest } | null> => {
|
|
211
211
|
const modules = scanServerModules(opts.entries)
|
|
212
212
|
if (Object.keys(modules).length === 0) return null
|
|
213
|
+
// The server bundle sees the whole project map (a server module may import shared code), but the
|
|
214
|
+
// bundler's asset check runs over EVERY file in the map — so binaries and svgs must be present as
|
|
215
|
+
// stubs, or a client screen's `asset("./x.png")` fails the SERVER build ("asset not found", seen
|
|
216
|
+
// with a Figma-exported design next to app.server.ts). The board's `design/` files are never
|
|
217
|
+
// server code and stay out entirely.
|
|
213
218
|
const files: MemoryFiles = {}
|
|
214
219
|
for (const e of opts.entries) {
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
220
|
+
const path = normalizePath(e.path)
|
|
221
|
+
if (/^\/design\//.test(path)) continue
|
|
222
|
+
if (e.type !== "text" || path.toLowerCase().endsWith(".svg")) { files[path] = `export default ""`; continue }
|
|
223
|
+
files[path] = e.text ?? ""
|
|
218
224
|
}
|
|
219
225
|
let params = opts.params
|
|
220
226
|
if (!params && opts.validate !== false) {
|
package/src/g2/Node2D.ts
CHANGED
|
@@ -16,6 +16,19 @@ import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
|
|
|
16
16
|
import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
|
|
17
17
|
import { ensurePointerEvents } from "./touch"
|
|
18
18
|
import { ensurePhysicsEvents } from "./loop"
|
|
19
|
+
import type { Animation } from "../animate/tween/Animation"
|
|
20
|
+
import { CLOCK_GAME, DOM_NODE2D, floatValue, vec2Value, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
|
|
21
|
+
import { tweenBag } from "../animate/tween/Timeline"
|
|
22
|
+
|
|
23
|
+
/** Animatable transform props of a 2D node; an array of values is a keyframe list. */
|
|
24
|
+
export type Node2DTweenProps = {
|
|
25
|
+
x?: number | number[],
|
|
26
|
+
y?: number | number[],
|
|
27
|
+
position?: Vec2Like | Vec2Like[],
|
|
28
|
+
/** Degrees. */
|
|
29
|
+
rotation?: number | number[],
|
|
30
|
+
scale?: number | Vec2Like | (number | Vec2Like)[],
|
|
31
|
+
}
|
|
19
32
|
|
|
20
33
|
// re-exported so existing `import { Vec2 } from "./Node2D"` sites keep working; the canonical home is math/.
|
|
21
34
|
export { Vec2, type Vec2Like }
|
|
@@ -135,6 +148,31 @@ export class Node2D extends AspectHost<Node2DEvents> implements CompWriter {
|
|
|
135
148
|
_creator2d.setScale(this.id, sx, sy)
|
|
136
149
|
}
|
|
137
150
|
|
|
151
|
+
// --- keyframe animation (docs/timeline-plan.md) ---
|
|
152
|
+
/** Tween the transform — `node.animateTo({ x: 120, rotation: 90, duration: 400, easing: 'outBack' })`;
|
|
153
|
+
* arrays are keyframes. Game clock unless `clock: 'ui'`. Returns the {@link Animation} handle. */
|
|
154
|
+
animateTo(props: Node2DTweenProps & TweenMeta): Animation {
|
|
155
|
+
return tweenBag(this, props as Record<string, unknown>, true)
|
|
156
|
+
}
|
|
157
|
+
/** Tween FROM the given values to the node's current transform. */
|
|
158
|
+
animateFrom(props: Node2DTweenProps & TweenMeta): Animation {
|
|
159
|
+
return tweenBag(this, props as Record<string, unknown>, false)
|
|
160
|
+
}
|
|
161
|
+
/** @internal keyframe-core target protocol */
|
|
162
|
+
readonly _tweenClock: number = CLOCK_GAME
|
|
163
|
+
/** @internal */
|
|
164
|
+
_tweenChannel(prop: string): TweenChannel | null {
|
|
165
|
+
const id = () => this.id
|
|
166
|
+
switch (prop) {
|
|
167
|
+
case "x": return { domain: DOM_NODE2D, id, value: floatValue, commit: (v) => { this._x = v as number } }
|
|
168
|
+
case "y": return { domain: DOM_NODE2D, id, value: floatValue, commit: (v) => { this._y = v as number } }
|
|
169
|
+
case "position": return { domain: DOM_NODE2D, id, value: vec2Value, commit: (v) => { this._x = cx(v as Vec2Like); this._y = cy(v as Vec2Like) } }
|
|
170
|
+
case "rotation": return { domain: DOM_NODE2D, id, value: floatValue, commit: (v) => { this._rotation = v as number } }
|
|
171
|
+
case "scale": return { domain: DOM_NODE2D, id, value: vec2Value, commit: (v) => { const s = typeof v === "number" ? [v, v] : v as Vec2Like; this._sx = cx(s); this._sy = cy(s) } }
|
|
172
|
+
default: return null
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
138
176
|
/** Draw layer (int). Higher layers render on top. */
|
|
139
177
|
get layer(): number { return this._layer }
|
|
140
178
|
set layer(v: number) { this._layer = v; _creator2d.setLayer(this.id, v) }
|
package/src/g2/Sprite.ts
CHANGED
|
@@ -5,8 +5,16 @@
|
|
|
5
5
|
import { Color, type ColorInput } from "../core/color"
|
|
6
6
|
import { Vec2, type Vec2Like } from "../math/vec"
|
|
7
7
|
import { Canvas, isCanvas } from "../canvas/Canvas"
|
|
8
|
-
import { Node2D } from "./Node2D"
|
|
8
|
+
import { Node2D, type Node2DTweenProps } from "./Node2D"
|
|
9
9
|
import type { Texture2D } from "./Texture2D"
|
|
10
|
+
import { DOM_SPRITE2D, colorValue, floatValue, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
|
|
11
|
+
import type { Animation } from "../animate/tween/Animation"
|
|
12
|
+
|
|
13
|
+
/** Animatable sprite props on top of the 2D transform. */
|
|
14
|
+
export type SpriteTweenProps = Node2DTweenProps & {
|
|
15
|
+
opacity?: number | number[],
|
|
16
|
+
color?: ColorInput | ColorInput[],
|
|
17
|
+
}
|
|
10
18
|
|
|
11
19
|
/** Anything that can back a sprite: a texture, or a Canvas (baked to a texture on assign). */
|
|
12
20
|
export type TextureSource = Texture2D | Canvas
|
|
@@ -76,6 +84,17 @@ export class Sprite extends Node2D {
|
|
|
76
84
|
return this
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
/** Tween `opacity` / `color` and the transform — see {@link Node2D.animateTo}. */
|
|
88
|
+
override animateTo(props: SpriteTweenProps & TweenMeta): Animation { return super.animateTo(props) }
|
|
89
|
+
override animateFrom(props: SpriteTweenProps & TweenMeta): Animation { return super.animateFrom(props) }
|
|
90
|
+
/** @internal */
|
|
91
|
+
override _tweenChannel(prop: string): TweenChannel | null {
|
|
92
|
+
const id = () => this.id
|
|
93
|
+
if (prop === "opacity") return { domain: DOM_SPRITE2D, id, value: floatValue, commit: (v) => { this._opacity = v as number } }
|
|
94
|
+
if (prop === "color") return { domain: DOM_SPRITE2D, id, value: (v) => colorValue(v, Color.toRgba01), commit: (v) => { this._color = v as ColorInput } }
|
|
95
|
+
return super._tweenChannel(prop)
|
|
96
|
+
}
|
|
97
|
+
|
|
79
98
|
get color(): ColorInput { return this._color }
|
|
80
99
|
set color(c: ColorInput) { this._color = c; const [ r, g, b ] = Color.toRgb01(c); _creator2d.setSpriteColor(this.id, r, g, b) }
|
|
81
100
|
|
package/src/gl/Camera.ts
CHANGED
|
@@ -3,8 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
import { Vec3 } from "../math/vec"
|
|
5
5
|
import { Aspect } from "../core/Aspect"
|
|
6
|
-
import { Node } from "./Node"
|
|
6
|
+
import { Node, type NodeTweenProps } from "./Node"
|
|
7
7
|
import { Ray } from "./Ray"
|
|
8
|
+
import { DOM_GL_CAMERA, floatValue, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
|
|
9
|
+
import type { Animation } from "../animate/tween/Animation"
|
|
10
|
+
|
|
11
|
+
/** Animatable camera props on top of the node transform. */
|
|
12
|
+
export type CameraTweenProps = NodeTweenProps & {
|
|
13
|
+
fov?: number | number[],
|
|
14
|
+
near?: number | number[],
|
|
15
|
+
far?: number | number[],
|
|
16
|
+
}
|
|
8
17
|
|
|
9
18
|
/** @internal `camera.follow(node)`: copies the node's world pose onto the camera every LATE frame
|
|
10
19
|
* (order 1000 — after every other aspect has moved things). One per camera; lives on the target. */
|
|
@@ -24,6 +33,24 @@ export const CAMERA_DEFAULTS = { fov: 60, near: 0.01, far: 1000 }
|
|
|
24
33
|
|
|
25
34
|
export class Camera extends Node {
|
|
26
35
|
private _sceneId = -1
|
|
36
|
+
|
|
37
|
+
/** Tween `fov` / `near` / `far` (a zoom, a dolly) and the transform — see {@link Node.animateTo}. */
|
|
38
|
+
override animateTo(props: CameraTweenProps & TweenMeta): Animation { return super.animateTo(props) }
|
|
39
|
+
override animateFrom(props: CameraTweenProps & TweenMeta): Animation { return super.animateFrom(props) }
|
|
40
|
+
/** @internal The projection channels address the SCENE (one camera per scene on the host). */
|
|
41
|
+
override _tweenChannel(prop: string): TweenChannel | null {
|
|
42
|
+
if (prop === "fov" || prop === "near" || prop === "far") {
|
|
43
|
+
const id = () => (this._sceneId < 0 ? 0 : this._sceneId + 1) // scene ids start at 0; 0 = unaddressable
|
|
44
|
+
const commit = (v: unknown) => {
|
|
45
|
+
if (prop === "fov") this._fov = v as number
|
|
46
|
+
else if (prop === "near") this._near = v as number
|
|
47
|
+
else this._far = v as number
|
|
48
|
+
this._projSet = true
|
|
49
|
+
}
|
|
50
|
+
return { domain: DOM_GL_CAMERA, id, value: floatValue, commit }
|
|
51
|
+
}
|
|
52
|
+
return super._tweenChannel(prop)
|
|
53
|
+
}
|
|
27
54
|
/** Live projection matrix (updated by the host when the viewport/fov changes). */
|
|
28
55
|
readonly projectionMatrix = new Float32Array(16)
|
|
29
56
|
|
|
@@ -57,6 +84,12 @@ export class Camera extends Node {
|
|
|
57
84
|
* adaptation when the sun swings into view (drop it, and the sun stays at the display's peak
|
|
58
85
|
* while everything else darkens). Scene light follows it; particle `emissive` is post-exposure
|
|
59
86
|
* and does not, so a fireball keeps its on-screen brightness. Cheap to animate every frame. */
|
|
87
|
+
/** The camera's EV100 with the compensation in: log2(aperture² / shutterSpeed x 100 / iso) - compensation. Sunny 16 is
|
|
88
|
+
* ~15; a lit surface of luminance L lands on screen at L / (1.2 x 2^ev100). */
|
|
89
|
+
get ev100(): number {
|
|
90
|
+
const e = this._exposure
|
|
91
|
+
return Math.log2((e.aperture * e.aperture) / e.shutterSpeed * 100 / e.iso) - this._ev
|
|
92
|
+
}
|
|
60
93
|
get exposureCompensation(): number { return this._ev }
|
|
61
94
|
set exposureCompensation(stops: number) {
|
|
62
95
|
this._ev = Number.isFinite(stops) ? stops : 0
|