diff --git a/.github/copilot/skills/port-flow-graph-block.md b/.github/copilot/skills/port-flow-graph-block.md new file mode 100644 index 0000000000..7097b57a1a --- /dev/null +++ b/.github/copilot/skills/port-flow-graph-block.md @@ -0,0 +1,360 @@ +# Port a Flow-Graph Block to Babylon Lite + +You are porting **one block** (node) from the Babylon.js `FlowGraph` system to +**Babylon Lite's** flow-graph subsystem. Babylon.js blocks are classes; Lite +blocks are **plain-data definitions + pure functions**. This skill is the +mechanical recipe to do that conversion correctly and to wire the block into the +glTF `KHR_interactivity` path when applicable. + +> Read first: `docs/lite/architecture/42-flow-graph.md` (the subsystem design) +> and `GUIDANCE.md` (pillars: no classes, pure-state, zero module-level side +> effects, 100% tree-shakable). This skill assumes that architecture. + +--- + +## What a block becomes + +| Babylon.js | Babylon Lite | +|---|---| +| `class FooBlock extends FlowGraphBlock` | a `const fooDef: FgBlockDef` exported from one file | +| `class … extends FlowGraphExecutionBlock` | `FgBlockDef` with an `execute()` | +| `class … extends FlowGraphAsyncExecutionBlock` | `FgBlockDef` with `execute` (start task) + `onTick(task)` + `cancelPending` | +| `class … extends FlowGraphEventBlock` | `FgBlockDef.build()` returns `{ event: FgEventType.X }` | +| constructor `registerDataInput/Output` | sockets declared in `build()` | +| `_registerSignalInput/Output` | signal sockets declared in `build()` | +| `getValue(ctx)` / `setValue(v, ctx)` | `getDataValue(ctx, env, block, name)` / `setDataValue(ctx, block, name, v)` | +| `signal._activateSignal(ctx)` | `activateSignal(ctx, env, block, name)` | +| `this.config.foo` | `block.config?.foo` | +| per-instance field / state | `ctx.executionVariables["${block.id}:key"]` | +| `RegisterClass(name, Class)` + `blockFactory` case | one `getBlockDef` switch case (dynamic import) | +| `getClassName()` returns `FlowGraphBlockNames.X` | `FgBlockType.X` string tag | + +**No classes. No `this`. No methods on data. No module-level `new`.** + +--- + +## Step-by-step + +### 1. Locate the source block + +In the Babylon.js clone (`~/Babylon.js/packages/dev/core/src/FlowGraph/Blocks/**`) +open the block's `.pure.ts` file (the `.ts` wrapper only calls `RegisterClass`, +ignore it). Identify: + +- **Kind:** data (has `_updateOutputs`), execution (has `_execute`), async + (extends `FlowGraphAsyncExecutionBlock`), or event (extends + `FlowGraphEventBlock`). +- **Data inputs/outputs:** each `registerDataInput("name", RichTypeX, default)` + and `registerDataOutput(...)`. +- **Signal inputs/outputs:** the inherited `in`/`error`, plus each + `_registerSignalOutput("name")`. +- **Config:** fields read off `this.config`. +- **Per-instance state:** anything read/written via + `context._getExecutionVariable/_setExecutionVariable`. +- **Class name:** the `FlowGraphBlockNames.X` returned by `getClassName()`. + +### 2. Add the type tag + +In `flow-graph/block-type.ts`, add a member to the `FgBlockType` const enum if it +does not exist. **Keep the string value identical to the BJS class name** +(e.g. `Branch = "Branch"` matches `FlowGraphBranchBlock` → name `"Branch"`), +so serialized graphs and the declaration mapper line up. + +### 3. Create the block file + +Path: `flow-graph/blocks//.ts`. Export a single +`FgBlockDef`. Use the matching template below. + +#### Template — DATA block (pull) + +BJS reference (`flowGraphConstantBlock.pure.ts` style): + +```typescript +// flow-graph/blocks/math/add.ts +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAdd } from "../../fg-math.js"; // type-generic add (number|vecN|…) + +export const addDef: FgBlockDef = { + type: FgBlockType.Add, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgAdd(a, b)); + }, +}; +``` + +#### Template — EXECUTION block (push) + +BJS reference (`flowGraphBranchBlock.pure.ts`): + +```typescript +// flow-graph/blocks/control-flow/branch.ts +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, activateSignal } from "../../runtime.js"; +import { sockIn, sigIn, sigOut } from "../../sockets.js"; + +export const branchDef: FgBlockDef = { + type: FgBlockType.Branch, + build: () => ({ + dataIn: [sockIn("condition", FgType.Boolean, false)], + signalIn: [sigIn("in")], + signalOut: [sigOut("onTrue"), sigOut("onFalse")], + }), + execute(block, ctx, env /*, incomingSignal */) { + if (getDataValue(ctx, env, block, "condition")) { + activateSignal(ctx, env, block, "onTrue"); + } else { + activateSignal(ctx, env, block, "onFalse"); + } + }, +}; +``` + +#### Template — ASYNC block (delay / animation) + +> Async state lives on an **`FgPendingTask`** (with a unique `token`), not on the +> block and not as a single scalar — a block may have **multiple concurrent +> tasks** (e.g. several in-flight delays). `addPending` dedupes the block in the +> tick loop and returns a task; `cancelPending` marks tasks canceled and the tick +> loop skips them. + +```typescript +// flow-graph/blocks/control-flow/set-delay.ts +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, activateSignal, addPending, cancelPendingForBlock } from "../../runtime.js"; +import { sockIn, sigIn, sigOut } from "../../sockets.js"; + +export const setDelayDef: FgBlockDef = { + type: FgBlockType.SetDelay, + build: () => ({ + dataIn: [sockIn("duration", FgType.Number, 0)], + signalIn: [sigIn("in"), sigIn("cancel")], + signalOut: [sigOut("out"), sigOut("done"), sigOut("error")], + }), + execute(block, ctx, env, incomingSignal) { + if (incomingSignal === "cancel") { + cancelPendingForBlock(ctx, block); // marks this block's tasks canceled + return; + } + const seconds = getDataValue(ctx, env, block, "duration") as number; + const task = addPending(ctx, block); // unique token, deduped in tick loop + task.state.remainingMs = seconds * 1000; + activateSignal(ctx, env, block, "out"); // sync flow continues immediately + }, + onTick(block, ctx, env, deltaMs, task) { // task passed by the tick loop + if (task.canceled) { return; } + const left = (task.state.remainingMs as number) - deltaMs; + if (left <= 0) { + task.done = true; // tick loop compacts it out + activateSignal(ctx, env, block, "done"); + } else { + task.state.remainingMs = left; + } + }, +}; +``` + +#### Template — EVENT block + +```typescript +// flow-graph/blocks/events/scene-tick.ts +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { FgEventType } from "../../event-bus.js"; +import { setDataValue, activateSignal } from "../../runtime.js"; +import { sockOut, sigOut } from "../../sockets.js"; + +export const sceneTickDef: FgBlockDef = { + type: FgBlockType.SceneTick, + build: () => ({ + dataOut: [sockOut("timeSinceStart", FgType.Number), sockOut("deltaTime", FgType.Number)], + signalOut: [sigOut("out"), sigOut("done")], + event: FgEventType.Tick, // the scene driver fires this block on the tick channel + }), + // event blocks expose outputs via updateOutputs; the driver sets payload then fires signals + updateOutputs(block, ctx, env) { + // values written by the driver into connectionValues before firing + }, +}; +``` + +> Event blocks do **not** have an `in` signal (BJS removes it). The scene driver +> reads `block.event`, pushes the payload into `ctx.connectionValues`, then calls +> `activateSignal(ctx, env, block, "out")` and `"done"`. + +### 4. Map the rich types + +Replace BJS `RichTypeX` with the `FgType` tag: + +| BJS RichType | FgType | +|---|---| +| `RichTypeAny` | `FgType.Any` | +| `RichTypeNumber` | `FgType.Number` | +| `RichTypeBoolean` | `FgType.Boolean` | +| `RichTypeString` | `FgType.String` | +| `RichTypeFlowGraphInteger` | `FgType.Integer` | +| `RichTypeVector2/3/4` | `FgType.Vector2/3/4` | +| `RichTypeQuaternion` | `FgType.Quaternion` | +| `RichTypeMatrix/2D/3D` | `FgType.Matrix/Matrix2D/Matrix3D` | +| `RichTypeColor3/4` | `FgType.Color3/Color4` | + +Default values come from `defaultForType(FgType.X)` (in `rich-type.ts`), never +from a module-level `new RichType(...)`. + +### 5. Map the math + +Lite core `math/` is **Vec3-centric and minimal** — it has `addVec3`, `subVec3`, +`scaleVec3`, `dotVec3`, `crossVec3`, `lengthVec3`, `normalizeVec3`, `lerpVec3`, +`mat4Multiply`, `mat4Invert`, `mat4Compose`, `mat4Decompose`, `mat4FromQuat`, and +little else. It has **no Vec2, no general quaternion algebra, no +transpose/determinant**. + +Rule (GUIDANCE §4c′): **do not add these to core `math/`.** Put block-specific +math in `flow-graph/fg-math.ts` (and `flow-graph/custom-types/`), lazily imported +by the blocks that need it, so non-interactivity scenes stay byte-for-byte +unchanged. Reuse core `math/` only where it already covers the op. + +When a math op is type-generic (works on number/vecN/matrix), implement a small +dispatcher in `fg-math.ts` (e.g. `fgAdd`, `fgMul`, `fgLength`) that branches on +the runtime value shape — mirroring BJS's per-component handling. + +### 6. Convert per-instance state + +Anything BJS stores via `context._getExecutionVariable(this, key, def)` / +`_setExecutionVariable(this, key, v)` becomes +`getExecVar(ctx, block, key, def)` / `setExecVar(ctx, block, key, v)` (keyed by +`block.id`). **Never** add fields to `FgBlock` for mutable runtime state — it is +immutable topology data; all mutable state lives in `FgContext`. + +### 7. Register the block + +Add **one case** to the `getBlockDef` switch in `flow-graph/block-registry.ts`: + +```typescript +case FgBlockType.Branch: + return async () => (await import("./blocks/control-flow/branch.js")).branchDef; +``` + +This is the only place the block is referenced by string. It is dynamic-imported, +so a scene that never uses the block never bundles it. (`getBlockDef` returns +`null` for unknown types; the glTF interactivity parser treats that as a +loud, structured *unsupported-op* error rather than silently substituting a +no-op — don't change that.) + +### 8. (If glTF-driven) add the declaration-mapper entry + +If the block backs a `KHR_interactivity` op, add an entry to +`flow-graph/gltf/declaration-mapper.ts` mapping the glTF op name to this block. +Copy the structure from the BJS `declarationMapper.ts` entry for the same op, but +as Lite plain data: + +```typescript +"flow/branch": { + blocks: [FgBlockType.Branch], + inputs: { + values: { condition: { name: "condition", gltfType: "bool" } }, + flows: { in: { name: "in" } }, + }, + outputs: { + flows: { true: { name: "onTrue" }, false: { name: "onFalse" } }, + }, +}, +``` + +Handle BJS specifics when present: **value transformers** (e.g. animation time +`seconds → frames`: `dataTransformer: (t) => [t[0] * fps]`), **socket renames** +(`in_$N` for switch cases), **multi-block expansions** (`pointer/set` → +`SetProperty` + `JsonPointerParser` linked via `interBlockConnectors`), and +**integer validation** (switch/multiGate cases must be ints). Reproduce these as +pure functions/data, not classes. + +> **⚠️ The spec is unratified and moving.** `KHR_interactivity` is a draft and +> Babylon.js is reworking its implementation to the newer spec in +> **[PR #18455 "KHR_interactivity rework"](https://github.com/BabylonJS/Babylon.js/pull/18455)** +> (closed-for-age, to be reopened). Treat the op name, its types, and its mapping +> as **volatile**: keep changes confined to `flow-graph/gltf/` +> (mapper/parser/path-converter), never leak op-specific logic into the runtime or +> a block's `execute`/`updateOutputs`. When in doubt, check the op against the +> latest Khronos draft **and** PR #18455; record which BJS commit your mapper +> entry mirrors. If the op changed, prefer a version-tagged branch over silently +> editing the existing entry. + +### 9. Test + +Add a vitest spec next to the block (or in the subsystem test folder): + +```typescript +// build the block + a minimal context/env, drive it, assert. +const block = instantiate(branchDef, { id: "b1", config: {} }); +setDataValue(ctx, block, "condition", true); +branchDef.execute(block, ctx, env, "in"); +expect(firedSignals).toContain("onTrue"); +``` + +For async blocks, tick `onTick` with `deltaMs` and assert `done` fires at the +right time. For glTF-mapped blocks, add a parser test: a small interactivity +JSON fragment → expected `FgGraph` topology. + +### 10. Validate (mandatory before done) + +- `pnpm run lint:fix && pnpm run lint` — must pass (GUIDANCE §6). +- `pnpm test` (vitest unit) for the subsystem. +- If you added/changed an engine path that a parity scene exercises: + `pnpm build:bundle-scenes && pnpm test:parity`, and **commit the regenerated + `lab/public/bundle/manifest.json`** (GUIDANCE §0c). +- **Bundle discipline:** confirm a non-interactivity scene's bundle is unchanged + — the block must tree-shake away entirely when unused. Never raise a bundle + ceiling without explicit user approval (GUIDANCE §2 / §2b′). + +--- + +## Checklist + +- [ ] `FgBlockType` tag added, string value == BJS class name. +- [ ] One file under `blocks//`, exports a single `FgBlockDef`. +- [ ] No classes, no `this`, no module-level `new` / `Map` / `Set`. +- [ ] Sockets declared in `build()`; rich types mapped to `FgType`. +- [ ] Math reused from core where possible, else added to `fg-math.ts` (lazy). +- [ ] Per-instance state via `getExecVar/setExecVar`, not block fields. +- [ ] One `getBlockDef` switch case (dynamic import). +- [ ] Declaration-mapper entry added if glTF-driven (transformers/multi-block/validation preserved). +- [ ] Unit tests for outputs/signals/async timing; parser test if glTF-mapped. +- [ ] Lint + tests green; bundle unchanged for non-interactivity scenes. + +--- + +## Common pitfalls + +- **Pull vs push confusion.** Data inputs are *pulled* on demand inside + `execute`/`updateOutputs` via `getDataValue` — do not "push" data. Signals are + *pushed* via `activateSignal` — do not poll them. +- **Pull recompute, not cache.** `getDataValue` re-runs the producer's + `updateOutputs` on **every** read (matching BJS). `connectionValues` is a + transport slot, not a validity cache. Never short-circuit recomputation on a + cached value or skip `updateOutputs` — a `pointer/get`/`GetVariable`/`random` + output would go stale after an intervening write in the same cascade. Likewise + never cache values on the (immutable) block. +- **Event blocks with an `in` signal.** Drop it — event blocks are driven by the + bus, not an incoming signal (matches BJS `FlowGraphEventBlock`). +- **Module-level allocation.** A top-level `const x = new FgMatrix2D()` or + `new Map()` kills tree-shaking. Construct inside functions; for constants use + plain literals or typed arrays. +- **Bloating core math.** Resist adding Vec2/quaternion/matrix ops to `src/math/`. + Keep them in `flow-graph/fg-math.ts` so unrelated scenes don't grow. +- **Handedness.** glTF is right-handed; if the block reads/writes transforms via + an accessor, ensure Z/quaternion coercion happens at the accessor boundary, not + scattered in block logic. diff --git a/demos-config.json b/demos-config.json index 0eb2a1e31a..e8ebc469ee 100644 --- a/demos-config.json +++ b/demos-config.json @@ -66,6 +66,13 @@ "tags": ["showcase"], "mobile": true }, + { + "slug": "calculator", + "name": "Calculator (KHR_interactivity)", + "description": "Interactive glTF showcase: the Khronos \"Calculator\" sample driven by Babylon Lite's flow-graph runtime (glTF KHR_interactivity). On load the embedded interactivity graph runs — resetting the display to \"00\" by scrolling the digit materials' texture-transform offset, hiding the minus sign via KHR_node_visibility, and seeding scene variables — exercising the ported math, pointer and event blocks. KHR_interactivity is an unratified draft; the runtime tracks the current spec. Khronos glTF-Test-Assets-Interactivity (CC0).", + "tags": ["showcase", "interactivity", "gltf"], + "mobile": true + }, { "slug": "littlest-tokyo", "name": "Littlest Tokyo", diff --git a/docs/lite/architecture/00-overview.md b/docs/lite/architecture/00-overview.md index 5282b9d5ce..d16a9ec6f9 100644 --- a/docs/lite/architecture/00-overview.md +++ b/docs/lite/architecture/00-overview.md @@ -60,6 +60,8 @@ Pages are ordered by how commonly Babylon Lite users reach for them — start wi | [38-bundle-size-tooling.md](/lite/architecture/38-bundle-size-tooling) | Bundle Size Tooling | Per-scene bundle analysis, ceilings, treemaps | | [39-animation-parity-testing.md](/lite/architecture/39-animation-parity-testing) | Animation Parity | Animated scene test methodology | | [40-material-stencil.md](/lite/architecture/40-material-stencil) | Material Stencil | Opt-in `enableMaterialStencil` per-material stencil mask/test on Standard/PBR/Shader, zero-impact hook | +| [41-audio-engine.md](/lite/architecture/41-audio-engine) | Audio Engine | WebAudio engine, sound sources, spatial audio | +| [42-flow-graph.md](/lite/architecture/42-flow-graph) | Flow Graph | Pure-state visual-scripting runtime + glTF `KHR_interactivity` port plan (DESIGN) | --- diff --git a/docs/lite/architecture/42-flow-graph.md b/docs/lite/architecture/42-flow-graph.md new file mode 100644 index 0000000000..f6b2d0f8e9 --- /dev/null +++ b/docs/lite/architecture/42-flow-graph.md @@ -0,0 +1,872 @@ +# Module: flow-graph + +> Package path: `packages/babylon-lite/src/flow-graph/` +> +> Status: **DESIGN / PORT PLAN** (no runtime code yet). This document is the +> formal specification for porting the Babylon.js `FlowGraph` system to Babylon +> Lite. It is written so that the subsystem can be implemented from this doc +> alone, in line with GUIDANCE §4 (Documentation-Driven Architecture). + +--- + +## Purpose + +The flow graph is Babylon's **visual scripting / behaviour runtime**: a directed +graph of nodes ("blocks") that react to engine events and drive scene behaviour +(animate properties, play sounds, branch on conditions, do math). Its primary +real-world consumer is the glTF **`KHR_interactivity`** extension, which embeds +an interactivity graph inside a `.glb` and expects the engine to execute it at +runtime. + +**Goal of this port (phase priority, locked):** load and run interactive glTF +assets. The plan therefore phases: + +1. a minimal **pure-state runtime** (graph + context + connection model), +2. the **subset of blocks** that `KHR_interactivity` actually maps to, and +3. the **`loader-gltf` extension** that parses the interactivity JSON into a + Lite graph and resolves JSON pointers to scene accessors. + +Full editor-authored parity (all ~170 BJS blocks, the BJS snippet/serialization +format, the debugger, multi-context coordinators) is explicitly **out of scope +for the MVP** and is listed as later phases. + +> **⚠️ Spec stability caveat (read before touching `flow-graph/gltf/`):** +> `KHR_interactivity` is **not yet ratified** and is actively changing; Babylon.js +> is reworking its implementation to the newer spec draft in +> **[PR #18455 "KHR_interactivity rework"](https://github.com/BabylonJS/Babylon.js/pull/18455)** +> (currently closed-for-age, expected to be reopened/merged soon). This port +> therefore **isolates all spec-dependent code in `flow-graph/gltf/`** and keeps +> the runtime core spec-agnostic, so future spec revisions are cheap, localized +> edits. See "The spec is NOT ratified" under the loader section and the risks at +> the end. + +--- + +## Architectural Decision: Pure-State, Not Classes + +Babylon.js `FlowGraph` is a deep OOP hierarchy +(`FlowGraphBlock` → `FlowGraphExecutionBlock` → `FlowGraphAsyncExecutionBlock` → +`FlowGraphEventBlock`), with methods on every node, a **global class registry** +(`RegisterClass`) used for deserialization, and module-level `new RichType(...)` +allocations. None of that is permitted in Lite (GUIDANCE §4b′ pure-state +interfaces, §4 zero module-level side effects, 100% tree-shakable). + +**Lite re-architects the same semantics as data + functions:** + +| Concern | Babylon.js (OOP) | Babylon Lite (pure-state) | +|---|---|---| +| A node | `class FooBlock extends FlowGraphBlock` with methods | `FgBlock` **plain data** + a `FgBlockDef` record of **pure functions** | +| Node behaviour | `block._execute(ctx)` method | `def.execute(block, ctx, env, signal)` standalone fn | +| Data output calc | `block._updateOutputs(ctx)` method | `def.updateOutputs(block, ctx, env)` standalone fn | +| Type info | `class RichType` instances at module scope | `const enum FgType` string tags + pure `defaultForType()` | +| className → ctor | global `RegisterClass` registry (side-effect) | tree-shakable `getBlockDef(type)` dynamic-import switch | +| Per-run state | fields on `FlowGraphContext` instance | `FgContext` **plain data** mutated by standalone fns | +| Scene access | block holds/queries `Scene` | block touches **only accessors/handles** wired by the loader; the scene **owns and drives** the graph | + +The key insight that makes this clean: **BJS already keeps per-instance state in +`FlowGraphContext` keyed by block `uniqueId`, not on the block.** The block is +nearly a stateless definition already. Lite finishes the job — the block becomes +*pure data describing topology*, and all behaviour moves into a registry of pure +functions. This also means **porting one block = writing one small data record + +one or two pure functions**, which keeps future block ports mechanical (see the +companion skill `port-flow-graph-block.md`). + +This mirrors two existing Lite patterns: + +- **`loader-gltf/gltf-feature-registry.ts`** — `[trigger, () => import(...)]` + tuples, dynamic-imported only when needed. The block registry uses the same + idea. +- **BJS's own `blockFactory`** — already a tree-shakable `switch` of dynamic + imports (no global side effect). We keep that shape; we only drop the class + bodies behind it. + +--- + +## One-Way Ownership & Scene Drive Model (Critical) + +GUIDANCE §4b forbids components from referencing the scene. A flow graph +obviously needs to read/write node transforms, fire on scene events, and play +animations — so the wiring is inverted exactly like the animation subsystem: + +- The **scene owns** the graphs via an **optional** `scene._flowGraphs?: + FgRuntime[]` field (plain data). It is left `undefined` for non-interactivity + scenes and **lazily created by `attachFlowGraph()`** — so core stays + byte-identical when no graph is attached (verified: the per-scene bundle + manifest is unchanged by this subsystem's existence). +- The graph is **driven** through the scene's existing generic seams, NOT a + hardcoded loop in `scene-core.ts` (GUIDANCE §4c′): `attachFlowGraph()` registers + a `onBeforeRender(scene, cb)` driver (exactly how animation groups are ticked) + and an `onSceneDispose(scene, cb)` teardown. The flow-graph drive therefore + lives entirely in `flow-graph/scene-flow-graph.ts`, pulled into a bundle only + when something imports it (the glTF interactivity feature or user code). +- Blocks **never see the scene**. Anything scene-dependent is pre-resolved by the + **loader** into plain capability objects stored in `FgEnv`: + - **Object accessors** (`FgAccessor { get, set?, target }`) — closures over the + already-loaded Lite scene objects, produced when a glTF JSON pointer is + resolved. A `pointer/get` block just calls `accessor.get()`. + - **Animation handles** — references to the `AnimationGroup` plain-data objects + the glTF loader already created. + - **Event sources** — an `FgEventBus` the scene driver feeds (tick, start, + pointer, key). Event blocks subscribe to the bus, not to the scene. + +This keeps the dependency arrow pointing **scene → flow-graph**, never the +reverse, preserving zero circular deps and tree-shakability. + +--- + +## Public API Surface + +All exported from `packages/babylon-lite/src/flow-graph/index.ts` and re-exported +from the package `index.ts`. Everything is plain data + standalone functions. + +### Core data types (`flow-graph/types.ts`) + +```typescript +/** A value that can flow along a data edge. */ +export type FgValue = + | number | boolean | string + | Vec2 | Vec3 | Vec4 | Quat | Mat4 + | FgInteger // tagged 32-bit int (see CustomTypes below) + | FgMatrix2D | FgMatrix3D + | Color3 | Color4 + | null | undefined; + +/** Type tags. const enum → fully erased at build, zero runtime cost. */ +export const enum FgType { + Any = "any", + Number = "number", + Boolean = "boolean", + String = "string", + Integer = "FlowGraphInteger", + Vector2 = "Vector2", + Vector3 = "Vector3", + Vector4 = "Vector4", + Quaternion = "Quaternion", + Matrix = "Matrix", + Matrix2D = "Matrix2D", + Matrix3D = "Matrix3D", + Color3 = "Color3", + Color4 = "Color4", +} + +/** A data input/output port — plain data. */ +export interface FgDataSocket { + readonly name: string; + readonly type: FgType; + /** wired source (for inputs): producing block id + its output socket name */ + source?: { blockId: string; socket: string }; + /** literal fallback used when `source` is undefined */ + defaultValue?: FgValue; +} + +/** A control-flow (signal) port — plain data. Push model. */ +export interface FgSignalSocket { + readonly name: string; + /** wired targets (for outputs): consuming block id + its input signal name */ + readonly targets: { blockId: string; socket: string }[]; +} + +/** A node instance — PURE DATA describing topology + config only. */ +export interface FgBlock { + readonly id: string; + readonly type: string; // FgBlockType value or "module/Name" + readonly config?: Readonly>; + readonly dataIn: readonly FgDataSocket[]; + readonly dataOut: readonly FgDataSocket[]; + readonly signalIn: readonly FgSignalSocket[]; + readonly signalOut: readonly FgSignalSocket[]; + /** declared by event blocks; which bus event activates them */ + readonly event?: FgEventType; +} + +/** A parsed graph — pure data. */ +export interface FgGraph { + readonly blocks: readonly FgBlock[]; + /** id → block index, for O(1) edge resolution (built by the parser) */ + readonly byId: Readonly>; + /** declared graph variables: name → { type, initialValue } */ + readonly variables: Readonly>; +} +``` + +### Behaviour definitions (`flow-graph/block-def.ts`) + +```typescript +/** The shape a def declares when a block is instantiated. */ +export interface FgBlockShape { + dataIn?: FgDataSocket[]; + dataOut?: FgDataSocket[]; + signalIn?: FgSignalSocket[]; + signalOut?: FgSignalSocket[]; + event?: FgEventType; +} + +/** + * Pure behaviour record for one block type. No classes, no `this`. + * Exactly ONE of these per block kind; this is what a porter writes. + */ +export interface FgBlockDef { + readonly type: string; + + /** Declare sockets/signals from config (called once at instantiation). */ + readonly build: (config: Readonly> | undefined) => FgBlockShape; + + /** DATA blocks: compute outputs from inputs (PULL). Pure-ish: writes via setDataValue. */ + readonly updateOutputs?: (block: FgBlock, ctx: FgContext, env: FgEnv) => void; + + /** EXECUTION blocks: run when an input signal fires (PUSH). For async blocks + * this is also where a task is started, via addPending(ctx, block). */ + readonly execute?: (block: FgBlock, ctx: FgContext, env: FgEnv, incomingSignal: string) => void; + + /** ASYNC blocks: advance one outstanding task each frame (e.g. delay countdown, + * animation progress). The tick loop passes the specific FgPendingTask. */ + readonly onTick?: (block: FgBlock, ctx: FgContext, env: FgEnv, deltaMs: number, task: FgPendingTask) => void; + /** ASYNC blocks: teardown hook called on dispose/cancel; mark tasks canceled. */ + readonly cancelPending?: (block: FgBlock, ctx: FgContext, env: FgEnv) => void; +} +``` + +### Execution context & environment (`flow-graph/context.ts`) + +```typescript +/** Per-execution-instance MUTABLE state — plain data. */ +export interface FgContext { + /** data transport slots: `${blockId}:${socket}` → last value the producer wrote. + * NOT a validity cache — producers recompute on every pull (see execution model). */ + readonly connectionValues: Record; + /** per-block scratch: `${blockId}:${key}` → value (counter state, async tokens, resolving guard) */ + readonly executionVariables: Record; + /** live graph variable values (seeded from FgGraph.variables) */ + readonly userVariables: Record; + /** async task records, ticked each frame (deduped; carry cancel tokens) */ + readonly pending: FgPendingTask[]; + /** glTF graphs are right-handed; drives Z/handedness coercion on read */ + readonly rightHanded: boolean; + /** @internal monotonic token source for addPending (unique task tokens) */ + _tokenSeq: number; +} + +/** One outstanding async task (a delay, an animation). Token enables precise cancel. */ +export interface FgPendingTask { + readonly blockId: string; + readonly token: number; // unique per task; a block may own several concurrently + canceled: boolean; + /** set by onTick when finished; compacted out after the frame's pending loop */ + done: boolean; + state: Record; // e.g. remainingMs, delayIndex, animation handle +} + +/** Per-graph RESOLVED capabilities, wired by the loader. Read-mostly. */ +export interface FgEnv { + readonly graph: FgGraph; + /** block defs resolved up-front (awaited dynamic imports), type → def */ + readonly defs: Record; + /** scene-object accessors resolved from JSON pointers, keyed by pointer id */ + readonly accessors: Record; + /** animation handles by glTF animation index */ + readonly animations: readonly AnimationGroup[]; + /** scene-owned capabilities blocks may invoke WITHOUT a scene reference: + * play/stop animation, create a temp interpolation group, subscribe to + * animation-end, etc. Provided by the loader/animation subsystem. */ + readonly caps: FgCapabilities; + /** event bus the scene driver feeds */ + readonly events: FgEventBus; +} + +export interface FgAccessor { + readonly type: FgType; + readonly get: () => FgValue; + readonly set?: (value: FgValue) => void; + readonly target?: unknown; +} +``` + +### Standalone runtime functions (`flow-graph/runtime.ts`) + +```typescript +/** PULL a data input: resolve source, run its def.updateOutputs, read cache. */ +export function getDataValue(ctx: FgContext, env: FgEnv, block: FgBlock, socket: string): FgValue; +/** Write a data output into the cache (called from def.updateOutputs). */ +export function setDataValue(ctx: FgContext, block: FgBlock, socket: string, value: FgValue): void; +/** PUSH a signal: for each target, dispatch into its def.execute. */ +export function activateSignal(ctx: FgContext, env: FgEnv, block: FgBlock, socket: string): void; + +/** Instantiate runtime state for a parsed graph. */ +export function createFgContext(graph: FgGraph, opts?: { rightHanded?: boolean }): FgContext; +/** Build the resolved env (await needed defs, attach accessors/animations/bus). + * FAILS LOUDLY (throws) on an unsupported block type — see registry note. */ +export function createFgEnv(graph: FgGraph, wiring?: FgWiring): Promise; + +/** One graph runtime = graph + context + env, owned by the scene. */ +export interface FgRuntime { + readonly graph: FgGraph; + readonly context: FgContext; + readonly env: FgEnv; + started: boolean; + /** @internal bus unsubscribe fns registered at start, called on dispose */ + _unsub: (() => void)[]; +} + +/** Convenience: build env (awaiting defs) + context in one call. */ +export function createFgRuntime(graph: FgGraph, wiring?: FgWiring, opts?: { rightHanded?: boolean }): Promise; + +/** Start the graph: subscribe ALL non-start receivers first (init-priority + * order), THEN fire `onStart` event blocks once. Idempotent. */ +export function startFlowGraph(rt: FgRuntime): void; +/** Per-frame drive: pump tick events + advance pending async blocks. */ +export function tickFlowGraph(rt: FgRuntime, deltaMs: number): void; +/** Tear down: cancel pending, clear caches, detach bus listeners. */ +export function disposeFlowGraph(rt: FgRuntime): void; + +// Pending-task helpers used by async block defs and the tick loop: +export function addPending(ctx: FgContext, block: FgBlock, state?: Record): FgPendingTask; +export function stillPending(ctx: FgContext, task: FgPendingTask): boolean; +export function cancelPendingForBlock(ctx: FgContext, block: FgBlock): void; +export function compactPending(ctx: FgContext): void; +``` + +### Scene attachment (`flow-graph/scene-flow-graph.ts`) + +```typescript +/** Attach a runtime to a scene: starts on the first frame, ticks every frame, + * auto-disposed on scene dispose. Lazily creates `scene._flowGraphs`. Uses the + * generic onBeforeRender/onSceneDispose seams (no scene-core loop). */ +export function attachFlowGraph(scene: SceneContext, rt: FgRuntime): void; +/** Detach + dispose a previously attached runtime. */ +export function detachFlowGraph(scene: SceneContext, rt: FgRuntime): void; +``` + +### Block-type names & registry (`flow-graph/block-type.ts`, `block-registry.ts`) + +```typescript +/** Lite block type identifiers (const enum string tags). */ +export const enum FgBlockType { + // Events + SceneStart = "SceneReadyEvent", + SceneTick = "SceneTickEvent", + SendCustomEvent = "SendCustomEvent", + ReceiveCustomEvent = "ReceiveCustomEvent", + // Control flow + Branch = "Branch", Sequence = "Sequence", Switch = "Switch", + ForLoop = "ForLoop", WhileLoop = "WhileLoop", DoN = "DoN", + MultiGate = "MultiGate", WaitAll = "WaitAll", Throttle = "Throttle", + SetDelay = "SetDelay", CancelDelay = "CancelDelay", + // Data / math (subset; see block list) + Constant = "Constant", Add = "Add", Subtract = "Subtract", /* … */ + // Pointer / variable / animation + GetProperty = "GetProperty", SetProperty = "SetProperty", + JsonPointerParser = "JsonPointerParser", + GetVariable = "GetVariable", SetVariable = "SetVariable", + ValueInterpolation = "ValueInterpolation", + PlayAnimation = "PlayAnimation", StopAnimation = "StopAnimation", + // Debug + ConsoleLog = "ConsoleLog", +} + +/** + * Tree-shakable, side-effect-free. Returns a lazy loader for one def. + * Unused cases are code-split and never fetched — zero bytes for scenes + * without interactivity. Mirrors BJS blockFactory + Lite gltf-feature-registry. + */ +export function getBlockDef(type: string): () => Promise { + switch (type) { + case FgBlockType.Branch: + return async () => (await import("./blocks/control-flow/branch.js")).branchDef; + case FgBlockType.Add: + return async () => (await import("./blocks/math/add.js")).addDef; + // … one case per supported block … + default: + return null; // unknown type — caller decides (see note) + } +} +``` + +> **Note on side effects:** the `switch` function body is pure (no module-level +> allocation), so the registry module is fully tree-shakable. +> +> **Unknown ops:** `getBlockDef` returns `null` for unknown types; the **caller** +> chooses the policy. The **glTF interactivity parser fails loudly** (collects a +> structured `unsupportedOp` diagnostic and aborts/flags that graph) so a +> `KHR_interactivity` asset can't silently render a broken interaction. A +> permissive editor/snippet path (post-MVP) may instead substitute an explicit +> `noopDef`. Never silently swallow an unknown op on the KHR path — it makes +> parity failures undiagnosable. + +--- + +## Internal Architecture + +### Execution model (pull data, push signals) + +Identical semantics to BJS, re-expressed functionally: + +- **Data edges are PULL, and recompute on every pull.** When a block needs an + input, `getDataValue` looks at the socket's `source`. If wired, it finds the + producing block and **invokes that block's `def.updateOutputs` every time** + (matching BJS `FlowGraphDataConnection.getValue`, which calls the owner's + `_updateOutputs` on each read). `ctx.connectionValues` is the **transport slot** + the producer writes into and the consumer reads back — **not** a validity + cache. Do **not** skip recomputation based on a cached value: producers like + `pointer/get`, `GetVariable`, `random`, and event-payload outputs would return + stale data after an intervening `pointer/set`/`SetVariable` in the same + cascade. (A future optimization may add explicit dirty-tracking with cascade + IDs, but it must exclude all non-pure producers; the MVP recomputes.) +- **Cycles.** Data pulls assume an acyclic data subgraph (as glTF interactivity + requires). `getDataValue` carries an in-progress guard + (`ctx.executionVariables["${id}:resolving"]`) to break accidental data cycles + and return the socket default rather than recursing infinitely. +- **Type coercion on read.** `getDataValue` applies (a) any socket/port + `dataTransformer` declared by the declaration mapper, then (b) `coerceValue` + for the consumer socket's `FgType` — this is where BJS's RichType + `typeTransformer` lives (notably `Vector4`/`Matrix` → `Quaternion`). Coercion + happens at the boundary so block bodies stay type-clean. +- **Signal edges are PUSH.** `activateSignal` iterates `signalSocket.targets`; + for each, it looks up the target block's def and calls `def.execute`, passing + the incoming signal name. Execution blocks call `activateSignal` on their own + outputs to continue the cascade. +- **Event blocks** declare `event: FgEventType`. The scene driver, on receiving a + bus event, finds matching event blocks and fires their output signals + (`out` / `done`), starting a cascade. **Listener registration ordering is + significant:** `startFlowGraph` first subscribes/initializes *all* event + receivers (in init-priority order — `ReceiveCustomEvent` before scene-start-like + events, matching BJS `initPriority`), and *only then* fires the `onStart` + cascade. Otherwise a graph like `onStart → SendCustomEvent → ReceiveCustomEvent` + would drop the event because the receiver wasn't listening yet. Custom events + are **scene/coordinator-scoped**, not per-graph, so multiple `KHR_interactivity` + graphs in one asset can communicate. +- **Async blocks** (`SetDelay`, `PlayAnimation`) register an `FgPendingTask` via + `addPending(ctx, block)` (which assigns a unique token and **dedupes** so a + block re-entered while already pending does not double-tick), get `onTick`'d + each frame, and fire a completion signal (`done`) when finished, then remove + the task. A single block may own **multiple concurrent tasks** (BJS supports + several in-flight delays per block, tracked by index) — state lives on the + task record, not the block. `cancelPending` marks the matching task(s) + `canceled`; the tick loop skips canceled/removed tasks (see below). + +### Async task lifecycle (ordering hazards) + +The per-frame pending loop must be cancellation-safe. A task can be canceled or a +new task added **during** the loop by another block's signal cascade. Rules: + +``` +for task of ctx.pending.slice(): // snapshot + if task.canceled: continue // skip if canceled this frame + if !stillPending(ctx, task): continue // skip if already removed + env.defs[blockOf(task)].onTick(task, …) // may add/cancel further tasks +ctx.pending = ctx.pending.filter(t => !t.canceled && !t.done) // compact after +``` + +Tasks added mid-loop are picked up next frame (not retro-ticked), matching BJS. +Each `FgContext` has its own `pending` list, so multiple contexts (multi-actor) +never interfere. + +### Per-frame flow + +``` +scene._beforeRender(deltaMs) // existing Lite hook + └─ for rt of scene._flowGraphs: + if !rt.started: + registerEventListeners(rt) // subscribe ALL receivers first (init-priority order) + startFlowGraph(rt) // THEN fire onStart event blocks once + tickFlowGraph(rt, deltaMs): + resetCustomEventRecursionCounters(rt.context) // guard against runaway re-entrancy + env.events.pump("tick", { deltaTime: deltaMs/1000 }) // → onTick blocks + for task of ctx.pending.slice(): // cancellation-safe (see async lifecycle) + if task.canceled || !stillPending(ctx, task): continue + env.defs[blockOf(task)].onTick?.(task, ctx, env, deltaMs) + compactPending(ctx.context) +``` + +Pointer/key events are fed into `env.events` by the picking/input layer the same +way (the scene driver forwards them); event blocks for those simply subscribe to +the corresponding bus channel. + +### Custom math & types live IN the subsystem (bundle discipline) + +Lite's core `math/` module is intentionally minimal (Vec3-centric; **no Vec2, +no general quaternion/matrix algebra**). Flow-graph math blocks need Vec2, +quaternion mul/conjugate/slerp, matrix transpose/determinant/inverse/compose/ +decompose, integer bitwise ops, etc. Per GUIDANCE §4c′ (always extensions, never +bloat the core) these helpers live **inside `flow-graph/`** (e.g. +`flow-graph/fg-math.ts`, `flow-graph/custom-types/`), lazily imported by the +blocks that use them. Scenes without interactivity pay **zero bytes**. Core +`math/` is reused where it already suffices (`addVec3`, `dotVec3`, `crossVec3`, +`mat4Multiply`, `mat4Invert`, `mat4Compose`, `mat4Decompose`, `mat4FromQuat`). + +### Custom types (`flow-graph/custom-types/`) + +- `FgInteger` — glTF distinguishes `int` from `float`; represented as a tagged + plain object `{ value: number; __fgInt: true }` (no class) so type coercion and + bitwise ops work. Pure helpers `fgInt(n)`, `isFgInt(v)`. +- `FgMatrix2D` / `FgMatrix3D` — plain `Float32Array`-backed (`{ m: Float32Array }`) + for `float2x2` / `float3x3` glTF types, with pure op helpers. +- `Vec2` — add to `flow-graph/types.ts` (or a tiny local) since core math lacks it. + +### Rich-type behaviour without RichType instances (`flow-graph/rich-type.ts`) + +BJS's `RichType` carries three things Lite must preserve even though it drops the +class: a **default value**, a **typeTransformer**, and an **animationType**. + +- `defaultForType(t: FgType): FgValue` — pure switch returning the type's default + (e.g. `0`, `false`, fresh `Vec3.zero`, identity quaternion). Replaces + `RichType.defaultValue`. Constructs values **inside the function** (no + module-level allocation). +- `coerceValue(value, target: FgType): FgValue` — the home of BJS's + `typeTransformer`. Critically includes **`Vector4`/`Matrix` → `Quaternion`** and + numeric↔integer coercions. Invoked by `getDataValue` on read (step (b) above), + and by the mapper when a config/socket declares a `flowGraphType` different from + its `gltfType`. +- `animationTypeForFgType(t: FgType): number` — replaces `RichType.animationType`, + used by `ValueInterpolation`/animation blocks to pick the correct keyframe + interpolation (float/vector/quaternion/color/matrix). Quaternion targets must + resolve to slerp; the mapper's `useSlerp` flag forces `FgType.Quaternion`. + +These three pure functions are the complete replacement for the `RichType` class +and its module-scope instances. + +--- + +## glTF `KHR_interactivity` Loader Extension + +> Package path: `packages/babylon-lite/src/loader-gltf/gltf-feature-interactivity.ts` +> plus `flow-graph/gltf/` for the parser + declaration mapper. + +### ⚠️ The spec is NOT ratified — isolate everything spec-dependent + +`KHR_interactivity` is **still a draft / not ratified**; the op set, type system, +JSON pointer semantics, and node/declaration shapes **will change**. Babylon.js +is reworking its implementation to the newer spec draft in +**[PR #18455 "KHR_interactivity rework"](https://github.com/BabylonJS/Babylon.js/pull/18455)** +— currently **closed** (it was auto-closed for age) but **expected to be +reopened/merged soon**. So even BJS is a moving target here, and our mapping +tables must track that PR as it lands. + +**Design rule (mandatory):** the spec-volatile surface must be **quarantined in +`flow-graph/gltf/`** and depend on the runtime, never the reverse. The runtime +core (`runtime.ts`, `block-def.ts`, `context.ts`, blocks) must contain **zero** +glTF/`KHR_interactivity` knowledge, so a spec revision never touches the engine — +only the `gltf/` translation layer: + +- `interactivity-parser.ts` — JSON shape of nodes/declarations/variables/flows. +- `declaration-mapper.ts` — the op→block table (the part most likely to churn). +- `path-converter.ts` + `object-model-mapping.ts` — JSON-pointer semantics. +- a `gltf/spec-version.ts` constant + a place to branch behaviour if we must + support more than one draft simultaneously. + +**Practical guardrails so future spec changes are cheap:** +- Keep the op→block mapping a **plain-data table**, not code, so edits are diffs + to data (and so a future "import the BJS table" step stays mechanical). +- Treat the **BJS rework PR ([#18455 "KHR_interactivity rework"](https://github.com/BabylonJS/Babylon.js/pull/18455), + closed-for-age, to be reopened)** as the reference target; when it lands, + re-diff our `declaration-mapper.ts` against it. Record which BJS commit/PR our + table mirrors in a header comment so drift is auditable. +- **Version-tag** parser/mapper behaviour; if Khronos bumps the draft, add a + branch keyed on the asset's declared spec version rather than mutating the + existing path. +- Unknown/changed ops already **fail loudly** with a structured diagnostic (see + registry note) — that is the early-warning signal that the spec moved. +- The companion skill (`port-flow-graph-block.md`) is the routine for absorbing + new/changed ops as the spec and the BJS PR evolve. + +### Registration (no side effects, lazy) + +Add one tuple to `gltf-feature-registry.ts`, identical to every other feature: + +```typescript +["KHR_interactivity", () => import("./gltf-feature-interactivity.js")], +``` + +The feature implements `GltfFeature.applyAsset(meshes, root, ctx)`: + +1. Read `ctx._json.extensions.KHR_interactivity.graphs`. +2. For each graph, run the **interactivity parser** → `FgGraph` (plain data). +3. **Resolve JSON pointers** in the graph to `FgAccessor`s over the already-built + Lite scene objects (nodes/meshes/cameras/materials/animations) via a path + converter (Lite analogue of BJS `gltfPathToObjectConverter` + + `objectModelMapping`). +4. Build `FgEnv` (await needed block defs, attach accessors/animations/event bus). +5. Return `{ flowGraphs: [FgRuntime] }` merged into the `AssetContainer`. + `addToScene` pushes them onto `scene._flowGraphs` and registers the + `_beforeRender` driver + disposer. + +> The loader sets the equivalent of BJS's `_skipStartAnimationStep` — animations +> referenced by interactivity must **not** auto-play; the graph controls them. + +### Interactivity parser (`flow-graph/gltf/interactivity-parser.ts`) + +Pure translation of the glTF interactivity JSON +(`types`, `declarations`, `variables`, `events`, `nodes`, `flows`) into an +`FgGraph`. Stages mirror BJS `InteractivityGraphToFlowGraphParser`: +`parseTypes → parseDeclarations → parseVariables → parseEvents → parseNodes → +parseConnections`. Output is plain data — **no block instances, no class registry**. + +glTF→Lite type table (from BJS, kept verbatim): + +| glTF type | length | FgType | element | +|---|---|---|---| +| `float` | 1 | Number | number | +| `bool` | 1 | Boolean | boolean | +| `int` | 1 | Integer | number | +| `float2` | 2 | Vector2 | number | +| `float3` | 3 | Vector3 | number | +| `float4` | 4 | Vector4 | number | +| `float2x2` | 4 | Matrix2D | number | +| `float3x3` | 9 | Matrix3D | number | +| `float4x4` | 16 | Matrix | number | + +### Declaration mapper (`flow-graph/gltf/declaration-mapper.ts`) + +The largest single artefact (BJS = ~1,851 lines, **168 ops**). It is a **data +table** mapping each glTF op (`"math/add"`, `"flow/branch"`, `"pointer/set"`, …) +to: target Lite block type(s), socket renames, config translation, value +transformers (e.g. seconds→frames for animation time), and multi-block expansions +(e.g. `pointer/set` → `SetProperty` + `JsonPointerParser` linked by an +inter-block connector). This is plain data + small pure transformer functions — +no classes. Porting entries is the bulk of ongoing work and is exactly what the +companion **skill** automates. + +```typescript +export interface FgDeclMapping { + blocks: FgBlockType[]; + inputs?: { values?: Record; flows?: Record }; + outputs?: { values?: Record; flows?: Record }; + configuration?: Record; + interBlockConnectors?: { input: string; output: string; inBlock: number; outBlock: number }[]; + extraProcessor?: (gltfBlock: unknown, /* … */) => FgBlock[]; +} +export function getMappingForOp(op: string, extension?: string): FgDeclMapping | undefined; +``` + +--- + +## Block coverage for the MVP (KHR_interactivity subset) + +`KHR_interactivity` maps to ~60 distinct Lite block types (the 168 ops collapse +onto fewer blocks via config). MVP target set, by category: + +- **Events (4):** `onStart`→SceneStart, `onTick`→SceneTick, `event/send`→SendCustomEvent, `event/receive`→ReceiveCustomEvent. +- **Flow control (11):** branch, sequence, switch, while, for, doN, multiGate, waitAll, throttle, setDelay, cancelDelay. +- **Math constants/arithmetic/comparison/trig/exp (~60 ops → ~40 blocks):** E, Pi, Inf, NaN, random; abs/sign/trunc/floor/ceil/round/fract/neg; add/sub/mul/div/rem/min/max/clamp/saturate/mix; eq/lt/le/gt/ge/select; sin…atanh; exp/log/log2/log10/sqrt/cbrt/pow; isNaN/isInf; rad/deg. +- **Vector/matrix/quaternion (~25 ops):** length, normalize, dot, cross, rotate2D/3D, transform, transpose, determinant, inverse, matMul, matCompose/Decompose, combineN/extractN, quat ops. +- **Integer bitwise (9):** not/and/or/xor/asr/lsl/clz/ctz/popcnt. +- **Type conversion (6):** bool/int/float cross conversions. +- **Variables (3):** get, set, interpolate. +- **Pointers (3):** get, set, interpolate (+ JsonPointerParser). +- **Animation (3):** start, stop, stopAt (+ ArrayIndex / data provider). +- **Debug (1):** log. + +Each block is one small file under `flow-graph/blocks//.ts` +exporting a `FgBlockDef`. See the skill doc for the exact file template. + +--- + +## State Machine / Lifecycle + +``` +load .glb with KHR_interactivity + └─ gltf-feature-interactivity.applyAsset + ├─ interactivity-parser → FgGraph (pure data) + ├─ path-converter → FgAccessor map + ├─ createFgEnv (await needed defs, wire bus/accessors/animations) + ├─ createFgContext (seed userVariables) + └─ return { flowGraphs:[ FgRuntime ] } +addToScene + └─ scene._flowGraphs.push(rt); onBeforeRender(scene, drive); onSceneDispose(scene, () => disposeFlowGraph(rt)) +first frame + └─ registerEventListeners(rt) // subscribe ALL receivers first, init-priority order + └─ startFlowGraph(rt) // THEN fire onStart cascade once +each frame + └─ tickFlowGraph(rt, deltaMs) // reset recursion counters → pump tick → advance pending async +dispose + └─ disposeFlowGraph(rt) // cancel pending tasks, clear ctx, detach bus listeners +``` + +--- + +## Babylon.js Equivalence Map + +| Babylon.js | Babylon Lite | +|---|---| +| `FlowGraphBlock` (class) | `FgBlock` (data) + `FgBlockDef` (functions) | +| `FlowGraphExecutionBlock._execute` | `FgBlockDef.execute` | +| `FlowGraphBlock._updateOutputs` | `FgBlockDef.updateOutputs` | +| `FlowGraphAsyncExecutionBlock` | `FgBlockDef.execute` (starts task) + `onTick(task)` + `cancelPending` | +| `FlowGraphEventBlock` | `FgBlock.event` + scene-driven bus dispatch | +| `FlowGraphDataConnection.getValue/setValue` | `getDataValue` / `setDataValue` | +| `FlowGraphSignalConnection._activateSignal` | `activateSignal` | +| `FlowGraphContext` | `FgContext` (data) + `FgEnv` (resolved caps) | +| `FlowGraphCoordinator` (multi-graph) | `scene._flowGraphs` + scene driver | +| `FlowGraphSceneEventCoordinator` | `FgEventBus` fed by scene/input layer | +| `RichType` instances | `const enum FgType` + `defaultForType()` | +| `RegisterClass` global registry | `getBlockDef` dynamic-import switch | +| `blockFactory` (already lazy) | `getBlockDef` (same shape, no classes) | +| `gltfPathToObjectConverter` + `objectModelMapping` | `flow-graph/gltf/path-converter.ts` → `FgAccessor` | +| `InteractivityGraphToFlowGraphParser` | `flow-graph/gltf/interactivity-parser.ts` | +| `declarationMapper.ts` table | `flow-graph/gltf/declaration-mapper.ts` table | +| `KHR_interactivity` loader extension | `loader-gltf/gltf-feature-interactivity.ts` | + +--- + +## Dependencies + +- **Core math** (`src/math/`): reuse `addVec3`, `dotVec3`, `crossVec3`, + `mat4Multiply`, `mat4Invert`, `mat4Compose`, `mat4Decompose`, `mat4FromQuat`. +- **Animation** (`src/animation/`): `AnimationGroup` handles for animation blocks; + `ValueInterpolation` reuses the interpolation/easing machinery where possible. +- **Scene** (`src/scene/`): `onBeforeRender`, `onSceneDispose`, `addToScene` + dispatch, the `_flowGraphs` array (new field). +- **Loader-gltf** (`src/loader-gltf/`): `GltfFeature` hook + registry tuple; the + node/material/camera maps the path-converter resolves against. +- **Picking/input**: pointer & key events forwarded into `FgEventBus`. +- **New, subsystem-local:** `flow-graph/fg-math.ts`, `flow-graph/custom-types/`. + +--- + +## Test Specification + +- **Unit (vitest):** per-block defs — feed inputs, assert outputs/signals + (pure functions, trivial to test). Runtime: **pull recompute** (verify a + producer re-runs on every read, no stale cache), push cascade, async delay + countdown with **cancellation mid-tick** and **multiple concurrent delays per + block**, event dispatch with **listener-before-onStart ordering** (custom event + fired from `onStart` is received). +- **Math parity tests (focused):** quaternion mul/conjugate/slerp, + matrix compose/decompose/transpose/inverse, `math/transform`, and the + **accessor-boundary handedness** (right-handed glTF value → Lite LH) — these are + the easiest places to diverge on multiplication order / row-vs-column layout. +- **Coercion tests:** `Vector4`/`Matrix` → `Quaternion` via `coerceValue`; + `animationTypeForFgType` picks slerp for quaternion targets. +- **Parser unit tests:** representative interactivity JSON → expected `FgGraph` + topology; declaration-mapper entries → expected blocks/sockets/config; an + **unknown op fails loudly** with a structured diagnostic (not a silent no-op). +- **Integration / parity:** a `KHR_interactivity` sample `.glb` (e.g. a + Khronos sample like a button that animates on click, or `onStart`→rotate) + loaded in Lite; assert the driven property changes over frames. Where a visual + golden is warranted, follow §2c animated-scene golden convention + (`?seekTime=` freeze). Add a `scene-config.json` entry + bundle-size ceiling + only when a parity scene is added. +- **Bundle-size guard:** verify a non-interactivity scene's bundle is + **byte-unchanged** (the subsystem must be fully tree-shaken away when unused). + +--- + +## File Manifest (target) + +``` +packages/babylon-lite/src/flow-graph/ + index.ts # public exports + types.ts # FgValue, FgType, FgBlock, FgGraph, sockets + block-def.ts # FgBlockDef, FgBlockShape + block-type.ts # FgBlockType const enum + block-registry.ts # getBlockDef() dynamic-import switch + context.ts # FgContext, FgEnv, FgAccessor + runtime.ts # getDataValue/setDataValue/activateSignal, FgRuntime, start/tick/dispose + event-bus.ts # FgEventBus, FgEventType, subscribe/pump/clear + fg-math.ts # Vec2 + quaternion/matrix/bitwise helpers (lazy) + rich-type.ts # defaultForType(), coerceValue(), animationTypeForFgType() + scene-flow-graph.ts # attachFlowGraph/detachFlowGraph (onBeforeRender/onSceneDispose seams) + custom-types/ + fg-integer.ts + fg-matrix.ts + blocks/ + events/{scene-start,scene-tick,send-custom-event,receive-custom-event}.ts + control-flow/{branch,sequence,switch,for-loop,while-loop,do-n,multi-gate,wait-all,throttle,set-delay,cancel-delay}.ts + math/{add,subtract,…}.ts + data/{constant,get-variable,set-variable,get-property,set-property,json-pointer-parser}.ts + animation/{play-animation,stop-animation,value-interpolation}.ts + debug/console-log.ts + noop.ts + gltf/ + interactivity-parser.ts + declaration-mapper.ts + path-converter.ts + object-model-mapping.ts +packages/babylon-lite/src/loader-gltf/ + gltf-feature-interactivity.ts # GltfFeature.applyAsset + gltf-feature-registry.ts # + ["KHR_interactivity", () => import(...)] +packages/babylon-lite/src/scene/ + scene-core.ts # + optional `_flowGraphs?` field (type-only; zero runtime cost) +``` + +> Note: the scene driver is NOT hardcoded in `scene-core.ts`. `scene-flow-graph.ts` +> attaches via the existing `onBeforeRender`/`onSceneDispose` seams, so non- +> interactivity scenes stay byte-identical (verified against the bundle manifest). + +--- + +## Phased Implementation Plan + +> Each phase is independently mergeable; engine-changing phases must pass +> `pnpm build:bundle-scenes` + `pnpm test:parity` and commit the regenerated +> `lab/public/bundle/manifest.json` (GUIDANCE §0c). + +**Phase 0 — Spec & skill (this doc + `port-flow-graph-block.md`).** ✅ DONE — no code. + +**Phase 1 — Core runtime, no blocks.** ✅ **DONE.** `types.ts`, `block-def.ts`, +`context.ts`, `runtime.ts`, `event-bus.ts`, `rich-type.ts`, `block-type.ts`, +`custom-types/{fg-integer,fg-matrix}.ts`, `scene-flow-graph.ts`, and an empty +`block-registry.ts`. Pure functions only. 24 unit tests cover pull recompute, +push cascade, branch routing, async pending (countdown, dedupe, cancel +mid-tick, no retro-tick), event dispatch + custom-event-during-start ordering, +tick pump, dispose, variable seeding, loud-fail on unknown ops, and rich-type +defaults/coercion. Scene drive wired via the `onBeforeRender`/`onSceneDispose` +seams. **Guard met:** non-interactivity bundle manifest byte-identical. + +**Phase 2 — Vertical slice (end-to-end EARLY).** Implement the *minimum* set that +proves the whole pipeline before the long tail of blocks: `SceneStart`, +`SceneTick`, `Branch`, `Sequence`, one math op (`Add`), `GetProperty`/`SetProperty` ++ `JsonPointerParser`, `PlayAnimation`/`StopAnimation`; plus a **minimal** +parser + declaration-mapper (just those ops) + path-converter + the +`gltf-feature-interactivity.ts` loader hook; plus **one** Khronos +`KHR_interactivity` sample running end-to-end as a parity scene. This surfaces +mapper/accessor/runtime/handedness mismatches immediately rather than after the +whole block library is written. + +**Phase 3 — Broaden block library.** Fill in the rest of the ~60 blocks +(full math/trig/exp, vector/matrix/quaternion, integer bitwise, type conversion, +variables, interpolation, control-flow remainder) + `fg-math.ts` + +`custom-types/**`; register each in `block-registry.ts`. Unit-test each def. + +**Phase 4 — Complete the declaration mapper + more scenes.** Extend `gltf/` +mapper/parser to the full 168-op surface; add further `KHR_interactivity` parity +scenes, `scene-config.json` entries, bundle ceilings. Run full `pnpm test`. + +**Phase 5+ (post-MVP, out of MVP scope):** remaining blocks toward full parity; +multi-context coordinator; debugger hooks; physics/audio interactivity ops; and +**BJS-native FlowGraph serialization parsing** (the editor / `FlowGraphCoordinator.serialize()` +JSON — `{ allBlocks: [{ className, config, dataInputs/Outputs, signalInputs/Outputs }], +executionContexts: [...] }`). The pure-data runtime already consumes any `FgGraph`, +so this is a **second parser front-end** alongside `gltf/interactivity-parser.ts`, +producing the same `FgGraph`. Concrete sub-tasks: (1) a parser reading +`allBlocks` + the connection-point graph; (2) a `className → FgBlockType` map for +the registry; (3) rich-type value de/serialization (Vec2/3, quaternion, matrix, +integer); (4) a scene-object-reference binding layer (BJS serializes mesh/node/ +material refs by Babylon className + id/name — analogous to the glTF path-converter +but for BJS's asset-ref scheme). Gated on actually needing BJS-editor interop, and +on the format settling after #18455 (it is a BJS-*internal*, higher-churn format +than the standardized glTF draft). + +--- + +## Open Questions / Risks + +- **⚠️ Unratified spec (highest-churn risk).** `KHR_interactivity` is a draft; the + op set/types/pointer semantics will change, and BJS is reworking its + implementation to the newer draft in + **[PR #18455](https://github.com/BabylonJS/Babylon.js/pull/18455)** (closed for + age, to be reopened) — so even BJS is a moving target. Mitigation is + structural — all spec-dependent code is quarantined in `flow-graph/gltf/` + (parser, mapper, path-converter), version-tagged, and mirrored against a + recorded BJS commit; the runtime core stays spec-agnostic so revisions never + touch the engine. Re-sync the mapper when #18455 lands. See the loader + section's "spec is NOT ratified" subsection. +- **Event bus surface.** Exact channels (tick/start/pointer/key/custom) and how + the picking/input layer forwards pointer & key events need a small design pass + in Phase 1; the bus must stay pure data + standalone subscribe/pump fns. +- **`ValueInterpolation` reuse.** Decide how much of `src/animation/` easing the + interpolation block can reuse vs. a subsystem-local easing helper. +- **Right-handedness.** glTF graphs are right-handed; confirm where Z/quaternion + coercion happens on accessor read/write to match Lite's LH convention. +- **Parser scale.** The declaration mapper is large; Phases 3–4 should land an + initial subset (events + flow + core math + pointer get/set) before the long + tail of math ops, so an end-to-end scene works early. diff --git a/lab/lite/demo-calculator.html b/lab/lite/demo-calculator.html new file mode 100644 index 0000000000..3c52f0871c --- /dev/null +++ b/lab/lite/demo-calculator.html @@ -0,0 +1,140 @@ + + + + + + + Babylon Lite — Demo: Calculator (KHR_interactivity) + + + + + + + + “Calculator” Khronos glTF KHR_interactivity test asset + +
+
+ +
Loading Calculator…
+
Fetching interactive glTF model & studio environment…
+ + +
+
+ + + + + diff --git a/lab/lite/src/demos/calculator.ts b/lab/lite/src/demos/calculator.ts new file mode 100644 index 0000000000..ba033eb885 --- /dev/null +++ b/lab/lite/src/demos/calculator.ts @@ -0,0 +1,146 @@ +// Demo — Calculator (glTF KHR_interactivity showcase) +// +// Loads the Khronos "Calculator" sample glb and runs its embedded +// KHR_interactivity graph with Babylon Lite's flow-graph runtime. On load the +// graph's `event/onStart` chain fires: it resets the two display digits to "00" +// by driving `pointer/set` on the digit materials' KHR_texture_transform offset +// (the digit atlas is scrolled so both wheels show "0"), hides the minus sign +// via KHR_node_visibility, and seeds the calculator's scene variables. The math +// blocks (add/sub/mul/div/rem/abs/floor/lt/clamp + combine2/extract2) and the +// pointer get/set accessors are all exercised by this graph. +// +// ⚠️ KHR_interactivity is an UNRATIFIED glTF draft; this demo (and the runtime +// behind it) tracks the current draft and will be re-synced when the spec and +// Babylon.js PR #18455 land. +// +// Model: "Calculator" from the Khronos glTF-Test-Assets-Interactivity repo +// https://github.com/KhronosGroup/glTF-Test-Assets-Interactivity (CC0 / public +// domain test asset). The directional light ships in the glb via +// KHR_lights_punctual; a studio HDR cube is loaded for PBR image-based lighting. + +import { + addToScene, + attachControl, + createArcRotateCamera, + createEngine, + createSceneContext, + loadGltf, + onBeforeRender, + registerScene, + runFlowGraphs, + setCameraLimits, + startEngine, +} from "babylon-lite"; +import { loadDdsEnvironment } from "babylon-lite/loader-env/load-dds-env"; +import { configureDemoDecoderBases, demoAssetUrl } from "./demo-asset-url.js"; +import { installFetchProgress } from "./loading-progress.js"; + +// Studio HDR cube used for image-based lighting only (no visible skybox — the +// calculator reads as a clean product shot on a neutral background). +const ENV_URL = "https://playground.babylonjs.com/textures/environment.dds"; + +// ArcRotate pose reconstructed from the glb's authored "Main Camera" (eye at +// roughly (-0.07, 1.59, 1.72) looking down on the calculator lying flat on the +// XZ plane, display at the far -Z edge, buttons toward +Z). Targets the body +// centre and looks down the authored ~44° tilt. +const CAM = { + alpha: 1.6115, + beta: 0.8736, + radius: 2.2443, + target: { x: 0, y: 0.15, z: 0 }, + fov: 1.0471975, // glb Main Camera yfov (60°) +}; + +// Gentle auto-rotation so the showcase slowly turns; paused while the user +// interacts and for a short grace period afterwards. +const AUTO_ROTATE_SPEED = 0.0025; // radians per frame +const IDLE_DELAY_MS = 2500; + +async function main(): Promise { + const __initStart = performance.now(); + const canvas = document.getElementById("renderCanvas") as HTMLCanvasElement; + const progress = installFetchProgress(canvas, { estimatedBytes: 1_900_000 }); + + const engine = await createEngine(canvas); + const scene = createSceneContext(engine); + + const cam = createArcRotateCamera(CAM.alpha, CAM.beta, CAM.radius, CAM.target); + cam.fov = CAM.fov; + scene.camera = cam; + attachControl(cam, canvas, scene); + setCameraLimits( + cam, + { + lowerRadiusLimit: CAM.radius * 0.45, + upperRadiusLimit: CAM.radius * 2.5, + }, + scene, + ); + + // The model is uncompressed, but configure the decoder bases anyway so the + // demo stays subpath-safe if the asset is ever re-exported compressed. + await configureDemoDecoderBases(import.meta.url); + + const [asset] = await Promise.all([ + loadGltf(engine, demoAssetUrl("./Calculator.glb", import.meta.url)), + loadDdsEnvironment(scene, ENV_URL, { + skipSkybox: true, + skipGround: true, + brdfUrl: demoAssetUrl("./brdf-lut.png", import.meta.url), + }), + ]); + addToScene(scene, asset); + + // Drive the embedded KHR_interactivity graph(s). The runtime starts on the + // first frame and runs the onStart chain → display resets to "00". + if (asset.flowGraphs?.length) { + await runFlowGraphs(scene, asset.flowGraphs, asset.animationGroups); + } + + // Slow continuous orbit, paused during/after user interaction so it never + // fights a manual orbit or zoom. + let lastInteractionMs = -Infinity; + const markInteraction = (): void => { + lastInteractionMs = performance.now(); + }; + canvas.addEventListener("pointerdown", markInteraction); + canvas.addEventListener("wheel", markInteraction, { passive: true }); + canvas.addEventListener("pointermove", (e) => { + if (e.buttons !== 0) { + markInteraction(); + } + }); + canvas.addEventListener("touchstart", markInteraction, { passive: true }); + canvas.addEventListener("touchmove", markInteraction, { passive: true }); + + let autoRotateEnabled = true; + const rotateBtn = document.getElementById("rotateToggle"); + if (rotateBtn) { + rotateBtn.addEventListener("click", () => { + autoRotateEnabled = !autoRotateEnabled; + rotateBtn.textContent = autoRotateEnabled ? "⏸ Auto-rotate" : "▶ Auto-rotate"; + rotateBtn.setAttribute("aria-pressed", String(autoRotateEnabled)); + }); + } + + onBeforeRender(scene, () => { + if (autoRotateEnabled && performance.now() - lastInteractionMs > IDLE_DELAY_MS) { + cam.alpha += AUTO_ROTATE_SPEED; + } + }); + + await registerScene(scene); + progress.done(); + await startEngine(engine); + + canvas.dataset.drawCalls = String(engine.drawCallCount); + canvas.dataset.camAlpha = String(cam.alpha); + canvas.dataset.camBeta = String(cam.beta); + canvas.dataset.camRadius = String(cam.radius); + canvas.dataset.camTarget = `${cam.target.x},${cam.target.y},${cam.target.z}`; + canvas.dataset.camFov = String(cam.fov); + canvas.dataset.initMs = String(performance.now() - __initStart); + canvas.dataset.ready = "true"; +} + +main().catch(console.error); diff --git a/lab/public/bundle/demos-manifest.json b/lab/public/bundle/demos-manifest.json index 32ba8c8cf2..3b8e5230d8 100644 --- a/lab/public/bundle/demos-manifest.json +++ b/lab/public/bundle/demos-manifest.json @@ -42,5 +42,9 @@ "platformer": { "rawKB": 82.2, "gzipKB": 29.1 + }, + "calculator": { + "rawKB": 131.6, + "gzipKB": 55.4 } } \ No newline at end of file diff --git a/lab/public/bundle/manifest.json b/lab/public/bundle/manifest.json index 6fa4fd7e64..a85349bf04 100644 --- a/lab/public/bundle/manifest.json +++ b/lab/public/bundle/manifest.json @@ -10,7 +10,7 @@ "scene1-generate-mipmaps-DfKYIHYG.js", "scene1-gltf-glb-parser-BL74Wcec.js", "scene1-ibl-fragment-CeRrvT96.js", - "scene1-pbr-renderable-BgW6YoeF.js", + "scene1-pbr-renderable-DpADIW0T.js", "scene1-rgbd-decode-JSHbS7uW.js", "scene1-scene-uniforms-FTYH6Ct0.js", "scene1-singlelight-hemispheric-wgsl-dhCCcXC9.js", @@ -57,7 +57,7 @@ ] }, "scene5": { - "rawKB": 90, + "rawKB": 90.1, "gzipKB": 38.1, "ignoredRawKB": 0, "runtimeChunks": [ @@ -66,16 +66,16 @@ "scene5-generate-mipmaps-DjmtEap1.js", "scene5-gltf-animation-qzi1HRZg.js", "scene5-gltf-color-normalize-BtnJk1yq.js", - "scene5-gltf-feature-animations-CQrRowr9.js", + "scene5-gltf-feature-animations-NJC85MBJ.js", "scene5-gltf-feature-morph-BNcyLEaQ.js", - "scene5-gltf-feature-registry-CFEhSLX7.js", + "scene5-gltf-feature-registry-DfOei33V.js", "scene5-gltf-feature-skeleton-Siixi_P0.js", "scene5-gltf-json-asset-DS0i23k1.js", - "scene5-morph-fragment-BhcJT1xs.js", - "scene5-pbr-renderable-205Mi-TV.js", + "scene5-morph-fragment-SUVkfdAM.js", + "scene5-pbr-renderable-DGLP44TZ.js", "scene5-pbr-template-ext-BoJ8SFLM.js", "scene5-singlelight-hemispheric-wgsl-DYn0thCO.js", - "scene5-skeleton-fragment-BNjKEYjD.js", + "scene5-skeleton-fragment-DOzbHYJb.js", "scene5.js" ] }, @@ -96,7 +96,7 @@ ] }, "scene7": { - "rawKB": 104.3, + "rawKB": 104.4, "gzipKB": 44.1, "ignoredRawKB": 0, "runtimeChunks": [ @@ -105,17 +105,17 @@ "scene7-create-skeleton-xdZ7V4Ou.js", "scene7-generate-mipmaps-BNxg8boc.js", "scene7-gltf-animation-B7Xr33me.js", - "scene7-gltf-feature-animations-CG5FpT6C.js", + "scene7-gltf-feature-animations-DNqmTY-c.js", "scene7-gltf-feature-extras-VHPteGay.js", - "scene7-gltf-feature-registry-bmuzy_T3.js", + "scene7-gltf-feature-registry-BnU2J8EP.js", "scene7-gltf-feature-skeleton-BCWZyzWC.js", "scene7-gltf-json-asset-CAp-SN2V.js", "scene7-ibl-fragment-CeRrvT96.js", - "scene7-pbr-renderable-Cs3YgpsU.js", + "scene7-pbr-renderable-CseWBxfZ.js", "scene7-rgbd-decode-C9OJGqTP.js", "scene7-scene-uniforms-FTYH6Ct0.js", "scene7-singlelight-hemispheric-wgsl-DbXoSTyY.js", - "scene7-skeleton-fragment-B2mbr3JT.js", + "scene7-skeleton-fragment-Cz2KW3d3.js", "scene7-skybox.vertex-Ban7q6IM-D1KSfOUq.js", "scene7-wgsl-helpers-CkUi9P9Q.js", "scene7.js" @@ -163,7 +163,7 @@ ] }, "scene11": { - "rawKB": 87.5, + "rawKB": 87.6, "gzipKB": 36.8, "ignoredRawKB": 0, "runtimeChunks": [ @@ -171,28 +171,28 @@ "scene11-generate-mipmaps-YGxDoUB2.js", "scene11-gltf-animation-CQ75q6KC.js", "scene11-gltf-ext-spec-gloss-D60sFZYX.js", - "scene11-gltf-feature-animations-Bmg0PBR4.js", + "scene11-gltf-feature-animations-CmHJNYiV.js", "scene11-gltf-feature-extras-VHPteGay.js", - "scene11-gltf-feature-registry-1IvbX-2Q.js", + "scene11-gltf-feature-registry-a7ll1ICi.js", "scene11-gltf-feature-skeleton-CrXfHDG6.js", "scene11-gltf-glb-parser-B0CzN8nR.js", "scene11-gltf-sampler-desc-DP9TFn1W.js", - "scene11-pbr-renderable-CGkvaNwT.js", + "scene11-pbr-renderable-eg_x6Za2.js", "scene11-singlelight-hemispheric-wgsl-C40tQGmo.js", - "scene11-skeleton-fragment-CNcDYl5q.js", + "scene11-skeleton-fragment-CzpG9JGH.js", "scene11.js" ] }, "scene12": { - "rawKB": 101.9, + "rawKB": 102, "gzipKB": 41.6, "ignoredRawKB": 0, "runtimeChunks": [ "scene12-create-skeleton-Cjf80v3R.js", "scene12-generate-mipmaps-Bsp4UQ3D.js", "scene12-gltf-animation-BDKbEYQd.js", - "scene12-gltf-feature-animations-Bfc5iW7L.js", - "scene12-gltf-feature-registry-BydKPGWF.js", + "scene12-gltf-feature-animations-BFGZaHvm.js", + "scene12-gltf-feature-registry-D4uNB6sD.js", "scene12-gltf-feature-skeleton-CN1CorcX.js", "scene12-gltf-glb-parser-BrsOlymf.js", "scene12-ibl-fragment-CeRrvT96.js", @@ -206,7 +206,7 @@ ] }, "scene13": { - "rawKB": 83.9, + "rawKB": 84, "gzipKB": 34.6, "ignoredRawKB": 0, "runtimeChunks": [ @@ -214,7 +214,7 @@ "scene13-generate-mipmaps-BYu5QYgJ.js", "scene13-gltf-glb-parser-HP2v-3hE.js", "scene13-ibl-fragment-CeRrvT96.js", - "scene13-pbr-renderable-Jx_XgvTU.js", + "scene13-pbr-renderable-DIkUPn0q.js", "scene13-rgbd-decode-CmtmNnLb.js", "scene13-scene-uniforms-FTYH6Ct0.js", "scene13-singlelight-hemispheric-wgsl-D1uhnT_s.js", @@ -224,7 +224,7 @@ }, "scene14": { "rawKB": 88.7, - "gzipKB": 36.9, + "gzipKB": 37, "ignoredRawKB": 0, "runtimeChunks": [ "scene14-background-dds-skybox-D8l6lgM7.js", @@ -233,7 +233,7 @@ "scene14-generate-mipmaps-Drlt5FNB.js", "scene14-gltf-glb-parser-B--vN_CU.js", "scene14-ibl-fragment-CeRrvT96.js", - "scene14-pbr-renderable-BPFgC4Sv.js", + "scene14-pbr-renderable-Btml529S.js", "scene14-rgbd-decode-BdaP5z1T.js", "scene14-scene-uniforms-FTYH6Ct0.js", "scene14-singlelight-hemispheric-wgsl-Q-eHTfjt.js", @@ -411,7 +411,7 @@ ] }, "scene26": { - "rawKB": 89.4, + "rawKB": 89.5, "gzipKB": 36.4, "ignoredRawKB": 0, "runtimeChunks": [ @@ -429,18 +429,18 @@ ] }, "scene27": { - "rawKB": 81.5, + "rawKB": 81.6, "gzipKB": 33.3, "ignoredRawKB": 0, "runtimeChunks": [ "scene27-generate-mipmaps-bqbQDZJl.js", "scene27-gltf-ext-dielectric-C6hvXXiF.js", - "scene27-gltf-feature-registry-CdtcVg_o.js", + "scene27-gltf-feature-registry-B5rFsSXl.js", "scene27-gltf-feature-variants-D5VxGN7_.js", "scene27-gltf-glb-parser-By8noE3G.js", "scene27-gltf-pbr-builder-ext-BvS-rcCP.js", "scene27-gltf-variants-CqSlvDBE.js", - "scene27-pbr-renderable-DP1MITnP.js", + "scene27-pbr-renderable-CYcDjssQ.js", "scene27-reflectance-fragment-CvRmBvQ0.js", "scene27-singlelight-hemispheric-wgsl-B8SoT1MB.js", "scene27-texture-2d-DQo3n8D6.js", @@ -448,35 +448,35 @@ ] }, "scene28": { - "rawKB": 85.3, + "rawKB": 85.4, "gzipKB": 34.8, "ignoredRawKB": 0, "runtimeChunks": [ "scene28-clearcoat-fragment-AiyWBFX1.js", "scene28-generate-mipmaps-7w1hCgCw.js", "scene28-gltf-ext-clearcoat-BgaLgSgD.js", - "scene28-gltf-feature-registry-Bn70BaDI.js", + "scene28-gltf-feature-registry-LeObX4lm.js", "scene28-gltf-json-asset-DNaJyx0P.js", "scene28-ibl-fragment-CeRrvT96.js", - "scene28-pbr-renderable-BYSlPUr5.js", + "scene28-pbr-renderable-eX7eFIi2.js", "scene28-rgbd-decode-wUvHpHPX.js", "scene28-scene-uniforms-FTYH6Ct0.js", "scene28.js" ] }, "scene29": { - "rawKB": 88.9, + "rawKB": 89, "gzipKB": 36.7, "ignoredRawKB": 0, "runtimeChunks": [ "scene29-generate-mipmaps-DJCYSY1_.js", "scene29-gltf-ext-sheen-Dv23b_3T.js", "scene29-gltf-ext-uv-transform-tUSEOqA7.js", - "scene29-gltf-feature-registry-CfSJd3IN.js", + "scene29-gltf-feature-registry-D_dLqX6E.js", "scene29-gltf-json-asset-EOtWL8g4.js", "scene29-gltf-pbr-builder-ext-CiV9PuhY.js", "scene29-ibl-fragment-CeRrvT96.js", - "scene29-pbr-renderable-CYFwIG7G.js", + "scene29-pbr-renderable-CcNfOY06.js", "scene29-pbr-template-ext-BoJ8SFLM.js", "scene29-rgbd-decode-DZaCQfRj.js", "scene29-scene-uniforms-FTYH6Ct0.js", @@ -487,7 +487,7 @@ ] }, "scene30": { - "rawKB": 102.1, + "rawKB": 102.2, "gzipKB": 42.1, "ignoredRawKB": 0, "runtimeChunks": [ @@ -496,14 +496,14 @@ "scene30-gltf-ext-dielectric-C6hvXXiF.js", "scene30-gltf-ext-uv-transform-DQN5DAiE.js", "scene30-gltf-feature-draco-BSbw1ZBU.js", - "scene30-gltf-feature-registry-BaxqZoGN.js", + "scene30-gltf-feature-registry-DOa8b0AB.js", "scene30-gltf-glb-parser-DJ0cTJ-4.js", "scene30-gltf-pbr-builder-ext-pNz5pvxP.js", "scene30-ibl-fragment-CeRrvT96.js", - "scene30-pbr-refraction-BR-OcPmw.js", - "scene30-pbr-renderable-CoSAyXaL.js", + "scene30-pbr-refraction-BhGoeTX9.js", + "scene30-pbr-renderable-7H5c0RKe.js", "scene30-pbr-template-ext-BoJ8SFLM.js", - "scene30-pbr-transmission-ext-CGs28NrW.js", + "scene30-pbr-transmission-ext-D_j4aSe7.js", "scene30-rgbd-decode-RQYTGqhL.js", "scene30-scene-uniforms-FTYH6Ct0.js", "scene30-texture-2d-DQo3n8D6.js", @@ -512,33 +512,33 @@ ] }, "scene31": { - "rawKB": 79, + "rawKB": 79.1, "gzipKB": 32.4, "ignoredRawKB": 0, "runtimeChunks": [ "scene31-emissive-fragment-C1TNSo0w.js", "scene31-generate-mipmaps-CoCbB-UD.js", "scene31-gltf-ext-emissive-strength-CButLZnK.js", - "scene31-gltf-feature-registry-D2YBPfA0.js", + "scene31-gltf-feature-registry-BVpcVQGo.js", "scene31-gltf-glb-parser-CzRZVo57.js", "scene31-ibl-fragment-CeRrvT96.js", - "scene31-pbr-renderable-Bg8O1LwN.js", + "scene31-pbr-renderable-DBEVLcPa.js", "scene31-rgbd-decode-C5kd6U3R.js", "scene31-scene-uniforms-FTYH6Ct0.js", "scene31.js" ] }, "scene32": { - "rawKB": 78.8, - "gzipKB": 32.4, + "rawKB": 78.9, + "gzipKB": 32.5, "ignoredRawKB": 0, "runtimeChunks": [ "scene32-generate-mipmaps-DdnhjgK3.js", "scene32-gltf-ext-unlit-1vUYfF7v.js", - "scene32-gltf-feature-registry-BBrnfLsg.js", + "scene32-gltf-feature-registry-C65sQZbK.js", "scene32-gltf-glb-parser-BX2AS-bF.js", "scene32-ibl-fragment-CeRrvT96.js", - "scene32-pbr-renderable-CUtjMSAl.js", + "scene32-pbr-renderable-UXm0NIAX.js", "scene32-rgbd-decode-3FngBUdB.js", "scene32-scene-uniforms-FTYH6Ct0.js", "scene32-unlit-fragment-DYNWudHP.js", @@ -546,23 +546,23 @@ ] }, "scene33": { - "rawKB": 102.2, + "rawKB": 102.3, "gzipKB": 41.9, "ignoredRawKB": 0, "runtimeChunks": [ "scene33-generate-mipmaps-Z_0Gnbub.js", "scene33-gltf-ext-dielectric-C6hvXXiF.js", "scene33-gltf-feature-lights-punctual-B3W8Lfux.js", - "scene33-gltf-feature-registry-CiNspmto.js", + "scene33-gltf-feature-registry-CuyZ5EuV.js", "scene33-gltf-glb-parser-DMBUHtjv.js", "scene33-gltf-light-pointer-state-B7kFTRxK.js", "scene33-gltf-sampler-desc-ChMUJ3dG.js", "scene33-ibl-fragment-CeRrvT96.js", "scene33-light-base-SJy-sLMD.js", "scene33-multilight-wgsl-D3rF1wIQ.js", - "scene33-pbr-refraction-D471wMEc.js", - "scene33-pbr-renderable-DJawGTOL.js", - "scene33-pbr-transmission-ext-BYr3YXsr.js", + "scene33-pbr-refraction-DRBX9DRu.js", + "scene33-pbr-renderable-kYGvDAQa.js", + "scene33-pbr-transmission-ext-DNvne2d4.js", "scene33-point-light-CcVWqAnQ.js", "scene33-rgbd-decode-3bEbw5la.js", "scene33-scene-uniforms-FTYH6Ct0.js", @@ -570,19 +570,20 @@ ] }, "scene34": { - "rawKB": 95.6, - "gzipKB": 39.3, + "rawKB": 95.7, + "gzipKB": 39.5, "ignoredRawKB": 0, "runtimeChunks": [ + "scene34-animation-pointer-Ddy-YJnh.js", "scene34-generate-mipmaps-C244DQVV.js", "scene34-gltf-animation-Cz7h58Nc.js", "scene34-gltf-ext-node-visibility-CyR_MQR5.js", - "scene34-gltf-feature-animation-pointer-dMoZNhJb.js", - "scene34-gltf-feature-animations-BQSrKgM_.js", - "scene34-gltf-feature-registry-DW8yU7HD.js", + "scene34-gltf-feature-animation-pointer-rAfd1hLG.js", + "scene34-gltf-feature-animations-Bl_MxMv-.js", + "scene34-gltf-feature-registry-C_-XttLH.js", "scene34-gltf-glb-parser-BxoFTGKn.js", "scene34-ibl-fragment-CeRrvT96.js", - "scene34-pbr-renderable-BelxTmu1.js", + "scene34-pbr-renderable-BmEHhHi_.js", "scene34-rgbd-decode-ByPAsdES.js", "scene34-scene-uniforms-FTYH6Ct0.js", "scene34-visibility-fb8svtso.js", @@ -590,16 +591,16 @@ ] }, "scene35": { - "rawKB": 82.1, + "rawKB": 82.2, "gzipKB": 33.8, "ignoredRawKB": 0, "runtimeChunks": [ "scene35-generate-mipmaps-Dw2dswCB.js", "scene35-gltf-feature-gpu-instancing-RMojEpZv.js", - "scene35-gltf-feature-registry-DD8cSLcV.js", + "scene35-gltf-feature-registry-f-legoXu.js", "scene35-gltf-glb-parser-BJyme4Vk.js", "scene35-ibl-fragment-CeRrvT96.js", - "scene35-pbr-renderable-B_cgTnJN.js", + "scene35-pbr-renderable-CM5qYb7h.js", "scene35-rgbd-decode-U56TFVGU.js", "scene35-scene-uniforms-FTYH6Ct0.js", "scene35-thin-instance-fragment-CBn2miAC.js", @@ -619,7 +620,7 @@ ] }, "scene37": { - "rawKB": 95.8, + "rawKB": 95.9, "gzipKB": 39.2, "ignoredRawKB": 0, "runtimeChunks": [ @@ -628,11 +629,11 @@ "scene37-gltf-ext-dielectric-C6hvXXiF.js", "scene37-gltf-ext-sheen-Dv23b_3T.js", "scene37-gltf-ext-uv-transform-hVTzf5Q9.js", - "scene37-gltf-feature-registry-Wt0DPKNv.js", + "scene37-gltf-feature-registry-fIkQ1EVx.js", "scene37-gltf-glb-parser-CkbGcOCI.js", "scene37-gltf-pbr-builder-ext-BweVn8Hl.js", "scene37-ibl-fragment-CeRrvT96.js", - "scene37-pbr-renderable-DKwsq1aF.js", + "scene37-pbr-renderable-CUE3gxbO.js", "scene37-pbr-template-ext-BoJ8SFLM.js", "scene37-reflectance-fragment-QHrzuq3H.js", "scene37-rgbd-decode-D3VLnI-E.js", @@ -654,20 +655,21 @@ ] }, "scene39": { - "rawKB": 107.7, - "gzipKB": 45.6, + "rawKB": 107.8, + "gzipKB": 45.8, "ignoredRawKB": 0, "runtimeChunks": [ "scene39-alpha-test-fragment-C7zCAOOO.js", - "scene39-animation-pointer-lights-CZ0oCcbR.js", + "scene39-animation-pointer-BF6gVQTl.js", + "scene39-animation-pointer-lights-CYbYE0zn.js", "scene39-emissive-fragment-q97NxqgU.js", "scene39-generate-mipmaps-DHGFn5-s.js", "scene39-gltf-animation-h1jXkBbl.js", "scene39-gltf-ext-uv-transform-DhfZ3GtK.js", - "scene39-gltf-feature-animation-pointer-BEWWFwP-.js", - "scene39-gltf-feature-animations-BqoIrLnC.js", + "scene39-gltf-feature-animation-pointer-BOWPlTMd.js", + "scene39-gltf-feature-animations-BdsovlyG.js", "scene39-gltf-feature-lights-punctual-CM09I-Ul.js", - "scene39-gltf-feature-registry-CfVnSAqA.js", + "scene39-gltf-feature-registry-B8ig8ll3.js", "scene39-gltf-json-asset-CnK_Yal2.js", "scene39-gltf-light-pointer-state-B7kFTRxK.js", "scene39-gltf-pbr-builder-ext-BVnHLdY7.js", @@ -675,7 +677,7 @@ "scene39-ibl-fragment-CeRrvT96.js", "scene39-light-base-kuMtkINt.js", "scene39-light-matrix-CBTBdGsb.js", - "scene39-pbr-renderable-DqM2bVmy.js", + "scene39-pbr-renderable-B3XArPet.js", "scene39-pbr-template-ext-BoJ8SFLM.js", "scene39-rgbd-decode-YUi8rAlU.js", "scene39-scene-uniforms-FTYH6Ct0.js", @@ -696,12 +698,12 @@ "scene99-generate-mipmaps-DGydlezW.js", "scene99-gltf-animation-CLRicPbx.js", "scene99-gltf-feature-animations-BknhhNc3.js", - "scene99-gltf-feature-registry-C5SZcHEj.js", + "scene99-gltf-feature-registry-B3LcBATD.js", "scene99-gltf-feature-skeleton-DAg3sIKL.js", "scene99-gltf-glb-parser-BtneRupN.js", "scene99-multilight-wgsl-PWSzw1Ef.js", - "scene99-pbr-renderable-BE9CqTHC.js", - "scene99-skeleton-fragment-CaEr1tK3.js", + "scene99-pbr-renderable-CGYUNdsR.js", + "scene99-skeleton-fragment-BqKLod3Y.js", "scene99-types-DuyfkTc1.js", "scene99.js" ] @@ -717,13 +719,13 @@ ] }, "scene41": { - "rawKB": 91.8, - "gzipKB": 36.5, + "rawKB": 92, + "gzipKB": 36.6, "ignoredRawKB": 0, "runtimeChunks": [ "scene41-generate-mipmaps-BWxJCIcc.js", "scene41-gltf-ext-spec-gloss-D60sFZYX.js", - "scene41-gltf-feature-registry-CnFSdj0i.js", + "scene41-gltf-feature-registry-b3wRMyaT.js", "scene41-gltf-glb-parser-CkOP9avo.js", "scene41-gltf-sampler-desc-E_7RaQlV.js", "scene41-point-light-CnkkP9oB.js", @@ -786,13 +788,13 @@ ] }, "scene47": { - "rawKB": 91.1, - "gzipKB": 36.4, + "rawKB": 91.2, + "gzipKB": 36.5, "ignoredRawKB": 0, "runtimeChunks": [ "scene47-generate-mipmaps-CmIGpFd5.js", "scene47-gltf-ext-spec-gloss-D60sFZYX.js", - "scene47-gltf-feature-registry-CaVfgPXw.js", + "scene47-gltf-feature-registry-HOC_06Wd.js", "scene47-gltf-glb-parser-6-tsF2C2.js", "scene47-gltf-sampler-desc-BJMmnJDF.js", "scene47-shader-composer-CgJmQyJj.js", @@ -1210,7 +1212,7 @@ ] }, "scene73": { - "rawKB": 142.6, + "rawKB": 142.7, "gzipKB": 57.6, "ignoredRawKB": 3.3, "runtimeChunks": [ @@ -1221,14 +1223,14 @@ "scene73-fragment-output-CfRmfbPv.js", "scene73-generate-mipmaps-DcnO72FN.js", "scene73-gltf-ext-clearcoat-BgaLgSgD.js", - "scene73-gltf-feature-registry-Bv6CUQMQ.js", + "scene73-gltf-feature-registry-DwxnV9OK.js", "scene73-gltf-glb-parser-Bm1IrZjT.js", "scene73-ibl-fragment-CeRrvT96.js", "scene73-input-block-lrbZBZJO.js", "scene73-node-env-2j3ozSNZ.js", "scene73-node-renderable-Ik7zhWQu.js", "scene73-pbr-metallic-roughness-block-full-CgKHgEzu.js", - "scene73-pbr-renderable-Ccb3OJuh.js", + "scene73-pbr-renderable-C72iDITq.js", "scene73-perturb-normal-Bqx-TYaB.js", "scene73-reflection-block-CIXuH-z0.js", "scene73-rgbd-decode-Bpa-sBKE.js", @@ -1736,12 +1738,12 @@ ] }, "scene104": { - "rawKB": 100.6, + "rawKB": 100.7, "gzipKB": 39, "ignoredRawKB": 0, "runtimeChunks": [ "scene104-generate-mipmaps-BGv6wRC-.js", - "scene104-gltf-feature-registry-r97kCXhW.js", + "scene104-gltf-feature-registry-4t7-jinb.js", "scene104-gltf-glb-parser-BJfNq64D.js", "scene104-shader-composer-CgJmQyJj.js", "scene104-standard-renderable-BYZDGzK-.js", @@ -1751,12 +1753,12 @@ ] }, "scene105": { - "rawKB": 101.3, + "rawKB": 101.4, "gzipKB": 39.2, "ignoredRawKB": 0, "runtimeChunks": [ "scene105-generate-mipmaps-Bw7xZIhD.js", - "scene105-gltf-feature-registry-DSOt3NOM.js", + "scene105-gltf-feature-registry-Djs0y_aK.js", "scene105-gltf-glb-parser-BsWC1iTB.js", "scene105-shader-composer-CgJmQyJj.js", "scene105-standard-renderable-wWJPvbnz.js", @@ -1819,7 +1821,7 @@ ] }, "scene112": { - "rawKB": 118.9, + "rawKB": 119, "gzipKB": 48.2, "ignoredRawKB": 0, "runtimeChunks": [ @@ -1829,12 +1831,12 @@ "scene112-generate-mipmaps-r2o5vX6L.js", "scene112-gltf-ext-basisu-CRSWpXls.js", "scene112-gltf-ext-dielectric-C6hvXXiF.js", - "scene112-gltf-feature-registry-Cpt2BtfK.js", + "scene112-gltf-feature-registry-rj3PvtiF.js", "scene112-gltf-json-asset-CBZ_Aalp.js", "scene112-ibl-fragment-CeRrvT96.js", - "scene112-pbr-refraction-DZYuYfJR.js", - "scene112-pbr-renderable-CU6jvotk.js", - "scene112-pbr-transmission-ext-C5pDjKKB.js", + "scene112-pbr-refraction-gMVc2VQg.js", + "scene112-pbr-renderable-BWN4drN-.js", + "scene112-pbr-transmission-ext-CxPp7oKx.js", "scene112-rgbd-decode-CCG7-bTA.js", "scene112-scene-uniforms-FTYH6Ct0.js", "scene112-singlelight-hemispheric-wgsl-CKrwpx3D.js", @@ -1867,7 +1869,7 @@ ] }, "scene115": { - "rawKB": 124.5, + "rawKB": 124.6, "gzipKB": 51.2, "ignoredRawKB": 0, "runtimeChunks": [ @@ -1876,14 +1878,14 @@ "scene115-generate-mipmaps-3gj5PaV4.js", "scene115-gltf-animation-u6_6VYrC.js", "scene115-gltf-color-normalize-BtnJk1yq.js", - "scene115-gltf-feature-animations-eusiMJnc.js", + "scene115-gltf-feature-animations-Do6kXVEc.js", "scene115-gltf-feature-morph-DLWJRlR1.js", - "scene115-gltf-feature-registry-BlikC7-I.js", + "scene115-gltf-feature-registry-DxZaaSaT.js", "scene115-gltf-feature-skeleton-8Oys0O0p.js", "scene115-gltf-json-asset-D9q5e9Ta.js", "scene115-morph-fragment-DQefd8aW.js", "scene115-morph-fragment-core-BW8kE7zi.js", - "scene115-pbr-renderable-9KLrN-yl.js", + "scene115-pbr-renderable-CN_CDZ1B.js", "scene115-pbr-template-ext-BoJ8SFLM.js", "scene115-shader-composer-CgJmQyJj.js", "scene115-singlelight-hemispheric-wgsl-D4qRhfo5.js", @@ -2099,7 +2101,7 @@ ] }, "scene144": { - "rawKB": 109, + "rawKB": 109.1, "gzipKB": 44.1, "ignoredRawKB": 0, "runtimeChunks": [ @@ -2107,17 +2109,17 @@ "scene144-generate-mipmaps-DW-CgxSa.js", "scene144-gltf-animation-CAQsjrcS.js", "scene144-gltf-ext-spec-gloss-D60sFZYX.js", - "scene144-gltf-feature-animations-DOSVTx1Z.js", + "scene144-gltf-feature-animations-Bud5JgI9.js", "scene144-gltf-feature-extras-VHPteGay.js", - "scene144-gltf-feature-registry-BPA8xk3Y.js", + "scene144-gltf-feature-registry-uUxlsXJ7.js", "scene144-gltf-feature-skeleton-Dq3GN8sP.js", "scene144-gltf-glb-parser-CXf6hcyj.js", "scene144-gltf-pbr-builder-ext-pRXV-A0d.js", "scene144-ibl-fragment-CeRrvT96.js", - "scene144-pbr-renderable-CJnGSI74.js", + "scene144-pbr-renderable-DP_xpfDU.js", "scene144-rgbd-decode-DNSQ_egC.js", "scene144-scene-uniforms-FTYH6Ct0.js", - "scene144-skeleton-fragment-Boc4qWJv.js", + "scene144-skeleton-fragment-BEG1WT5V.js", "scene144-texture-2d-DQo3n8D6.js", "scene144.js" ] @@ -2173,14 +2175,14 @@ ] }, "scene147": { - "rawKB": 102.6, - "gzipKB": 41, + "rawKB": 102.7, + "gzipKB": 41.1, "ignoredRawKB": 0, "runtimeChunks": [ "scene147-directional-light-CwjabfgT.js", "scene147-generate-mipmaps-BfjMA7hX.js", "scene147-gltf-feature-lights-punctual-DdmW_Vp5.js", - "scene147-gltf-feature-registry-CRHPISh7.js", + "scene147-gltf-feature-registry-D4h0oKKP.js", "scene147-gltf-glb-parser-BPCGAatE.js", "scene147-gltf-light-pointer-state-B7kFTRxK.js", "scene147-multilight-wgsl-CLgjeQ_G.js", @@ -2192,14 +2194,14 @@ ] }, "scene148": { - "rawKB": 108.5, + "rawKB": 108.6, "gzipKB": 42.6, "ignoredRawKB": 0, "runtimeChunks": [ "scene148-directional-light-DQDzxnQb.js", "scene148-generate-mipmaps-LgSJe-aH.js", "scene148-gltf-feature-lights-punctual-BLCC9HwV.js", - "scene148-gltf-feature-registry-Deb8rfIt.js", + "scene148-gltf-feature-registry-Bj2x7Jh0.js", "scene148-gltf-glb-parser-B33Edg7S.js", "scene148-gltf-light-pointer-state-B7kFTRxK.js", "scene148-pbr-geometry-view-CjV4zRl7.js", @@ -2211,8 +2213,8 @@ ] }, "scene149": { - "rawKB": 107.9, - "gzipKB": 44.1, + "rawKB": 108, + "gzipKB": 44.2, "ignoredRawKB": 6.8, "runtimeChunks": [ "scene149-_math-factory-DFVyS8gB.js", @@ -2221,7 +2223,7 @@ "scene149-generate-mipmaps-D6ALG8gN.js", "scene149-geometry-texture-output-BfeQftmX.js", "scene149-gltf-feature-lights-punctual-w2wC-7RK.js", - "scene149-gltf-feature-registry-D5PDjQhh.js", + "scene149-gltf-feature-registry-Be-9VsBW.js", "scene149-gltf-glb-parser-Cwf9MN9N.js", "scene149-gltf-light-pointer-state-B7kFTRxK.js", "scene149-input-block-DAEnTkZz.js", @@ -2256,7 +2258,7 @@ ] }, "scene152": { - "rawKB": 92.6, + "rawKB": 92.7, "gzipKB": 38.5, "ignoredRawKB": 0, "runtimeChunks": [ @@ -2264,15 +2266,15 @@ "scene152-generate-mipmaps-COVZomJQ.js", "scene152-gltf-animation-Cam0al-J.js", "scene152-gltf-ext-spec-gloss-D60sFZYX.js", - "scene152-gltf-feature-animations-z0zN7iq2.js", + "scene152-gltf-feature-animations-DtfqqyNl.js", "scene152-gltf-feature-extras-VHPteGay.js", - "scene152-gltf-feature-registry-Kyc3p6n8.js", + "scene152-gltf-feature-registry-CTdXDF3L.js", "scene152-gltf-feature-skeleton-DdNk7nTu.js", "scene152-gltf-glb-parser-Dr_B2hXm.js", "scene152-gltf-sampler-desc-D0Q2VM93.js", - "scene152-pbr-renderable-D_0M3XBI.js", + "scene152-pbr-renderable-B-fzwrDm.js", "scene152-singlelight-hemispheric-wgsl-DT7gfcww.js", - "scene152-skeleton-fragment-DtrinbOb.js", + "scene152-skeleton-fragment-KWg36pou.js", "scene152.js" ] }, @@ -2322,31 +2324,31 @@ "scene157-create-skeleton-CkTxS2Cs.js", "scene157-generate-mipmaps-CSgsFZsy.js", "scene157-gltf-animation-97jnT3rB.js", - "scene157-gltf-feature-animations-C4GEf0C-.js", - "scene157-gltf-feature-registry-D-CUXeBM.js", + "scene157-gltf-feature-animations-3TPbmuLQ.js", + "scene157-gltf-feature-registry-MqLl8-FA.js", "scene157-gltf-feature-skeleton-sqEIM232.js", "scene157-gltf-glb-parser-DZ4Xgxsa.js", "scene157-multilight-wgsl-DMQZ6dS_.js", - "scene157-pbr-renderable-DBjQB5n8.js", - "scene157-skeleton-fragment-CSyOaqLV.js", + "scene157-pbr-renderable-B6Hjf0IX.js", + "scene157-skeleton-fragment-DfhFipOY.js", "scene157.js" ] }, "scene158": { - "rawKB": 94.5, + "rawKB": 94.6, "gzipKB": 38.4, "ignoredRawKB": 0, "runtimeChunks": [ "scene158-create-skeleton-B0S37Ruf.js", "scene158-generate-mipmaps-ONVnFwM1.js", "scene158-gltf-animation-D36FWf_9.js", - "scene158-gltf-feature-animations-_BY8aVDC.js", - "scene158-gltf-feature-registry-CHl2pVu-.js", + "scene158-gltf-feature-animations-DfL_qlI0.js", + "scene158-gltf-feature-registry-DHqG8Tw1.js", "scene158-gltf-feature-skeleton-BQO9mRtS.js", "scene158-gltf-glb-parser-BhqojsL9.js", "scene158-multilight-wgsl-DhVJV_xa.js", - "scene158-pbr-renderable-DudI2994.js", - "scene158-skeleton-fragment-BEyhBIMl.js", + "scene158-pbr-renderable-BOKf235n.js", + "scene158-skeleton-fragment-DxHsHGzx.js", "scene158.js" ] }, @@ -2396,7 +2398,7 @@ ] }, "scene164": { - "rawKB": 96.9, + "rawKB": 97, "gzipKB": 40.1, "ignoredRawKB": 0, "runtimeChunks": [ @@ -2405,16 +2407,16 @@ "scene164-generate-mipmaps-BxA9-hzJ.js", "scene164-gltf-animation-Lkey7rHQ.js", "scene164-gltf-color-normalize-BtnJk1yq.js", - "scene164-gltf-feature-animations-CkuodxMl.js", + "scene164-gltf-feature-animations-DDqOp9Eq.js", "scene164-gltf-feature-morph-Dcma2yce.js", - "scene164-gltf-feature-registry-B8153K1-.js", + "scene164-gltf-feature-registry-ButWyLn5.js", "scene164-gltf-feature-skeleton-DOpy65Ao.js", "scene164-gltf-json-asset-BCxbuHbB.js", - "scene164-morph-fragment-29qCql2q.js", - "scene164-pbr-renderable-CtmbMPPp.js", + "scene164-morph-fragment-BrDXuWr6.js", + "scene164-pbr-renderable-XUsRNJ8A.js", "scene164-pbr-template-ext-BoJ8SFLM.js", "scene164-singlelight-hemispheric-wgsl-L03Bnk5I.js", - "scene164-skeleton-fragment-BNgGN4I9.js", + "scene164-skeleton-fragment-DC_UaM8a.js", "scene164.js" ] }, @@ -2447,7 +2449,7 @@ "runtimeChunks": [ "scene171-generate-mipmaps-DXPozdXM.js", "scene171-gltf-glb-parser-COBAbifQ.js", - "scene171-pbr-renderable-N1i0xSLG.js", + "scene171-pbr-renderable-8UCWJclt.js", "scene171-recast-navigation-CYBQI-zY-Dry_z7x_.js", "scene171-shader-composer-CgJmQyJj.js", "scene171-singlelight-hemispheric-wgsl-CeuKqi92.js", @@ -2479,13 +2481,13 @@ ] }, "scene174": { - "rawKB": 96.4, + "rawKB": 96.5, "gzipKB": 39.4, "ignoredRawKB": 1485.2, "runtimeChunks": [ "scene174-generate-mipmaps-oyz7Zcb3.js", "scene174-gltf-glb-parser-V10AO6Mf.js", - "scene174-pbr-renderable-khVBcoiw.js", + "scene174-pbr-renderable-CrwMaE3h.js", "scene174-recast-navigation-CYBQI-zY-Dry_z7x_.js", "scene174-shader-composer-CgJmQyJj.js", "scene174-singlelight-hemispheric-wgsl-STW_78f5.js", @@ -2501,7 +2503,7 @@ "runtimeChunks": [ "scene175-generate-mipmaps-B-d91NI_.js", "scene175-gltf-glb-parser-BmlEZ507.js", - "scene175-pbr-renderable-_CMUP1sf.js", + "scene175-pbr-renderable-WNTvF3-Y.js", "scene175-recast-navigation-CYBQI-zY-Dry_z7x_.js", "scene175-shader-composer-CgJmQyJj.js", "scene175-singlelight-hemispheric-wgsl-D-nS_1oq.js", @@ -2511,14 +2513,14 @@ ] }, "scene176": { - "rawKB": 102.3, + "rawKB": 102.4, "gzipKB": 41.1, "ignoredRawKB": 0, "runtimeChunks": [ "scene176-generate-mipmaps-Dse5kXpK.js", "scene176-gltf-ext-dielectric-C6hvXXiF.js", "scene176-gltf-feature-extras-VHPteGay.js", - "scene176-gltf-feature-registry-DGFPEC4N.js", + "scene176-gltf-feature-registry-7Bthuiau.js", "scene176-gltf-json-asset-CVMNQwdq.js", "scene176-ibl-fragment-CeRrvT96.js", "scene176-ibl-skybox-wgsl-CFIBOHHx.js", @@ -2546,13 +2548,13 @@ ] }, "scene178": { - "rawKB": 86.7, + "rawKB": 86.8, "gzipKB": 35.3, "ignoredRawKB": 0, "runtimeChunks": [ "scene178-generate-mipmaps-MoaS2Mzr.js", "scene178-gltf-ext-iridescence-b4LVniAM.js", - "scene178-gltf-feature-registry-BTzN8aW4.js", + "scene178-gltf-feature-registry-6UwuYjyI.js", "scene178-gltf-glb-parser-d5msxooD.js", "scene178-ibl-fragment-CeRrvT96.js", "scene178-ibl-skybox-wgsl-CFIBOHHx.js", @@ -2565,13 +2567,13 @@ }, "scene179": { "rawKB": 72.3, - "gzipKB": 29.5, + "gzipKB": 29.6, "ignoredRawKB": 0, "runtimeChunks": [ "scene179-alpha-test-fragment-BaX_J1Vy.js", "scene179-generate-mipmaps-BxrZVeh_.js", "scene179-gltf-json-asset-8LBNnI1r.js", - "scene179-pbr-renderable-DiBBath3.js", + "scene179-pbr-renderable-BiGfT8Np.js", "scene179.js" ] }, @@ -2719,23 +2721,23 @@ ] }, "scene210": { - "rawKB": 75.4, + "rawKB": 75.5, "gzipKB": 31.3, "ignoredRawKB": 0, "runtimeChunks": [ "scene210-generate-mipmaps-BhUML67h.js", - "scene210-gltf-feature-registry-COHuk6hF.js", + "scene210-gltf-feature-registry-UUWEG4w5.js", "scene210-gltf-feature-xmp-S8Tl-XNQ.js", "scene210-gltf-glb-parser-fJOQf1ny.js", "scene210-gltf-interleave-fvR_nHHs.js", "scene210-gltf-normals-DYhLwXNX.js", - "scene210-pbr-renderable-D5dHBfLG.js", + "scene210-pbr-renderable-EUUhAPi0.js", "scene210-singlelight-hemispheric-wgsl-CDyba_3g.js", "scene210.js" ] }, "scene211": { - "rawKB": 88.3, + "rawKB": 88.4, "gzipKB": 37.1, "ignoredRawKB": 0, "runtimeChunks": [ @@ -2743,26 +2745,26 @@ "scene211-generate-mipmaps-CJi4kk1V.js", "scene211-gltf-animation-D93nwuGW.js", "scene211-gltf-ext-quantization-Do-ofjB-.js", - "scene211-gltf-feature-animations-C0rPaLwN.js", + "scene211-gltf-feature-animations-CTZOLIzx.js", "scene211-gltf-feature-meshopt-Dt65KW5n.js", - "scene211-gltf-feature-registry-BZvhWur-.js", + "scene211-gltf-feature-registry-BoQfoj_n.js", "scene211-gltf-feature-skeleton-C5YXdC68.js", "scene211-gltf-json-asset-_1B5yK0t.js", "scene211-gltf-multi-buffer-Cwm5u1Qq.js", - "scene211-pbr-renderable-BfE5pOIl.js", + "scene211-pbr-renderable-D0Yq81ts.js", "scene211-singlelight-hemispheric-wgsl-DSkghTRP.js", - "scene211-skeleton-fragment-DIgH_ecl.js", + "scene211-skeleton-fragment-j8M2FAgZ.js", "scene211.js" ] }, "scene212": { - "rawKB": 102.6, + "rawKB": 102.7, "gzipKB": 41.1, "ignoredRawKB": 0, "runtimeChunks": [ "scene212-generate-mipmaps-BA4GCk3y.js", "scene212-gltf-ext-dielectric-C6hvXXiF.js", - "scene212-gltf-feature-registry-Bl0XFXnM.js", + "scene212-gltf-feature-registry-DhJBJp9H.js", "scene212-gltf-glb-parser-DpbXuAuC.js", "scene212-ibl-fragment-CeRrvT96.js", "scene212-ibl-skybox-wgsl-CFIBOHHx.js", @@ -2839,7 +2841,7 @@ ] }, "scene218": { - "rawKB": 91, + "rawKB": 91.1, "gzipKB": 37.9, "ignoredRawKB": 0, "runtimeChunks": [ @@ -2847,33 +2849,33 @@ "scene218-generate-mipmaps-OT2YZZyD.js", "scene218-gltf-animation-BXVexdPI.js", "scene218-gltf-ext-spec-gloss-D60sFZYX.js", - "scene218-gltf-feature-animations-Xir-5MtM.js", + "scene218-gltf-feature-animations-BhunLZUh.js", "scene218-gltf-feature-extras-VHPteGay.js", - "scene218-gltf-feature-registry-BIJQHgvB.js", + "scene218-gltf-feature-registry-B8LVL3Gw.js", "scene218-gltf-feature-skeleton-CuQWo93o.js", "scene218-gltf-glb-parser-BXHcqEF1.js", "scene218-gltf-sampler-desc-CS2bAJuk.js", - "scene218-pbr-renderable-Uyit_WAZ.js", + "scene218-pbr-renderable-Bh2l4hxd.js", "scene218-singlelight-hemispheric-wgsl-CYhHmrGt.js", "scene218.js" ] }, "scene219": { - "rawKB": 93.5, - "gzipKB": 39, + "rawKB": 93.6, + "gzipKB": 39.1, "ignoredRawKB": 0, "runtimeChunks": [ "scene219-create-skeleton-DNC_UZif.js", "scene219-generate-mipmaps-0aNHbogu.js", "scene219-gltf-animation-BrzaWRaE.js", "scene219-gltf-ext-spec-gloss-D60sFZYX.js", - "scene219-gltf-feature-animations-Cr5Rjgc6.js", + "scene219-gltf-feature-animations-7TJPdKpt.js", "scene219-gltf-feature-extras-VHPteGay.js", - "scene219-gltf-feature-registry-6BhbARjE.js", + "scene219-gltf-feature-registry-D-nL4k2x.js", "scene219-gltf-feature-skeleton-QiTS1ubb.js", "scene219-gltf-glb-parser-PDyH09Yg.js", "scene219-gltf-sampler-desc-CKWNJQQv.js", - "scene219-pbr-renderable-BwDj7GKa.js", + "scene219-pbr-renderable-CqDmN0xY.js", "scene219-singlelight-hemispheric-wgsl-tmGAbfLP.js", "scene219-thin-instance-fragment-CBn2miAC.js", "scene219-thin-instance-gpu-DFISRtwj.js", @@ -2968,38 +2970,39 @@ "scene229-generate-mipmaps-qvmJKzcy.js", "scene229-gltf-json-asset-aHzaWMo_.js", "scene229-gltf-normals-DYhLwXNX.js", - "scene229-pbr-renderable-l08Tk5Mv.js", + "scene229-pbr-renderable-BXEnKaMn.js", "scene229-unlit-fragment-DjsTCAku.js", "scene229.js" ] }, "scene240": { - "rawKB": 89.5, - "gzipKB": 37.4, + "rawKB": 89.6, + "gzipKB": 37.5, "ignoredRawKB": 0, "runtimeChunks": [ "scene240-flat-normal-wgsl-Cm9mM4tC.js", "scene240-generate-mipmaps-BQZ9ivTX.js", "scene240-gltf-animation-BKjmZpR3.js", - "scene240-gltf-feature-animations-Deh9CXYc.js", - "scene240-gltf-feature-registry-CfgxGkTs.js", + "scene240-gltf-feature-animations-hHnIQ4Bv.js", + "scene240-gltf-feature-registry-DjvCYRLY.js", "scene240-gltf-json-asset-wZq70j5q.js", "scene240-gltf-multi-buffer-Cwm5u1Qq.js", "scene240-gltf-normals-DYhLwXNX.js", "scene240-ibl-fragment-CeRrvT96.js", - "scene240-pbr-renderable-CMPWVjP9.js", + "scene240-pbr-renderable-u8wK6BUo.js", "scene240-rgbd-decode-2FzzQzZ2.js", "scene240-scene-uniforms-FTYH6Ct0.js", "scene240.js" ] }, "scene241": { - "rawKB": 160.3, - "gzipKB": 65.5, + "rawKB": 160.4, + "gzipKB": 65.7, "ignoredRawKB": 0, "runtimeChunks": [ "scene241-alpha-test-fragment-BpzeKSEk.js", - "scene241-animation-pointer-ext-Bwn0pDBw.js", + "scene241-animation-pointer-ext-C2fdauTX.js", + "scene241-animation-pointer-laGxw-Ac.js", "scene241-anisotropy-fragment-CbNkTpSR.js", "scene241-clearcoat-fragment-Bi0N7tnR.js", "scene241-directional-light-BIPgtvfI.js", @@ -3013,10 +3016,10 @@ "scene241-gltf-ext-sheen-Dv23b_3T.js", "scene241-gltf-ext-unlit-1vUYfF7v.js", "scene241-gltf-ext-uv-transform-CXwEpHUI.js", - "scene241-gltf-feature-animation-pointer-BJ0p5Vnu.js", - "scene241-gltf-feature-animations-CSjZ3yRx.js", + "scene241-gltf-feature-animation-pointer-CepPGaOM.js", + "scene241-gltf-feature-animations-CJigVEWA.js", "scene241-gltf-feature-lights-punctual-DjeruV0x.js", - "scene241-gltf-feature-registry-Bi3iZOoc.js", + "scene241-gltf-feature-registry-Ckb428t_.js", "scene241-gltf-json-asset-BI5mxnCG.js", "scene241-gltf-light-pointer-state-B7kFTRxK.js", "scene241-gltf-pbr-builder-ext-BK6naJUT.js", @@ -3024,10 +3027,10 @@ "scene241-iridescence-fragment-2b1zlUa_.js", "scene241-light-base-Dop4VNA_.js", "scene241-light-matrix-CtsX4GmG.js", - "scene241-pbr-refraction-DAa5tFok.js", - "scene241-pbr-renderable-cZo5h4ql.js", + "scene241-pbr-refraction-elsNtP3V.js", + "scene241-pbr-renderable-D58MtW_2.js", "scene241-pbr-template-ext-BoJ8SFLM.js", - "scene241-pbr-transmission-ext-DJnGQUbV.js", + "scene241-pbr-transmission-ext-DqBIqk4s.js", "scene241-reflectance-fragment-btSdECMQ.js", "scene241-rgbd-decode-Bil1WUEk.js", "scene241-scene-uniforms-FTYH6Ct0.js", @@ -3042,21 +3045,22 @@ ] }, "scene242": { - "rawKB": 96.2, - "gzipKB": 39.8, + "rawKB": 96.3, + "gzipKB": 40, "ignoredRawKB": 0, "runtimeChunks": [ + "scene242-animation-pointer-CtucZ4IY.js", "scene242-animation-pointer-basecolor-C0cq9A_I.js", "scene242-emissive-fragment-BjnF3duS.js", "scene242-generate-mipmaps-BCxjeJMR.js", "scene242-gltf-animation-Dm97A3mz.js", "scene242-gltf-ext-emissive-strength-CButLZnK.js", - "scene242-gltf-feature-animation-pointer-7Tn5Qu5X.js", - "scene242-gltf-feature-animations-B_VnRwPM.js", - "scene242-gltf-feature-registry-BUHew8Rq.js", + "scene242-gltf-feature-animation-pointer-DuUn5-xJ.js", + "scene242-gltf-feature-animations-BvTxdDNR.js", + "scene242-gltf-feature-registry-CW6xKIgS.js", "scene242-gltf-json-asset-CTUWQBxV.js", "scene242-ibl-fragment-CeRrvT96.js", - "scene242-pbr-renderable-DQEk4f7o.js", + "scene242-pbr-renderable-CmvfRP8M.js", "scene242-rgbd-decode-DapdOyyV.js", "scene242-scene-uniforms-FTYH6Ct0.js", "scene242-visibility-CNBVZaJ8.js", @@ -3064,22 +3068,22 @@ ] }, "scene243": { - "rawKB": 95.9, - "gzipKB": 40.4, + "rawKB": 96, + "gzipKB": 40.5, "ignoredRawKB": 0, "runtimeChunks": [ "scene243-create-morph-targets-C_lcfVOA.js", "scene243-generate-mipmaps-C4S5NcQC.js", "scene243-gltf-animation-B51SBoH8.js", - "scene243-gltf-feature-animations-DEeCXW1Z.js", + "scene243-gltf-feature-animations-C5TP9MXt.js", "scene243-gltf-feature-extras-VHPteGay.js", "scene243-gltf-feature-morph-BCUQi4W_.js", - "scene243-gltf-feature-registry-Ca6TPDdU.js", + "scene243-gltf-feature-registry-C23yalDf.js", "scene243-gltf-json-asset-CnDTTIDf.js", "scene243-gltf-pbr-builder-ext-DSRj0KRz.js", "scene243-ibl-fragment-CeRrvT96.js", - "scene243-morph-fragment-DpvPLMUz.js", - "scene243-pbr-renderable-Cm34unHt.js", + "scene243-morph-fragment-DTmLRHdp.js", + "scene243-pbr-renderable-CShH3BMl.js", "scene243-pbr-template-ext-BoJ8SFLM.js", "scene243-rgbd-decode-CtyuiJdH.js", "scene243-scene-uniforms-FTYH6Ct0.js", @@ -3088,27 +3092,28 @@ ] }, "scene244": { - "rawKB": 132.1, - "gzipKB": 53.4, + "rawKB": 132.2, + "gzipKB": 53.6, "ignoredRawKB": 0, "runtimeChunks": [ - "scene244-animation-pointer-ext-DfeJiTkj.js", + "scene244-animation-pointer-ext-CoOxzYYy.js", + "scene244-animation-pointer-jEniq5im.js", "scene244-clearcoat-fragment-fqLK-qje.js", "scene244-generate-mipmaps-BY39y9hS.js", "scene244-gltf-animation-BkhOo1GS.js", "scene244-gltf-ext-clearcoat-BgaLgSgD.js", "scene244-gltf-ext-dielectric-C6hvXXiF.js", "scene244-gltf-ext-uv-transform-YGWG8v_J.js", - "scene244-gltf-feature-animation-pointer-Cl7BygHu.js", - "scene244-gltf-feature-animations-CTlvB6vN.js", - "scene244-gltf-feature-registry-BPhJRekD.js", + "scene244-gltf-feature-animation-pointer-Cv2YzJgj.js", + "scene244-gltf-feature-animations-DmXCvx5H.js", + "scene244-gltf-feature-registry-DZB2Rivj.js", "scene244-gltf-json-asset-DaKe4-Lc.js", "scene244-gltf-pbr-builder-ext-Bi8wmQmz.js", "scene244-ibl-fragment-CeRrvT96.js", - "scene244-pbr-refraction-CXnidF1h.js", - "scene244-pbr-renderable-CLORI0l-.js", + "scene244-pbr-refraction-1mYxyyrr.js", + "scene244-pbr-renderable-CjRk8WAR.js", "scene244-pbr-template-ext-BoJ8SFLM.js", - "scene244-pbr-transmission-ext-CWsRTR8g.js", + "scene244-pbr-transmission-ext-D5dYChu4.js", "scene244-reflectance-fragment-COQ8Gwb2.js", "scene244-rgbd-decode-DC93u83f.js", "scene244-scene-uniforms-FTYH6Ct0.js", @@ -3119,7 +3124,7 @@ ] }, "scene245": { - "rawKB": 98.6, + "rawKB": 98.7, "gzipKB": 41.8, "ignoredRawKB": 0, "runtimeChunks": [ @@ -3127,24 +3132,24 @@ "scene245-flat-normal-wgsl-Cm9mM4tC.js", "scene245-generate-mipmaps-WM6bVf2v.js", "scene245-gltf-animation-Dvm46y_g.js", - "scene245-gltf-feature-animations-BV66HV-9.js", - "scene245-gltf-feature-registry-Cna9wg56.js", + "scene245-gltf-feature-animations-B19wAXO_.js", + "scene245-gltf-feature-registry-ComvNA2d.js", "scene245-gltf-feature-skeleton-IRWJQHYY.js", "scene245-gltf-interleave-D58ra88P.js", "scene245-gltf-json-asset-BtsXM9cU.js", "scene245-gltf-normals-DYhLwXNX.js", "scene245-gltf-strided-attribute-CGKc0Sx6.js", "scene245-ibl-fragment-CeRrvT96.js", - "scene245-pbr-renderable-DJtd1wk2.js", + "scene245-pbr-renderable-CUmy8A52.js", "scene245-pbr-template-ext-BoJ8SFLM.js", "scene245-rgbd-decode-CKe7_W5g.js", "scene245-scene-uniforms-FTYH6Ct0.js", - "scene245-skeleton-fragment-DCOcURyb.js", + "scene245-skeleton-fragment-DkIdFFDD.js", "scene245.js" ] }, "scene246": { - "rawKB": 97.5, + "rawKB": 97.6, "gzipKB": 41.4, "ignoredRawKB": 0, "runtimeChunks": [ @@ -3152,8 +3157,8 @@ "scene246-flat-normal-wgsl-Cm9mM4tC.js", "scene246-generate-mipmaps-DzKvtUax.js", "scene246-gltf-animation-DGsaicDt.js", - "scene246-gltf-feature-animations-DimU6cfM.js", - "scene246-gltf-feature-registry-Ddaviakj.js", + "scene246-gltf-feature-animations-CPl_ISPs.js", + "scene246-gltf-feature-registry-Dd-Cl0OW.js", "scene246-gltf-feature-skeleton-Bf1KbRoN.js", "scene246-gltf-interleave-BhCGfTMm.js", "scene246-gltf-json-asset-DZ_iAA05.js", @@ -3161,26 +3166,26 @@ "scene246-gltf-normals-DYhLwXNX.js", "scene246-gltf-strided-attribute-R3NdoKEL.js", "scene246-ibl-fragment-CeRrvT96.js", - "scene246-pbr-renderable-CIdpqET2.js", + "scene246-pbr-renderable-gTWSGiFj.js", "scene246-rgbd-decode-DQhLUjb0.js", "scene246-scene-uniforms-FTYH6Ct0.js", - "scene246-skeleton-fragment-vnGyEpBe.js", + "scene246-skeleton-fragment-DeOaTzo7.js", "scene246.js" ] }, "scene247": { - "rawKB": 82.8, + "rawKB": 82.9, "gzipKB": 34.4, "ignoredRawKB": 0, "runtimeChunks": [ "scene247-generate-mipmaps-Crazqgzv.js", "scene247-gltf-feature-extras-VHPteGay.js", "scene247-gltf-feature-gpu-instancing-Ctn1z3Jy.js", - "scene247-gltf-feature-registry-BH2GZ29y.js", + "scene247-gltf-feature-registry-D_YBbigE.js", "scene247-gltf-json-asset-nLezCZiO.js", "scene247-gltf-multi-buffer-Cwm5u1Qq.js", "scene247-ibl-fragment-CeRrvT96.js", - "scene247-pbr-renderable-CqPnXLX7.js", + "scene247-pbr-renderable-D7Uak2DC.js", "scene247-rgbd-decode-ClWWmAM6.js", "scene247-scene-uniforms-FTYH6Ct0.js", "scene247-thin-instance-fragment-CBn2miAC.js", @@ -3197,7 +3202,7 @@ "scene248-gltf-json-asset-Ctllxvz4.js", "scene248-gltf-sampler-desc-CdC-ieMf.js", "scene248-ibl-fragment-CeRrvT96.js", - "scene248-pbr-renderable-JPiljkua.js", + "scene248-pbr-renderable-D_KxVP9v.js", "scene248-rgbd-decode-CLeq7oRj.js", "scene248-scene-uniforms-FTYH6Ct0.js", "scene248.js" @@ -3213,7 +3218,7 @@ "scene249-gltf-color-normalize-BtnJk1yq.js", "scene249-gltf-json-asset-CB71ibXk.js", "scene249-ibl-fragment-CeRrvT96.js", - "scene249-pbr-renderable-Dq7mqANN.js", + "scene249-pbr-renderable-DxCf_gK2.js", "scene249-pbr-template-ext-BoJ8SFLM.js", "scene249-rgbd-decode-BRTm0Szn.js", "scene249-scene-uniforms-FTYH6Ct0.js", @@ -3221,20 +3226,20 @@ ] }, "scene251": { - "rawKB": 88.1, + "rawKB": 88.2, "gzipKB": 36.5, "ignoredRawKB": 0, "runtimeChunks": [ "scene251-create-skeleton-QaTYEW_C.js", "scene251-generate-mipmaps-ojYQxP8q.js", "scene251-gltf-animation-BJ2pOO_r.js", - "scene251-gltf-feature-animations-Do8ooupS.js", - "scene251-gltf-feature-registry-CIpBJuh2.js", + "scene251-gltf-feature-animations-Cj7oKyXf.js", + "scene251-gltf-feature-registry-DlOXiKU4.js", "scene251-gltf-feature-skeleton-Dd5Pe1-h.js", "scene251-gltf-glb-parser-8AD91ACR.js", "scene251-multilight-wgsl-Cxg2lDtv.js", - "scene251-pbr-renderable-DIJLkuwp.js", - "scene251-skeleton-fragment-CylZgMZD.js", + "scene251-pbr-renderable-rJR7cw2X.js", + "scene251-skeleton-fragment-BbV5HG1g.js", "scene251.js" ] }, @@ -3251,13 +3256,14 @@ }, "scene253": { "rawKB": 151, - "gzipKB": 63.7, + "gzipKB": 63.9, "ignoredRawKB": 0, "runtimeChunks": [ "scene253-alpha-test-fragment-CBuxkQtx.js", + "scene253-animation-pointer-4eq964B7.js", "scene253-animation-pointer-basecolor-C0cq9A_I.js", - "scene253-animation-pointer-ext-CIs10IPN.js", - "scene253-animation-pointer-lights-BUYIPQbK.js", + "scene253-animation-pointer-ext-VMAJienr.js", + "scene253-animation-pointer-lights-ZnjZL09g.js", "scene253-create-morph-targets-D5qHDYGj.js", "scene253-create-skeleton-DJ2P-GNN.js", "scene253-directional-light-C7HjjUeW.js", @@ -3270,12 +3276,12 @@ "scene253-gltf-ext-iridescence-b4LVniAM.js", "scene253-gltf-ext-unlit-1vUYfF7v.js", "scene253-gltf-ext-uv-transform-DixkPR9o.js", - "scene253-gltf-feature-animation-pointer-D2g4esdb.js", - "scene253-gltf-feature-animations-DQAfdhkC.js", + "scene253-gltf-feature-animation-pointer-B4es0u03.js", + "scene253-gltf-feature-animations-BfsMkPNo.js", "scene253-gltf-feature-extras-VHPteGay.js", "scene253-gltf-feature-lights-punctual-B0T8d3mz.js", "scene253-gltf-feature-morph-OgvZWqfo.js", - "scene253-gltf-feature-registry-BxN0uz2i.js", + "scene253-gltf-feature-registry-CX0KDP3N.js", "scene253-gltf-feature-skeleton-Bjigd6Ok.js", "scene253-gltf-json-asset-CQyVogeZ.js", "scene253-gltf-light-pointer-state-B7kFTRxK.js", @@ -3285,16 +3291,16 @@ "scene253-iridescence-fragment-BIm-2okC.js", "scene253-light-base-eE8_4fU8.js", "scene253-light-matrix-BpE3lP6k.js", - "scene253-morph-fragment-ClEpS6EZ.js", + "scene253-morph-fragment-CEhGS5lc.js", "scene253-multilight-wgsl-D9gPIJS-.js", - "scene253-pbr-refraction-CjdGzkAP.js", - "scene253-pbr-renderable-Bno43uQO.js", + "scene253-pbr-refraction-Cnk6VDE7.js", + "scene253-pbr-renderable-BmtMluPY.js", "scene253-pbr-template-ext-BoJ8SFLM.js", - "scene253-pbr-transmission-ext-D9xCLdT8.js", + "scene253-pbr-transmission-ext-Cd9Bx90s.js", "scene253-reflectance-fragment-ua5TUzB_.js", "scene253-rgbd-decode-Cz9OR-T-.js", "scene253-scene-uniforms-FTYH6Ct0.js", - "scene253-skeleton-fragment-D5DeYQNO.js", + "scene253-skeleton-fragment-1vKJHRqi.js", "scene253-spot-light-D97XdhJC.js", "scene253-texture-2d-DQo3n8D6.js", "scene253-unlit-fragment-BCZQRNLX.js", diff --git a/lab/public/models/Calculator.glb b/lab/public/models/Calculator.glb new file mode 100644 index 0000000000..f8cbd986c4 Binary files /dev/null and b/lab/public/models/Calculator.glb differ diff --git a/packages/babylon-lite/src/asset-container.ts b/packages/babylon-lite/src/asset-container.ts index d2760e610a..6fe74357fb 100644 --- a/packages/babylon-lite/src/asset-container.ts +++ b/packages/babylon-lite/src/asset-container.ts @@ -30,6 +30,9 @@ export interface AssetContainer { * `enableBoneControl()` was called before loading; otherwise `undefined`. * Drive bones via `getBoneByName()` + the `setBone*` functions. */ skeletons?: Skeleton[]; + /** Flow graphs parsed from the file (glTF KHR_interactivity). addToScene() + * binds and runs them via the scene's frame loop. */ + flowGraphs?: import("./flow-graph/context.js").LoadedFlowGraph[]; } /** diff --git a/packages/babylon-lite/src/flow-graph/block-def.ts b/packages/babylon-lite/src/flow-graph/block-def.ts new file mode 100644 index 0000000000..3c434c172c --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/block-def.ts @@ -0,0 +1,40 @@ +// The pure behaviour record for one block type. No classes, no `this`. +// Exactly ONE `FgBlockDef` per block kind; this is what a porter writes +// (see .github/copilot/skills/port-flow-graph-block.md). + +import type { FgContext, FgEnv, FgPendingTask } from "./context.js"; +import type { FgBlock, FgDataSocket, FgEventType, FgSignalSocket } from "./types.js"; + +/** The shape a def declares when a block is instantiated. */ +export interface FgBlockShape { + dataIn?: FgDataSocket[]; + dataOut?: FgDataSocket[]; + signalIn?: FgSignalSocket[]; + signalOut?: FgSignalSocket[]; + event?: FgEventType; +} + +/** + * Pure behaviour record for one block type. No classes, no `this`. + * A def is stateless — all per-run state lives in `FgContext`, keyed by block id. + */ +export interface FgBlockDef { + readonly type: string; + + /** Declare sockets/signals from config (called once at instantiation). */ + readonly build: (config: Readonly> | undefined) => FgBlockShape; + + /** DATA blocks: compute outputs from inputs (PULL). Writes via `setDataValue`. */ + readonly updateOutputs?: (block: FgBlock, ctx: FgContext, env: FgEnv) => void; + + /** EXECUTION blocks: run when an input signal fires (PUSH). For async blocks + * this is also where a task is started, via `addPending(ctx, block)`. */ + readonly execute?: (block: FgBlock, ctx: FgContext, env: FgEnv, incomingSignal: string) => void; + + /** ASYNC blocks: advance one outstanding task each frame (e.g. delay + * countdown, animation progress). The tick loop passes the specific task. */ + readonly onTick?: (block: FgBlock, ctx: FgContext, env: FgEnv, deltaMs: number, task: FgPendingTask) => void; + + /** ASYNC blocks: teardown hook called on dispose/cancel; mark tasks canceled. */ + readonly cancelPending?: (block: FgBlock, ctx: FgContext, env: FgEnv) => void; +} diff --git a/packages/babylon-lite/src/flow-graph/block-registry.ts b/packages/babylon-lite/src/flow-graph/block-registry.ts new file mode 100644 index 0000000000..07dd560a56 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/block-registry.ts @@ -0,0 +1,290 @@ +// Tree-shakable, side-effect-free block-def registry. Returns a lazy loader for +// one block def, or `null` for an unknown type. Each `case` dynamic-imports a +// single block module so unused blocks are code-split and never fetched — zero +// bytes for scenes without interactivity. Mirrors BJS `blockFactory` and Lite's +// `gltf-feature-registry`. +// +// Phase 2 lands the vertical-slice blocks (events, control-flow, one math op, +// property/variable data, animation). Add one `case` per block as more land +// (Phase 3+). The `switch` body stays pure (no module-level allocation), +// keeping this module fully tree-shakable. +// +// Unknown-op policy lives in the CALLER: `createFgEnv` (KHR_interactivity path) +// fails loudly on `null`; a permissive editor path (post-MVP) may substitute a +// no-op. Never silently swallow an unknown op on the KHR path. + +import type { FgBlockDef } from "./block-def.js"; +import { FgBlockType } from "./block-type.js"; + +export function getBlockDef(type: string): (() => Promise) | null { + switch (type) { + // ─── Events ─────────────────────────────────────────────── + case FgBlockType.SceneStart: + return async () => (await import("./blocks/events/scene-start.js")).sceneStartDef; + case FgBlockType.SceneTick: + return async () => (await import("./blocks/events/scene-tick.js")).sceneTickDef; + case FgBlockType.OnSelect: + return async () => (await import("./blocks/events/on-select.js")).onSelectDef; + case FgBlockType.SendCustomEvent: + return async () => (await import("./blocks/events/send-custom-event.js")).sendCustomEventDef; + case FgBlockType.ReceiveCustomEvent: + return async () => (await import("./blocks/events/receive-custom-event.js")).receiveCustomEventDef; + + // ─── Control flow ───────────────────────────────────────── + case FgBlockType.Branch: + return async () => (await import("./blocks/control-flow/branch.js")).branchDef; + case FgBlockType.Sequence: + return async () => (await import("./blocks/control-flow/sequence.js")).sequenceDef; + case FgBlockType.Switch: + return async () => (await import("./blocks/control-flow/switch.js")).switchDef; + case FgBlockType.ForLoop: + return async () => (await import("./blocks/control-flow/for-loop.js")).forLoopDef; + case FgBlockType.WhileLoop: + return async () => (await import("./blocks/control-flow/while-loop.js")).whileLoopDef; + case FgBlockType.DoN: + return async () => (await import("./blocks/control-flow/do-n.js")).doNDef; + case FgBlockType.MultiGate: + return async () => (await import("./blocks/control-flow/multi-gate.js")).multiGateDef; + case FgBlockType.WaitAll: + return async () => (await import("./blocks/control-flow/wait-all.js")).waitAllDef; + case FgBlockType.Throttle: + return async () => (await import("./blocks/control-flow/throttle.js")).throttleDef; + case FgBlockType.SetDelay: + return async () => (await import("./blocks/control-flow/set-delay.js")).setDelayDef; + case FgBlockType.CancelDelay: + return async () => (await import("./blocks/control-flow/cancel-delay.js")).cancelDelayDef; + + // ─── Math ───────────────────────────────────────────────── + case FgBlockType.Add: + return async () => (await import("./blocks/math/add.js")).addDef; + case FgBlockType.Subtract: + return async () => (await import("./blocks/math/subtract.js")).subtractDef; + case FgBlockType.Multiply: + return async () => (await import("./blocks/math/multiply.js")).multiplyDef; + case FgBlockType.Divide: + return async () => (await import("./blocks/math/divide.js")).divideDef; + case FgBlockType.Modulo: + return async () => (await import("./blocks/math/modulo.js")).moduloDef; + case FgBlockType.Abs: + return async () => (await import("./blocks/math/abs.js")).absDef; + case FgBlockType.Floor: + return async () => (await import("./blocks/math/floor.js")).floorDef; + case FgBlockType.LessThan: + return async () => (await import("./blocks/math/less-than.js")).lessThanDef; + case FgBlockType.Clamp: + return async () => (await import("./blocks/math/clamp.js")).clampDef; + case FgBlockType.CombineVector2: + return async () => (await import("./blocks/math/combine2.js")).combine2Def; + case FgBlockType.ExtractVector2: + return async () => (await import("./blocks/math/extract2.js")).extract2Def; + + // ─── Math: Phase 3 (scalar / trig / compare / bitwise / vector) ── + case FgBlockType.Negation: + return async () => (await import("./blocks/math/negation.js")).negationDef; + case FgBlockType.Sign: + return async () => (await import("./blocks/math/sign.js")).signDef; + case FgBlockType.Ceil: + return async () => (await import("./blocks/math/ceil.js")).ceilDef; + case FgBlockType.Round: + return async () => (await import("./blocks/math/round.js")).roundDef; + case FgBlockType.Trunc: + return async () => (await import("./blocks/math/trunc.js")).truncDef; + case FgBlockType.Fraction: + return async () => (await import("./blocks/math/fraction.js")).fractionDef; + case FgBlockType.Saturate: + return async () => (await import("./blocks/math/saturate.js")).saturateDef; + case FgBlockType.SquareRoot: + return async () => (await import("./blocks/math/square-root.js")).squareRootDef; + case FgBlockType.CubeRoot: + return async () => (await import("./blocks/math/cube-root.js")).cubeRootDef; + case FgBlockType.Exponential: + return async () => (await import("./blocks/math/exponential.js")).exponentialDef; + case FgBlockType.Log: + return async () => (await import("./blocks/math/log.js")).logDef; + case FgBlockType.Log2: + return async () => (await import("./blocks/math/log2.js")).log2Def; + case FgBlockType.Log10: + return async () => (await import("./blocks/math/log10.js")).log10Def; + case FgBlockType.DegToRad: + return async () => (await import("./blocks/math/deg-to-rad.js")).degToRadDef; + case FgBlockType.RadToDeg: + return async () => (await import("./blocks/math/rad-to-deg.js")).radToDegDef; + case FgBlockType.Sin: + return async () => (await import("./blocks/math/sin.js")).sinDef; + case FgBlockType.Cos: + return async () => (await import("./blocks/math/cos.js")).cosDef; + case FgBlockType.Tan: + return async () => (await import("./blocks/math/tan.js")).tanDef; + case FgBlockType.Asin: + return async () => (await import("./blocks/math/asin.js")).asinDef; + case FgBlockType.Acos: + return async () => (await import("./blocks/math/acos.js")).acosDef; + case FgBlockType.Atan: + return async () => (await import("./blocks/math/atan.js")).atanDef; + case FgBlockType.Sinh: + return async () => (await import("./blocks/math/sinh.js")).sinhDef; + case FgBlockType.Cosh: + return async () => (await import("./blocks/math/cosh.js")).coshDef; + case FgBlockType.Tanh: + return async () => (await import("./blocks/math/tanh.js")).tanhDef; + case FgBlockType.Asinh: + return async () => (await import("./blocks/math/asinh.js")).asinhDef; + case FgBlockType.Acosh: + return async () => (await import("./blocks/math/acosh.js")).acoshDef; + case FgBlockType.Atanh: + return async () => (await import("./blocks/math/atanh.js")).atanhDef; + case FgBlockType.Min: + return async () => (await import("./blocks/math/min.js")).minDef; + case FgBlockType.Max: + return async () => (await import("./blocks/math/max.js")).maxDef; + case FgBlockType.Power: + return async () => (await import("./blocks/math/power.js")).powerDef; + case FgBlockType.Atan2: + return async () => (await import("./blocks/math/atan2.js")).atan2Def; + case FgBlockType.Equality: + return async () => (await import("./blocks/math/equality.js")).equalityDef; + case FgBlockType.LessThanOrEqual: + return async () => (await import("./blocks/math/less-than-or-equal.js")).lessThanOrEqualDef; + case FgBlockType.GreaterThan: + return async () => (await import("./blocks/math/greater-than.js")).greaterThanDef; + case FgBlockType.GreaterThanOrEqual: + return async () => (await import("./blocks/math/greater-than-or-equal.js")).greaterThanOrEqualDef; + case FgBlockType.IsNaN: + return async () => (await import("./blocks/math/is-nan.js")).isNaNDef; + case FgBlockType.IsInfinity: + return async () => (await import("./blocks/math/is-infinity.js")).isInfinityDef; + case FgBlockType.BitwiseAnd: + return async () => (await import("./blocks/math/bitwise-and.js")).bitwiseAndDef; + case FgBlockType.BitwiseOr: + return async () => (await import("./blocks/math/bitwise-or.js")).bitwiseOrDef; + case FgBlockType.BitwiseXor: + return async () => (await import("./blocks/math/bitwise-xor.js")).bitwiseXorDef; + case FgBlockType.BitwiseNot: + return async () => (await import("./blocks/math/bitwise-not.js")).bitwiseNotDef; + case FgBlockType.BitwiseLeftShift: + return async () => (await import("./blocks/math/bitwise-left-shift.js")).bitwiseLeftShiftDef; + case FgBlockType.BitwiseRightShift: + return async () => (await import("./blocks/math/bitwise-right-shift.js")).bitwiseRightShiftDef; + case FgBlockType.LeadingZeros: + return async () => (await import("./blocks/math/leading-zeros.js")).leadingZerosDef; + case FgBlockType.TrailingZeros: + return async () => (await import("./blocks/math/trailing-zeros.js")).trailingZerosDef; + case FgBlockType.OneBitsCounter: + return async () => (await import("./blocks/math/one-bits-counter.js")).oneBitsCounterDef; + case FgBlockType.Length: + return async () => (await import("./blocks/math/length.js")).lengthDef; + case FgBlockType.Normalize: + return async () => (await import("./blocks/math/normalize.js")).normalizeDef; + case FgBlockType.Dot: + return async () => (await import("./blocks/math/dot.js")).dotDef; + case FgBlockType.Cross: + return async () => (await import("./blocks/math/cross.js")).crossDef; + case FgBlockType.Rotate2D: + return async () => (await import("./blocks/math/rotate2d.js")).rotate2DDef; + case FgBlockType.Rotate3D: + return async () => (await import("./blocks/math/rotate3d.js")).rotate3DDef; + case FgBlockType.MathInterpolation: + return async () => (await import("./blocks/math/mix.js")).mathInterpolationDef; + case FgBlockType.CombineVector3: + return async () => (await import("./blocks/math/combine3.js")).combine3Def; + case FgBlockType.CombineVector4: + return async () => (await import("./blocks/math/combine4.js")).combine4Def; + case FgBlockType.ExtractVector3: + return async () => (await import("./blocks/math/extract3.js")).extract3Def; + case FgBlockType.ExtractVector4: + return async () => (await import("./blocks/math/extract4.js")).extract4Def; + case FgBlockType.E: + return async () => (await import("./blocks/math/constant-e.js")).eDef; + case FgBlockType.PI: + return async () => (await import("./blocks/math/constant-pi.js")).piDef; + case FgBlockType.Inf: + return async () => (await import("./blocks/math/constant-inf.js")).infDef; + case FgBlockType.NaN: + return async () => (await import("./blocks/math/constant-nan.js")).nanDef; + case FgBlockType.Random: + return async () => (await import("./blocks/math/random.js")).randomDef; + case FgBlockType.Conditional: + return async () => (await import("./blocks/math/conditional.js")).conditionalDef; + case FgBlockType.DataSwitch: + return async () => (await import("./blocks/math/data-switch.js")).dataSwitchDef; + + // ─── Math: Phase 3f (matrix + quaternion) ──────────────────────────── + case FgBlockType.TransformVector: + return async () => (await import("./blocks/math/transform-vector.js")).transformVectorDef; + case FgBlockType.CombineMatrix2D: + return async () => (await import("./blocks/math/combine-matrix2d.js")).combineMatrix2DDef; + case FgBlockType.CombineMatrix3D: + return async () => (await import("./blocks/math/combine-matrix3d.js")).combineMatrix3DDef; + case FgBlockType.CombineMatrix: + return async () => (await import("./blocks/math/combine-matrix.js")).combineMatrixDef; + case FgBlockType.ExtractMatrix2D: + return async () => (await import("./blocks/math/extract-matrix2d.js")).extractMatrix2DDef; + case FgBlockType.ExtractMatrix3D: + return async () => (await import("./blocks/math/extract-matrix3d.js")).extractMatrix3DDef; + case FgBlockType.ExtractMatrix: + return async () => (await import("./blocks/math/extract-matrix.js")).extractMatrixDef; + case FgBlockType.Transpose: + return async () => (await import("./blocks/math/transpose.js")).transposeDef; + case FgBlockType.Determinant: + return async () => (await import("./blocks/math/determinant.js")).determinantDef; + case FgBlockType.InvertMatrix: + return async () => (await import("./blocks/math/invert-matrix.js")).invertMatrixDef; + case FgBlockType.MatrixMultiplication: + return async () => (await import("./blocks/math/matrix-multiplication.js")).matrixMultiplicationDef; + case FgBlockType.MatrixCompose: + return async () => (await import("./blocks/math/matrix-compose.js")).matrixComposeDef; + case FgBlockType.MatrixDecompose: + return async () => (await import("./blocks/math/matrix-decompose.js")).matrixDecomposeDef; + case FgBlockType.Conjugate: + return async () => (await import("./blocks/math/quat-conjugate.js")).quatConjugateDef; + case FgBlockType.AngleBetween: + return async () => (await import("./blocks/math/angle-between.js")).angleBetweenDef; + case FgBlockType.QuaternionFromAxisAngle: + return async () => (await import("./blocks/math/quaternion-from-axis-angle.js")).quaternionFromAxisAngleDef; + case FgBlockType.AxisAngleFromQuaternion: + return async () => (await import("./blocks/math/axis-angle-from-quaternion.js")).axisAngleFromQuaternionDef; + case FgBlockType.QuaternionFromDirections: + return async () => (await import("./blocks/math/quaternion-from-directions.js")).quaternionFromDirectionsDef; + case FgBlockType.QuaternionMultiplication: + return async () => (await import("./blocks/math/quaternion-multiplication.js")).quaternionMultiplicationDef; + case FgBlockType.BooleanToFloat: + return async () => (await import("./blocks/conversion/boolean-to-float.js")).booleanToFloatDef; + case FgBlockType.BooleanToInt: + return async () => (await import("./blocks/conversion/boolean-to-int.js")).booleanToIntDef; + case FgBlockType.FloatToBoolean: + return async () => (await import("./blocks/conversion/float-to-boolean.js")).floatToBooleanDef; + case FgBlockType.IntToBoolean: + return async () => (await import("./blocks/conversion/int-to-boolean.js")).intToBooleanDef; + case FgBlockType.IntToFloat: + return async () => (await import("./blocks/conversion/int-to-float.js")).intToFloatDef; + case FgBlockType.FloatToInt: + return async () => (await import("./blocks/conversion/float-to-int.js")).floatToIntDef; + + // ─── Data: property / variable ──────────────────────────── + case FgBlockType.GetProperty: + return async () => (await import("./blocks/data/get-property.js")).getPropertyDef; + case FgBlockType.SetProperty: + return async () => (await import("./blocks/data/set-property.js")).setPropertyDef; + case FgBlockType.GetVariable: + return async () => (await import("./blocks/data/get-variable.js")).getVariableDef; + case FgBlockType.SetVariable: + return async () => (await import("./blocks/data/set-variable.js")).setVariableDef; + case FgBlockType.Constant: + return async () => (await import("./blocks/data/constant.js")).constantDef; + + // ─── Animation ──────────────────────────────────────────── + case FgBlockType.PlayAnimation: + return async () => (await import("./blocks/animation/play-animation.js")).playAnimationDef; + case FgBlockType.StopAnimation: + return async () => (await import("./blocks/animation/stop-animation.js")).stopAnimationDef; + case FgBlockType.ValueInterpolation: + return async () => (await import("./blocks/animation/value-interpolation.js")).valueInterpolationDef; + + // ─── Debug ──────────────────────────────────────────────── + case FgBlockType.ConsoleLog: + return async () => (await import("./blocks/debug/console-log.js")).consoleLogDef; + + default: + return null; + } +} diff --git a/packages/babylon-lite/src/flow-graph/block-type.ts b/packages/babylon-lite/src/flow-graph/block-type.ts new file mode 100644 index 0000000000..96e615e752 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/block-type.ts @@ -0,0 +1,167 @@ +// Lite block-type identifiers. `const enum` string tags → erased at build. +// String values mirror the BJS `FlowGraphBlockNames` / glTF op identifiers +// where practical so the declaration mapper can pass them through. +// Phase 1 ships NO block implementations; this enum is the stable name surface +// that block-registry.ts and the gltf mapper resolve against as blocks land. + +export const enum FgBlockType { + // ─── Events ─────────────────────────────────────────────── + SceneStart = "SceneReadyEvent", + SceneTick = "SceneTickEvent", + OnSelect = "OnSelect", + SendCustomEvent = "SendCustomEvent", + ReceiveCustomEvent = "ReceiveCustomEvent", + + // ─── Control flow ───────────────────────────────────────── + Branch = "Branch", + Sequence = "Sequence", + Switch = "Switch", + ForLoop = "ForLoop", + WhileLoop = "WhileLoop", + DoN = "DoN", + MultiGate = "MultiGate", + WaitAll = "WaitAll", + Throttle = "Throttle", + SetDelay = "SetDelay", + CancelDelay = "CancelDelay", + + // ─── Data / math: arithmetic ────────────────────────────── + Constant = "Constant", + Add = "Add", + Subtract = "Subtract", + Multiply = "Multiply", + Divide = "Divide", + Modulo = "Modulo", + Min = "Min", + Max = "Max", + Power = "Power", + Negation = "Negation", + + // ─── Data / math: rounding + sign ───────────────────────── + Abs = "Abs", + Sign = "Sign", + Floor = "Floor", + Ceil = "Ceil", + Round = "Round", + Trunc = "Trunc", + Fraction = "Fraction", + Saturate = "Saturate", + Clamp = "Clamp", + + // ─── Data / math: exp / log / roots ─────────────────────── + Exponential = "Exponential", + Log = "Log", + Log2 = "Log2", + Log10 = "Log10", + SquareRoot = "SquareRoot", + CubeRoot = "CubeRoot", + + // ─── Data / math: trig + angles ─────────────────────────── + DegToRad = "DegToRad", + RadToDeg = "RadToDeg", + Sin = "Sin", + Cos = "Cos", + Tan = "Tan", + Asin = "Asin", + Acos = "Acos", + Atan = "Atan", + Atan2 = "Atan2", + Sinh = "Sinh", + Cosh = "Cosh", + Tanh = "Tanh", + Asinh = "Asinh", + Acosh = "Acosh", + Atanh = "Atanh", + + // ─── Data / math: comparison ────────────────────────────── + Equality = "Equality", + LessThan = "LessThan", + LessThanOrEqual = "LessThanOrEqual", + GreaterThan = "GreaterThan", + GreaterThanOrEqual = "GreaterThanOrEqual", + IsNaN = "IsNaN", + IsInfinity = "IsInfinity", + + // ─── Data / math: constants ─────────────────────────────── + E = "E", + PI = "PI", + Inf = "Inf", + NaN = "NaN", + Random = "Random", + + // ─── Data / math: selection ─────────────────────────────── + Conditional = "Conditional", + DataSwitch = "DataSwitch", + + // ─── Data / math: integer bitwise ───────────────────────── + BitwiseAnd = "BitwiseAnd", + BitwiseOr = "BitwiseOr", + BitwiseXor = "BitwiseXor", + BitwiseNot = "BitwiseNot", + BitwiseLeftShift = "BitwiseLeftShift", + BitwiseRightShift = "BitwiseRightShift", + LeadingZeros = "LeadingZeros", + TrailingZeros = "TrailingZeros", + OneBitsCounter = "OneBitsCounter", + + // ─── Data / math: vector ops ────────────────────────────── + Length = "Length", + Normalize = "Normalize", + Dot = "Dot", + Cross = "Cross", + MathInterpolation = "MathInterpolation", + Rotate2D = "Rotate2D", + Rotate3D = "Rotate3D", + TransformVector = "TransformVector", + + // ─── Data / math: combine / extract ─────────────────────── + CombineVector2 = "CombineVector2", + CombineVector3 = "CombineVector3", + CombineVector4 = "CombineVector4", + CombineMatrix2D = "CombineMatrix2D", + CombineMatrix3D = "CombineMatrix3D", + CombineMatrix = "CombineMatrix", + ExtractVector2 = "ExtractVector2", + ExtractVector3 = "ExtractVector3", + ExtractVector4 = "ExtractVector4", + ExtractMatrix2D = "ExtractMatrix2D", + ExtractMatrix3D = "ExtractMatrix3D", + ExtractMatrix = "ExtractMatrix", + + // ─── Data / math: matrix ────────────────────────────────── + Transpose = "Transpose", + Determinant = "Determinant", + InvertMatrix = "InvertMatrix", + MatrixMultiplication = "MatrixMultiplication", + MatrixCompose = "MatrixCompose", + MatrixDecompose = "MatrixDecompose", + + // ─── Data / math: quaternion ────────────────────────────── + Conjugate = "Conjugate", + AngleBetween = "AngleBetween", + QuaternionFromAxisAngle = "QuaternionFromAxisAngle", + AxisAngleFromQuaternion = "AxisAngleFromQuaternion", + QuaternionFromDirections = "QuaternionFromDirections", + QuaternionMultiplication = "QuaternionMultiplication", + + // ─── Data / type conversion ─────────────────────────────── + BooleanToFloat = "BooleanToFloat", + BooleanToInt = "BooleanToInt", + FloatToBoolean = "FloatToBoolean", + IntToBoolean = "IntToBoolean", + IntToFloat = "IntToFloat", + FloatToInt = "FloatToInt", + + // ─── Pointer / variable / animation ─────────────────────── + GetProperty = "GetProperty", + SetProperty = "SetProperty", + JsonPointerParser = "JsonPointerParser", + GetVariable = "GetVariable", + SetVariable = "SetVariable", + ValueInterpolation = "ValueInterpolation", + PlayAnimation = "PlayAnimation", + StopAnimation = "StopAnimation", + + // ─── Debug ──────────────────────────────────────────────── + ConsoleLog = "ConsoleLog", +} diff --git a/packages/babylon-lite/src/flow-graph/blocks/animation/play-animation.ts b/packages/babylon-lite/src/flow-graph/blocks/animation/play-animation.ts new file mode 100644 index 0000000000..7973fdb797 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/animation/play-animation.ts @@ -0,0 +1,81 @@ +// PlayAnimation (BJS FlowGraphPlayAnimationBlock, glTF op `animation/start`). +// Async execution block: starts an AnimationGroup (resolved by glTF animation +// index from `env.animations`), fires `out` immediately so sync flow continues, +// and fires `done` when the animation ends. +// +// LITE DIVERGENCE: BJS wires animation/start to [PlayAnimation, ArrayIndex, +// GLTFDataProvider]. Lite pre-resolves the animation array in the loader and +// drives playback through scene-owned `env.caps` (no scene reference in the +// block). `from`/`to` frame-range playback is a Phase 3 refinement; Phase 2 +// honors `speed` + `loop`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import type { AnimationGroup } from "../../../animation/animation-group.js"; +import { activateSignal, addPending, getDataValue } from "../../runtime.js"; +import { sigIn, sigOut, sockIn } from "../../sockets.js"; + +export const playAnimationDef: FgBlockDef = { + type: FgBlockType.PlayAnimation, + build: () => ({ + dataIn: [ + sockIn("animation", FgType.Integer), + sockIn("speed", FgType.Number, 1), + sockIn("loop", FgType.Boolean, false), + sockIn("from", FgType.Number, 0), + sockIn("to", FgType.Number), + ], + signalIn: [sigIn("in")], + signalOut: [sigOut("out"), sigOut("done"), sigOut("error")], + }), + execute(block, ctx, env) { + const index = toIndex(getDataValue(ctx, env, block, "animation")); + const group = index === undefined ? undefined : env.animations[index]; + if (!group || !env.caps.playAnimation) { + activateSignal(ctx, env, block, "error"); + return; + } + const speed = getDataValue(ctx, env, block, "speed") as number; + const loop = getDataValue(ctx, env, block, "loop") as boolean; + env.caps.playAnimation(group, { speed, loop }); + activateSignal(ctx, env, block, "out"); + + const task = addPending(ctx, block, { group }); + if (env.caps.onAnimationEnd) { + task.state.unsub = env.caps.onAnimationEnd(group, () => { + task.done = true; + activateSignal(ctx, env, block, "done"); + }); + } + }, + onTick(block, ctx, env, _deltaMs, task) { + // Fallback completion when no onAnimationEnd capability is wired: a + // non-looping group that stopped playing is finished. + if (task.state.unsub) { + return; + } + const group = task.state.group as AnimationGroup | undefined; + if (group && !group.isPlaying) { + task.done = true; + activateSignal(ctx, env, block, "done"); + } + }, + cancelPending(block, ctx) { + for (const task of ctx.pending) { + if (task.blockId === block.id) { + (task.state.unsub as (() => void) | undefined)?.(); + } + } + }, +}; + +function toIndex(value: unknown): number | undefined { + if (typeof value === "number") { + return value | 0; + } + if (typeof value === "object" && value !== null && "value" in value) { + return (value as { value: number }).value | 0; + } + return undefined; +} diff --git a/packages/babylon-lite/src/flow-graph/blocks/animation/stop-animation.ts b/packages/babylon-lite/src/flow-graph/blocks/animation/stop-animation.ts new file mode 100644 index 0000000000..7dee13ed51 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/animation/stop-animation.ts @@ -0,0 +1,82 @@ +// StopAnimation (BJS FlowGraphStopAnimationBlock, glTF ops `animation/stop` and +// `animation/stopAt`). Execution block: stops an AnimationGroup resolved by glTF +// animation index, then fires `out` (or `error` when the index/capability is +// missing). +// +// - `animation/stop`: stops immediately (resets to frame 0 via `stopAnimation`). +// - `animation/stopAt`: an optional `stopAtFrame` input defers the stop — the +// block fires `out` at once, then polls each tick and halts the group at the +// requested frame (`stopAnimationAt`, which poses the target at that frame). +// +// LITE DIVERGENCE: BJS expands `animation/stopAt` to [StopAnimation, ArrayIndex, +// GLTFDataProvider]; Lite pre-resolves the animation array in the loader and +// drives playback through scene-owned `env.caps`. The deferred monitor handles +// forward playback (the common case) and also completes if the group stops on +// its own. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import type { AnimationGroup } from "../../../animation/animation-group.js"; +import { activateSignal, addPending, getDataValue } from "../../runtime.js"; +import { sigIn, sigOut, sockIn } from "../../sockets.js"; + +const DEFAULT_FRAME_RATE = 60; + +export const stopAnimationDef: FgBlockDef = { + type: FgBlockType.StopAnimation, + build: () => ({ + dataIn: [sockIn("animation", FgType.Integer), sockIn("stopAtFrame", FgType.Number)], + signalIn: [sigIn("in")], + signalOut: [sigOut("out"), sigOut("error")], + }), + execute(block, ctx, env) { + const index = toIndex(getDataValue(ctx, env, block, "animation")); + const group = index === undefined ? undefined : env.animations[index]; + if (!group || !env.caps.stopAnimation) { + activateSignal(ctx, env, block, "error"); + return; + } + + // Deferred stop only when a `stopAtFrame` input is actually wired/provided + // (an unwired socket reads as 0, which must NOT be treated as "stop at 0"). + const frameSocket = block.dataIn.find((s) => s.name === "stopAtFrame"); + const hasStopAt = !!(frameSocket && (frameSocket.source || frameSocket.defaultValue !== undefined)) && !!env.caps.stopAnimationAt; + + if (!hasStopAt) { + env.caps.stopAnimation(group); + activateSignal(ctx, env, block, "out"); + return; + } + + const stopAtFrame = getDataValue(ctx, env, block, "stopAtFrame") as number; + activateSignal(ctx, env, block, "out"); + addPending(ctx, block, { group, stopAtFrame }); + }, + onTick(_block, _ctx, env, _deltaMs, task) { + const group = task.state.group as AnimationGroup | undefined; + if (!group) { + task.done = true; + return; + } + const stopAtFrame = task.state.stopAtFrame as number; + const currentFrame = group.currentTime * (group.frameRate || DEFAULT_FRAME_RATE); + if (currentFrame >= stopAtFrame) { + env.caps.stopAnimationAt?.(group, stopAtFrame); + task.done = true; + } else if (!group.isPlaying) { + // Group ended before reaching the target frame — nothing left to stop. + task.done = true; + } + }, +}; + +function toIndex(value: unknown): number | undefined { + if (typeof value === "number") { + return value | 0; + } + if (typeof value === "object" && value !== null && "value" in value) { + return (value as { value: number }).value | 0; + } + return undefined; +} diff --git a/packages/babylon-lite/src/flow-graph/blocks/animation/value-interpolation.ts b/packages/babylon-lite/src/flow-graph/blocks/animation/value-interpolation.ts new file mode 100644 index 0000000000..adf18d6dd1 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/animation/value-interpolation.ts @@ -0,0 +1,219 @@ +// ValueInterpolation (BJS FlowGraphInterpolationBlock + PlayAnimation chain, +// glTF ops `variable/interpolate` / `pointer/interpolate`). +// +// Self-contained async execution block: when `in` fires, it snapshots the +// start and end values, fires `out` immediately, then interpolates the value +// over `duration` seconds via `onTick`, writing the current result to the +// `value` data output. Fires `done` once when elapsed time reaches the duration. +// A new `in` signal cancels any in-progress interpolation on this block. +// +// Supported interpolation per animation type: +// Float / Integer: scalar linear lerp. +// Vector2/3/4, Color3/4: component-wise linear lerp. +// Quaternion: spherical linear interpolation (slerp), matching BJS `useSlerp`. +// Matrix: instant snap to endValue (no interpolation defined in BJS). +// +// Config: +// `type` — optional FgType string (e.g. "Vector3") for type-aware lerp. +// If omitted the type is inferred from the startValue at runtime. +// `useSlerp` — optional boolean; when true, treats values as quaternions and +// uses slerp regardless of the `type` field. +// +// glTF: `variable/interpolate` maps `value` → `endValue`, `duration` → +// `duration`. `pointer/interpolate` adds an accessor config. Easing control +// points (`p1`/`p2`) and bezier curves are deferred (linear only for now). + +import type { FgBlockDef } from "../../block-def.js"; +import type { FgPendingTask } from "../../context.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import type { FgValue, Vec2 } from "../../types.js"; +import type { Color3, Color4, Quat, Vec3, Vec4 } from "../../../math/types.js"; +import { FgAnimationValueType, animationTypeForFgType } from "../../rich-type.js"; +import { activateSignal, addPending, cancelPendingForBlock, getDataValue, setDataValue, setExecVar } from "../../runtime.js"; +import { sigIn, sigOut, sockIn, sockOut } from "../../sockets.js"; + +// ─── Interpolation math ─────────────────────────────────────────────────────── + +/** Spherical linear interpolation for quaternions (BJS-compatible). */ +function slerpQuat(a: Quat, b: Quat, t: number): Quat { + let dot = a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w; + let bx = b.x, + by = b.y, + bz = b.z, + bw = b.w; + // Take the shortest arc — negate b if dot is negative. + if (dot < 0) { + dot = -dot; + bx = -bx; + by = -by; + bz = -bz; + bw = -bw; + } + let s0: number; + let s1: number; + if (dot > 0.9995) { + // Numerically safe fallback: normalised linear interpolation. + s0 = 1 - t; + s1 = t; + } else { + const theta = Math.acos(dot); + const sinTheta = Math.sin(theta); + s0 = Math.sin((1 - t) * theta) / sinTheta; + s1 = Math.sin(t * theta) / sinTheta; + } + return { x: s0 * a.x + s1 * bx, y: s0 * a.y + s1 * by, z: s0 * a.z + s1 * bz, w: s0 * a.w + s1 * bw }; +} + +/** Type-aware linear/spherical interpolation between two FgValues. */ +function lerpFgValue(a: FgValue, b: FgValue, t: number, animType: FgAnimationValueType): FgValue { + switch (animType) { + case FgAnimationValueType.Float: { + const na = typeof a === "number" ? a : 0; + const nb = typeof b === "number" ? b : 0; + return na + t * (nb - na); + } + case FgAnimationValueType.Vector2: { + const va = a as Vec2; + const vb = b as Vec2; + return { x: va.x + t * (vb.x - va.x), y: va.y + t * (vb.y - va.y) }; + } + case FgAnimationValueType.Vector3: { + const va = a as Vec3; + const vb = b as Vec3; + return { x: va.x + t * (vb.x - va.x), y: va.y + t * (vb.y - va.y), z: va.z + t * (vb.z - va.z) }; + } + case FgAnimationValueType.Vector4: { + const va = a as Vec4; + const vb = b as Vec4; + return { x: va.x + t * (vb.x - va.x), y: va.y + t * (vb.y - va.y), z: va.z + t * (vb.z - va.z), w: va.w + t * (vb.w - va.w) }; + } + case FgAnimationValueType.Quaternion: + return slerpQuat(a as Quat, b as Quat, t); + case FgAnimationValueType.Color3: { + const ca = a as Color3; + const cb = b as Color3; + return { r: ca.r + t * (cb.r - ca.r), g: ca.g + t * (cb.g - ca.g), b: ca.b + t * (cb.b - ca.b) }; + } + case FgAnimationValueType.Color4: { + const ca = a as Color4; + const cb = b as Color4; + return { r: ca.r + t * (cb.r - ca.r), g: ca.g + t * (cb.g - ca.g), b: ca.b + t * (cb.b - ca.b), a: ca.a + t * (cb.a - ca.a) }; + } + default: + // Matrix types: snap to end (no interpolation defined in BJS for matrices). + return b; + } +} + +/** Derive the `FgAnimationValueType` from config and/or the runtime start value. */ +function resolveAnimType(startValue: FgValue, config: Readonly> | undefined): FgAnimationValueType { + if (config?.useSlerp) { + return FgAnimationValueType.Quaternion; + } + const configType = config?.type as string | undefined; + if (configType) { + return animationTypeForFgType(configType as FgType); + } + // Infer from value shape — covers the common test/unit cases. + if (typeof startValue === "number" || startValue === null || startValue === undefined) { + return FgAnimationValueType.Float; + } + if (typeof startValue === "object") { + if ("r" in startValue && "a" in startValue) { + return FgAnimationValueType.Color4; + } + if ("r" in startValue) { + return FgAnimationValueType.Color3; + } + // Vec4 and Quat both carry `w`; without an explicit `type`/`useSlerp` in + // config, prefer Vec4 (linear). Set `config.useSlerp` or `config.type = + // "Quaternion"` to force slerp. + if ("w" in startValue) { + return FgAnimationValueType.Vector4; + } + if ("z" in startValue) { + return FgAnimationValueType.Vector3; + } + if ("y" in startValue) { + return FgAnimationValueType.Vector2; + } + } + return FgAnimationValueType.Float; +} + +// ─── Block definition ───────────────────────────────────────────────────────── + +export const valueInterpolationDef: FgBlockDef = { + type: FgBlockType.ValueInterpolation, + build: () => ({ + dataIn: [sockIn("startValue", FgType.Any), sockIn("endValue", FgType.Any), sockIn("duration", FgType.Number, 0)], + dataOut: [sockOut("value", FgType.Any)], + signalIn: [sigIn("in")], + signalOut: [sigOut("out"), sigOut("done"), sigOut("error")], + }), + updateOutputs(block, ctx) { + // Expose whatever the most recent onTick (or execute) wrote. + const cv = ctx.executionVariables[`${block.id}:currentValue`]; + if (cv !== undefined) { + setDataValue(ctx, block, "value", cv as FgValue); + } + }, + execute(block, ctx, env, incomingSignal) { + if (incomingSignal !== "in" && incomingSignal !== undefined) { + return; + } + + const duration = getDataValue(ctx, env, block, "duration") as number; + if (!isFinite(duration) || isNaN(duration) || duration < 0) { + activateSignal(ctx, env, block, "error"); + return; + } + + // Cancel any prior interpolation on this block. + cancelPendingForBlock(ctx, block); + + const startValue = getDataValue(ctx, env, block, "startValue"); + const endValue = getDataValue(ctx, env, block, "endValue"); + const animType = resolveAnimType(startValue, block.config); + + // Seed the data output with the start value immediately. + setExecVar(ctx, block, "currentValue", startValue); + setDataValue(ctx, block, "value", startValue); + + if (duration <= 0) { + // Zero-duration: snap straight to end, fire done synchronously. + setExecVar(ctx, block, "currentValue", endValue); + setDataValue(ctx, block, "value", endValue); + activateSignal(ctx, env, block, "out"); + activateSignal(ctx, env, block, "done"); + return; + } + + addPending(ctx, block, { startValue, endValue, duration, elapsed: 0, animType }); + activateSignal(ctx, env, block, "out"); + }, + onTick(block, ctx, env, deltaMs, task: FgPendingTask) { + const elapsed = (task.state.elapsed as number) + deltaMs / 1000; + const duration = task.state.duration as number; + const startValue = task.state.startValue as FgValue; + const endValue = task.state.endValue as FgValue; + const animType = task.state.animType as FgAnimationValueType; + + if (elapsed >= duration) { + task.done = true; + setExecVar(ctx, block, "currentValue", endValue); + setDataValue(ctx, block, "value", endValue); + activateSignal(ctx, env, block, "done"); + } else { + task.state.elapsed = elapsed; + const t = elapsed / duration; + const current = lerpFgValue(startValue, endValue, t, animType); + setExecVar(ctx, block, "currentValue", current); + setDataValue(ctx, block, "value", current); + } + }, + cancelPending(_block, _ctx, _env) { + // No external resources (file handles, subscriptions) to release. + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/control-flow/branch.ts b/packages/babylon-lite/src/flow-graph/blocks/control-flow/branch.ts new file mode 100644 index 0000000000..bb296d6dc4 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/control-flow/branch.ts @@ -0,0 +1,25 @@ +// Branch (BJS FlowGraphBranchBlock, glTF op `flow/branch`). +// Execution block: routes the incoming signal to `onTrue` or `onFalse` based on +// the boolean `condition` data input. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { activateSignal, getDataValue } from "../../runtime.js"; +import { sigIn, sigOut, sockIn } from "../../sockets.js"; + +export const branchDef: FgBlockDef = { + type: FgBlockType.Branch, + build: () => ({ + dataIn: [sockIn("condition", FgType.Boolean, false)], + signalIn: [sigIn("in")], + signalOut: [sigOut("onTrue"), sigOut("onFalse")], + }), + execute(block, ctx, env) { + if (getDataValue(ctx, env, block, "condition")) { + activateSignal(ctx, env, block, "onTrue"); + } else { + activateSignal(ctx, env, block, "onFalse"); + } + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/control-flow/cancel-delay.ts b/packages/babylon-lite/src/flow-graph/blocks/control-flow/cancel-delay.ts new file mode 100644 index 0000000000..e9c6c6e857 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/control-flow/cancel-delay.ts @@ -0,0 +1,37 @@ +// CancelDelay (BJS FlowGraphCancelDelayBlock, glTF op `flow/cancelDelay`). +// Cancels a previously scheduled delay by its index (from SetDelay's +// `lastDelayIndex` output). Looks up the task via the global registry +// (`ctx.executionVariables["__delay_"]`) populated by SetDelay. +// Fires `out` after cancellation (whether or not a matching delay was found). + +import type { FgBlockDef } from "../../block-def.js"; +import type { FgPendingTask } from "../../context.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { activateSignal, getDataValue } from "../../runtime.js"; +import { sigIn, sigOut, sockIn } from "../../sockets.js"; +import { isFgInt } from "../../custom-types/fg-integer.js"; + +export const cancelDelayDef: FgBlockDef = { + type: FgBlockType.CancelDelay, + build: () => ({ + dataIn: [sockIn("delayIndex", FgType.Integer)], + signalIn: [sigIn("in")], + signalOut: [sigOut("out")], + }), + execute(block, ctx, env) { + const raw = getDataValue(ctx, env, block, "delayIndex"); + const idx: number = isFgInt(raw) ? raw.value : (raw as number) | 0; + if (isNaN(idx) || !isFinite(idx) || idx < 0) { + activateSignal(ctx, env, block, "out"); + return; + } + const key = `__delay_${idx}`; + const task = ctx.executionVariables[key] as FgPendingTask | undefined; + if (task) { + task.canceled = true; + delete ctx.executionVariables[key]; + } + activateSignal(ctx, env, block, "out"); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/control-flow/do-n.ts b/packages/babylon-lite/src/flow-graph/blocks/control-flow/do-n.ts new file mode 100644 index 0000000000..6a3e4092bd --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/control-flow/do-n.ts @@ -0,0 +1,48 @@ +// DoN (BJS FlowGraphDoNBlock, glTF op `flow/doN`). +// Fires `out` for the first N activations (where N = `maxExecutions` data input). +// Tracks count in `executionCount` data output. The `reset` signal resets the +// counter to 0 (or config.startIndex, default 0). After N executions the block +// is silent until reset. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { activateSignal, getDataValue, getExecVar, setDataValue, setExecVar } from "../../runtime.js"; +import { sigIn, sigOut, sockIn, sockOut } from "../../sockets.js"; +import { fgInt, isFgInt } from "../../custom-types/fg-integer.js"; + +function toInt(v: unknown): number { + if (isFgInt(v)) { + return v.value; + } + return (v as number) | 0; +} + +export const doNDef: FgBlockDef = { + type: FgBlockType.DoN, + build: () => ({ + dataIn: [sockIn("maxExecutions", FgType.Integer, fgInt(0))], + dataOut: [sockOut("executionCount", FgType.Integer)], + signalIn: [sigIn("in"), sigIn("reset")], + signalOut: [sigOut("out")], + }), + updateOutputs(block, ctx) { + setDataValue(ctx, block, "executionCount", fgInt(getExecVar(ctx, block, "count", 0))); + }, + execute(block, ctx, env, incomingSignal) { + if (incomingSignal === "reset") { + const startIdx = toInt(block.config?.startIndex ?? 0); + setExecVar(ctx, block, "count", startIdx); + setDataValue(ctx, block, "executionCount", fgInt(startIdx)); + return; + } + const max = toInt(getDataValue(ctx, env, block, "maxExecutions")); + const count = getExecVar(ctx, block, "count", 0); + if (count < max) { + const next = count + 1; + setExecVar(ctx, block, "count", next); + setDataValue(ctx, block, "executionCount", fgInt(next)); + activateSignal(ctx, env, block, "out"); + } + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/control-flow/for-loop.ts b/packages/babylon-lite/src/flow-graph/blocks/control-flow/for-loop.ts new file mode 100644 index 0000000000..b833cbadfb --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/control-flow/for-loop.ts @@ -0,0 +1,63 @@ +// ForLoop (BJS FlowGraphForLoopBlock, glTF op `flow/for`). +// Synchronous loop: fires `executionFlow` for each integer from `startIndex` up +// to (but NOT including) `endIndex` with stride `step` (default 1). Fires +// `completed` when done. Caps at 1000 iterations to guard against infinite loops. +// BJS semantics: `for (i = startIndex; i < endIndex; i += step)`. +// `index` data output is updated each iteration (available to wired consumers). +// config.incrementIndexWhenLoopDone (default false): when true, `index` is +// advanced one extra step after the loop — this is always set by the glTF mapper. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { activateSignal, getDataValue, setDataValue, setExecVar } from "../../runtime.js"; +import { sigIn, sigOut, sockIn, sockOut } from "../../sockets.js"; +import { fgInt, isFgInt } from "../../custom-types/fg-integer.js"; + +const MAX_ITERATIONS = 1000; + +function toNum(v: unknown): number { + if (isFgInt(v)) { + return v.value; + } + return (v as number) ?? 0; +} + +export const forLoopDef: FgBlockDef = { + type: FgBlockType.ForLoop, + build: () => ({ + dataIn: [sockIn("startIndex", FgType.Any, 0), sockIn("endIndex", FgType.Any, 0), sockIn("step", FgType.Number, 1)], + dataOut: [sockOut("index", FgType.Integer)], + signalIn: [sigIn("in")], + signalOut: [sigOut("executionFlow"), sigOut("completed")], + }), + updateOutputs(block, ctx) { + // Expose the current index (kept in execVar between iterations). + const i = (ctx.executionVariables[`${block.id}:index`] as number) ?? 0; + setDataValue(ctx, block, "index", fgInt(i)); + }, + execute(block, ctx, env) { + const start = toNum(getDataValue(ctx, env, block, "startIndex")); + const step = toNum(getDataValue(ctx, env, block, "step")) || 1; + let end = toNum(getDataValue(ctx, env, block, "endIndex")); + + for (let i = start; i < end; i += step) { + setExecVar(ctx, block, "index", i); + setDataValue(ctx, block, "index", fgInt(i)); + activateSignal(ctx, env, block, "executionFlow"); + // Re-read endIndex each iteration (body may modify it). + end = toNum(getDataValue(ctx, env, block, "endIndex")); + if (i > MAX_ITERATIONS * step) { + break; + } + } + + if (block.config?.incrementIndexWhenLoopDone) { + const cur = toNum(getDataValue(ctx, env, block, "endIndex")); + setExecVar(ctx, block, "index", cur); + setDataValue(ctx, block, "index", fgInt(cur)); + } + + activateSignal(ctx, env, block, "completed"); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/control-flow/multi-gate.ts b/packages/babylon-lite/src/flow-graph/blocks/control-flow/multi-gate.ts new file mode 100644 index 0000000000..f0167242de --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/control-flow/multi-gate.ts @@ -0,0 +1,77 @@ +// MultiGate (BJS FlowGraphMultiGateBlock, glTF op `flow/multiGate`). +// Routes each activation to the NEXT unused output signal (`out_0`, `out_1`, …). +// config.isRandom (default false): pick a random unused output each time. +// config.isLoop (default false): wrap back to start when all outputs are used. +// config.outputSignalCount: number of output signals (required). +// `reset` signal clears state (all outputs unused, lastIndex = -1). +// `lastIndex` data output tracks the last activated output index. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { activateSignal, getExecVar, setDataValue, setExecVar } from "../../runtime.js"; +import { sigIn, sigOut, sockOut } from "../../sockets.js"; +import { fgInt } from "../../custom-types/fg-integer.js"; + +function nextIndex(used: boolean[], isRandom: boolean, isLoop: boolean): number { + const allUsed = !used.includes(false); + if (allUsed) { + if (!isLoop) { + return -1; + } + used.fill(false); + } + if (!isRandom) { + return used.indexOf(false); + } + const unused = used.map((u, i) => (u ? -1 : i)).filter((i) => i >= 0); + if (!unused.length) { + return -1; + } + return unused[Math.floor(Math.random() * unused.length)]!; +} + +export const multiGateDef: FgBlockDef = { + type: FgBlockType.MultiGate, + build: (config) => { + const count = Math.max(1, (config?.outputSignalCount as number) ?? 1); + const signalOut: ReturnType[] = []; + for (let i = 0; i < count; i++) { + signalOut.push(sigOut(`out_${i}`)); + } + return { + dataOut: [sockOut("lastIndex", FgType.Integer)], + signalIn: [sigIn("in"), sigIn("reset")], + signalOut, + }; + }, + updateOutputs(block, ctx) { + setDataValue(ctx, block, "lastIndex", fgInt(getExecVar(ctx, block, "lastIndex", -1))); + }, + execute(block, ctx, env, incomingSignal) { + const count = block.signalOut.length; + const isRandom = !!(block.config?.isRandom as boolean | undefined); + const isLoop = !!(block.config?.isLoop as boolean | undefined); + + if (incomingSignal === "reset") { + setExecVar(ctx, block, "used", new Array(count).fill(false) as boolean[]); + setExecVar(ctx, block, "lastIndex", -1); + setDataValue(ctx, block, "lastIndex", fgInt(-1)); + return; + } + + let used = getExecVar(ctx, block, "used", undefined); + if (!used) { + used = new Array(count).fill(false) as boolean[]; + } + + const idx = nextIndex(used, isRandom, isLoop); + if (idx >= 0) { + used[idx] = true; + setExecVar(ctx, block, "used", used); + setExecVar(ctx, block, "lastIndex", idx); + setDataValue(ctx, block, "lastIndex", fgInt(idx)); + activateSignal(ctx, env, block, `out_${idx}`); + } + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/control-flow/sequence.ts b/packages/babylon-lite/src/flow-graph/blocks/control-flow/sequence.ts new file mode 100644 index 0000000000..153d18e776 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/control-flow/sequence.ts @@ -0,0 +1,26 @@ +// Sequence (BJS FlowGraphSequenceBlock, glTF op `flow/sequence`). +// Execution block: fires its N output flows `out_0`, `out_1`, … in order. The +// count comes from `config.outputSignalCount` (BJS) — the glTF mapper derives it +// from the node's declared output flow sockets. Defaults to 1. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { activateSignal } from "../../runtime.js"; +import { sigIn, sigOut } from "../../sockets.js"; + +export const sequenceDef: FgBlockDef = { + type: FgBlockType.Sequence, + build: (config) => { + const count = Math.max(1, (config?.outputSignalCount as number) ?? 1); + const signalOut = []; + for (let i = 0; i < count; i++) { + signalOut.push(sigOut(`out_${i}`)); + } + return { signalIn: [sigIn("in")], signalOut }; + }, + execute(block, ctx, env) { + for (const sig of block.signalOut) { + activateSignal(ctx, env, block, sig.name); + } + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/control-flow/set-delay.ts b/packages/babylon-lite/src/flow-graph/blocks/control-flow/set-delay.ts new file mode 100644 index 0000000000..ec9c912df9 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/control-flow/set-delay.ts @@ -0,0 +1,91 @@ +// SetDelay (BJS FlowGraphSetDelayBlock, glTF op `flow/setDelay`). +// Starts an async countdown of `duration` seconds, fires `out` immediately so +// sync flow continues, then fires `done` when the delay expires. Multiple +// concurrent delays are supported; each gets a unique index. +// `cancel` input cancels ALL pending delays on this block (matches BJS behaviour +// when called with its own `lastDelayIndex`). +// `lastDelayIndex` data output: the index of the most recently registered delay. +// +// CancelDelay coordination: each task's index is stored at +// `ctx.executionVariables["__delay_"] = task` so CancelDelay can find +// and cancel it by index without a direct block reference. + +import type { FgBlockDef } from "../../block-def.js"; +import type { FgPendingTask } from "../../context.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { activateSignal, addPending, cancelPendingForBlock, getDataValue, getExecVar, setDataValue, setExecVar } from "../../runtime.js"; +import { sigIn, sigOut, sockIn, sockOut } from "../../sockets.js"; +import { fgInt } from "../../custom-types/fg-integer.js"; + +/** Global key for the monotonically-increasing delay sequence counter. */ +const DELAY_SEQ_KEY = "__delaySeq"; +/** Per-index key prefix where the live task object is stored. */ +const DELAY_KEY = (idx: number) => `__delay_${idx}`; + +export const setDelayDef: FgBlockDef = { + type: FgBlockType.SetDelay, + build: () => ({ + dataIn: [sockIn("duration", FgType.Number, 0)], + dataOut: [sockOut("lastDelayIndex", FgType.Integer)], + signalIn: [sigIn("in"), sigIn("cancel")], + signalOut: [sigOut("out"), sigOut("done"), sigOut("error")], + }), + updateOutputs(block, ctx) { + setDataValue(ctx, block, "lastDelayIndex", fgInt(getExecVar(ctx, block, "lastDelayIndex", -1))); + }, + execute(block, ctx, env, incomingSignal) { + if (incomingSignal === "cancel") { + // Cancel all pending delays for this block and clean up global registry. + for (const task of ctx.pending) { + if (task.blockId === block.id && !task.canceled && !task.done) { + const idx = task.state.delayIndex as number; + delete ctx.executionVariables[DELAY_KEY(idx)]; + } + } + cancelPendingForBlock(ctx, block); + return; + } + + const duration = getDataValue(ctx, env, block, "duration") as number; + if (!isFinite(duration) || isNaN(duration) || duration < 0) { + activateSignal(ctx, env, block, "error"); + return; + } + + // Allocate the next global delay index. + const seq = ((ctx.executionVariables[DELAY_SEQ_KEY] as number | undefined) ?? -1) + 1; + ctx.executionVariables[DELAY_SEQ_KEY] = seq; + + setExecVar(ctx, block, "lastDelayIndex", seq); + setDataValue(ctx, block, "lastDelayIndex", fgInt(seq)); + + const task: FgPendingTask = addPending(ctx, block, { remainingMs: duration * 1000, delayIndex: seq }); + // Register in the global registry so CancelDelay can look it up. + ctx.executionVariables[DELAY_KEY(seq)] = task; + + activateSignal(ctx, env, block, "out"); + }, + onTick(block, ctx, env, deltaMs, task) { + const remaining = (task.state.remainingMs as number) - deltaMs; + if (remaining <= 0) { + task.done = true; + const idx = task.state.delayIndex as number; + delete ctx.executionVariables[DELAY_KEY(idx)]; + activateSignal(ctx, env, block, "done"); + } else { + task.state.remainingMs = remaining; + } + }, + cancelPending(_block, ctx) { + // Clean up any leftover global registry entries for canceled tasks. + for (const key of Object.keys(ctx.executionVariables)) { + if (key.startsWith("__delay_")) { + const task = ctx.executionVariables[key] as FgPendingTask | undefined; + if (task?.canceled) { + delete ctx.executionVariables[key]; + } + } + } + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/control-flow/switch.ts b/packages/babylon-lite/src/flow-graph/blocks/control-flow/switch.ts new file mode 100644 index 0000000000..82daa3bee4 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/control-flow/switch.ts @@ -0,0 +1,33 @@ +// Flow Switch (BJS FlowGraphSwitchBlock, glTF op `flow/switch`). +// Routes the incoming signal to `out_` if the numeric case value is in +// `config.cases`, or to `default` otherwise. Cases are integers. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { activateSignal, getDataValue } from "../../runtime.js"; +import { sigIn, sigOut, sockIn } from "../../sockets.js"; +import { isFgInt } from "../../custom-types/fg-integer.js"; + +export const switchDef: FgBlockDef = { + type: FgBlockType.Switch, + build: (config) => { + const cases = (config?.cases as number[] | undefined) ?? []; + const signalOut = [sigOut("default")]; + for (const c of cases) { + signalOut.push(sigOut(`out_${c | 0}`)); + } + return { + dataIn: [sockIn("case", FgType.Any)], + signalIn: [sigIn("in")], + signalOut, + }; + }, + execute(block, ctx, env) { + const cases = (block.config?.cases as number[] | undefined) ?? []; + const raw = getDataValue(ctx, env, block, "case"); + const key: number = isFgInt(raw) ? raw.value : (raw as number) | 0; + const matched = cases.some((c) => (c | 0) === key); + activateSignal(ctx, env, block, matched ? `out_${key}` : "default"); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/control-flow/throttle.ts b/packages/babylon-lite/src/flow-graph/blocks/control-flow/throttle.ts new file mode 100644 index 0000000000..ce3259be28 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/control-flow/throttle.ts @@ -0,0 +1,71 @@ +// Throttle (BJS FlowGraphThrottleBlock, glTF op `flow/throttle`). +// Passes the first activation through and suppresses further activations until +// `duration` seconds have elapsed since the last pass-through. `reset` clears +// the cooldown immediately. +// +// LITE DIVERGENCE: BJS uses wall-clock (`Date.now()`) for timing. Lite uses a +// tick-driven countdown via `addPending`/`onTick` so tests can drive it with +// `tickFlowGraph` instead of real-time. Semantics are identical for properly +// ticked scenes. +// +// `lastRemainingTime` data output: 0 when passing, remaining seconds when blocked. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { activateSignal, addPending, cancelPendingForBlock, getDataValue, getExecVar, setDataValue, setExecVar } from "../../runtime.js"; +import { sigIn, sigOut, sockIn, sockOut } from "../../sockets.js"; + +export const throttleDef: FgBlockDef = { + type: FgBlockType.Throttle, + build: () => ({ + dataIn: [sockIn("duration", FgType.Number, 0)], + dataOut: [sockOut("lastRemainingTime", FgType.Number)], + signalIn: [sigIn("in"), sigIn("reset")], + signalOut: [sigOut("out"), sigOut("error")], + }), + updateOutputs(block, ctx) { + setDataValue(ctx, block, "lastRemainingTime", getExecVar(ctx, block, "lastRemainingTime", NaN)); + }, + execute(block, ctx, env, incomingSignal) { + if (incomingSignal === "reset") { + cancelPendingForBlock(ctx, block); + setExecVar(ctx, block, "cooldownMs", 0); + setExecVar(ctx, block, "lastRemainingTime", NaN); + setDataValue(ctx, block, "lastRemainingTime", NaN); + return; + } + + const duration = getDataValue(ctx, env, block, "duration") as number; + if (!isFinite(duration) || isNaN(duration) || duration <= 0) { + activateSignal(ctx, env, block, "error"); + return; + } + + const cooldown = getExecVar(ctx, block, "cooldownMs", 0); + if (cooldown <= 0) { + // Ready to fire: reset cooldown and start countdown task. + setExecVar(ctx, block, "cooldownMs", duration * 1000); + setExecVar(ctx, block, "lastRemainingTime", 0); + setDataValue(ctx, block, "lastRemainingTime", 0); + addPending(ctx, block); + activateSignal(ctx, env, block, "out"); + } else { + // Still in cooldown: report remaining time but don't fire. + setExecVar(ctx, block, "lastRemainingTime", cooldown / 1000); + setDataValue(ctx, block, "lastRemainingTime", cooldown / 1000); + } + }, + onTick(block, ctx, _env, deltaMs, task) { + const remaining = getExecVar(ctx, block, "cooldownMs", 0) - deltaMs; + if (remaining <= 0) { + setExecVar(ctx, block, "cooldownMs", 0); + task.done = true; + } else { + setExecVar(ctx, block, "cooldownMs", remaining); + } + }, + cancelPending(block, ctx) { + setExecVar(ctx, block, "cooldownMs", 0); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/control-flow/wait-all.ts b/packages/babylon-lite/src/flow-graph/blocks/control-flow/wait-all.ts new file mode 100644 index 0000000000..7b1d20f390 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/control-flow/wait-all.ts @@ -0,0 +1,68 @@ +// WaitAll (BJS FlowGraphWaitAllBlock, glTF op `flow/waitAll`). +// Waits for ALL N input signals (`in_0` … `in_{N-1}`) to fire before firing +// `completed`. Fires `out` for intermediate non-reset activations. `reset` +// clears all received flags. After `completed` fires the state resets so the +// block can be re-armed. `remainingInputs` data output counts unset slots. +// config.inputSignalCount: number of input flows (required, default 1). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { activateSignal, getExecVar, setDataValue, setExecVar } from "../../runtime.js"; +import { sigIn, sigOut, sockOut } from "../../sockets.js"; +import { fgInt } from "../../custom-types/fg-integer.js"; + +export const waitAllDef: FgBlockDef = { + type: FgBlockType.WaitAll, + build: (config) => { + const n = Math.max(1, (config?.inputSignalCount as number) ?? 1); + const signalIn = [sigIn("reset")]; + for (let i = 0; i < n; i++) { + signalIn.push(sigIn(`in_${i}`)); + } + return { + dataOut: [sockOut("remainingInputs", FgType.Integer)], + signalIn, + signalOut: [sigOut("out"), sigOut("completed")], + }; + }, + updateOutputs(block, ctx) { + const state = getExecVar(ctx, block, "activationState", []); + const remaining = state.length ? state.filter((v) => !v).length : ((block.config?.inputSignalCount as number) ?? 1); + setDataValue(ctx, block, "remainingInputs", fgInt(remaining)); + }, + execute(block, ctx, env, incomingSignal) { + const n = (block.config?.inputSignalCount as number) ?? 1; + let state = getExecVar(ctx, block, "activationState", []); + if (!state.length) { + state = new Array(n).fill(false) as boolean[]; + } + + if (incomingSignal === "reset") { + state.fill(false); + setExecVar(ctx, block, "activationState", state.slice()); + setDataValue(ctx, block, "remainingInputs", fgInt(n)); + return; + } + + // Mark the corresponding slot received (socket name is "in_"). + const idxStr = incomingSignal.replace(/^in_/, ""); + const idx = parseInt(idxStr, 10); + if (!isNaN(idx) && idx >= 0 && idx < n) { + state[idx] = true; + } + + const remaining = state.filter((v) => !v).length; + setDataValue(ctx, block, "remainingInputs", fgInt(remaining)); + setExecVar(ctx, block, "activationState", state.slice()); + + if (remaining === 0) { + // All received — fire completed and reset for next round. + state.fill(false); + setExecVar(ctx, block, "activationState", state.slice()); + activateSignal(ctx, env, block, "completed"); + } else { + activateSignal(ctx, env, block, "out"); + } + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/control-flow/while-loop.ts b/packages/babylon-lite/src/flow-graph/blocks/control-flow/while-loop.ts new file mode 100644 index 0000000000..2d8bd50086 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/control-flow/while-loop.ts @@ -0,0 +1,42 @@ +// WhileLoop (BJS FlowGraphWhileLoopBlock, glTF op `flow/while`). +// Fires `executionFlow` repeatedly while `condition` is truthy. Fires +// `completed` after the condition becomes false. Caps at 1000 iterations. +// BJS config `doWhile` (default false): when true, the body runs once before +// the first condition check (not exposed via glTF). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { activateSignal, getDataValue } from "../../runtime.js"; +import { sigIn, sigOut, sockIn } from "../../sockets.js"; + +const MAX_ITERATIONS = 1000; + +export const whileLoopDef: FgBlockDef = { + type: FgBlockType.WhileLoop, + build: () => ({ + dataIn: [sockIn("condition", FgType.Boolean, false)], + signalIn: [sigIn("in")], + signalOut: [sigOut("executionFlow"), sigOut("completed")], + }), + execute(block, ctx, env) { + const doWhile = !!(block.config?.doWhile as boolean | undefined); + let condition = !!getDataValue(ctx, env, block, "condition"); + + // do-while: run body once unconditionally before the first check. + if (doWhile && !condition) { + activateSignal(ctx, env, block, "executionFlow"); + } + + let i = 0; + while (condition) { + activateSignal(ctx, env, block, "executionFlow"); + if (++i >= MAX_ITERATIONS) { + break; + } + condition = !!getDataValue(ctx, env, block, "condition"); + } + + activateSignal(ctx, env, block, "completed"); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/conversion/boolean-to-float.ts b/packages/babylon-lite/src/flow-graph/blocks/conversion/boolean-to-float.ts new file mode 100644 index 0000000000..a57a16b24a --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/conversion/boolean-to-float.ts @@ -0,0 +1,19 @@ +// BooleanToFloat (BJS FlowGraphBooleanToFloat, glTF op `type/boolToFloat`). +// Data block (PULL): boolean -> number (true->1, false->0). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; + +export const booleanToFloatDef: FgBlockDef = { + type: FgBlockType.BooleanToFloat, + build: () => ({ + dataIn: [sockIn("a", FgType.Boolean, false)], + dataOut: [sockOut("value", FgType.Number)], + }), + updateOutputs(block, ctx, env) { + setDataValue(ctx, block, "value", getDataValue(ctx, env, block, "a") ? 1 : 0); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/conversion/boolean-to-int.ts b/packages/babylon-lite/src/flow-graph/blocks/conversion/boolean-to-int.ts new file mode 100644 index 0000000000..ad508eb4c8 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/conversion/boolean-to-int.ts @@ -0,0 +1,20 @@ +// BooleanToInt (BJS FlowGraphBooleanToInt, glTF op `type/boolToInt`). +// Data block (PULL): boolean -> FlowGraphInteger (true->1, false->0). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgInt } from "../../custom-types/fg-integer.js"; + +export const booleanToIntDef: FgBlockDef = { + type: FgBlockType.BooleanToInt, + build: () => ({ + dataIn: [sockIn("a", FgType.Boolean, false)], + dataOut: [sockOut("value", FgType.Integer)], + }), + updateOutputs(block, ctx, env) { + setDataValue(ctx, block, "value", fgInt(getDataValue(ctx, env, block, "a") ? 1 : 0)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/conversion/float-to-boolean.ts b/packages/babylon-lite/src/flow-graph/blocks/conversion/float-to-boolean.ts new file mode 100644 index 0000000000..482a159377 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/conversion/float-to-boolean.ts @@ -0,0 +1,19 @@ +// FloatToBoolean (BJS FlowGraphFloatToBoolean, glTF op `type/floatToBool`). +// Data block (PULL): number -> boolean (0 and NaN -> false, else true). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; + +export const floatToBooleanDef: FgBlockDef = { + type: FgBlockType.FloatToBoolean, + build: () => ({ + dataIn: [sockIn("a", FgType.Number, 0)], + dataOut: [sockOut("value", FgType.Boolean)], + }), + updateOutputs(block, ctx, env) { + setDataValue(ctx, block, "value", !!(getDataValue(ctx, env, block, "a") as number)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/conversion/float-to-int.ts b/packages/babylon-lite/src/flow-graph/blocks/conversion/float-to-int.ts new file mode 100644 index 0000000000..f532d11f95 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/conversion/float-to-int.ts @@ -0,0 +1,24 @@ +// FloatToInt (BJS FlowGraphFloatToInt, glTF op `type/floatToInt`). +// Data block (PULL): number -> FlowGraphInteger. `config.roundingMode` selects +// floor/ceil/round; default truncates toward zero (BJS `value | 0`). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgInt } from "../../custom-types/fg-integer.js"; + +export const floatToIntDef: FgBlockDef = { + type: FgBlockType.FloatToInt, + build: () => ({ + dataIn: [sockIn("a", FgType.Number, 0)], + dataOut: [sockOut("value", FgType.Integer)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a") as number; + const mode = block.config?.roundingMode as string | undefined; + const r = mode === "floor" ? Math.floor(a) : mode === "ceil" ? Math.ceil(a) : mode === "round" ? Math.round(a) : Math.trunc(a); + setDataValue(ctx, block, "value", fgInt(r)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/conversion/int-to-boolean.ts b/packages/babylon-lite/src/flow-graph/blocks/conversion/int-to-boolean.ts new file mode 100644 index 0000000000..00b200e3ef --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/conversion/int-to-boolean.ts @@ -0,0 +1,21 @@ +// IntToBoolean (BJS FlowGraphIntToBoolean, glTF op `type/intToBool`). +// Data block (PULL): FlowGraphInteger -> boolean (0 -> false, else true). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { isFgInt } from "../../custom-types/fg-integer.js"; + +export const intToBooleanDef: FgBlockDef = { + type: FgBlockType.IntToBoolean, + build: () => ({ + dataIn: [sockIn("a", FgType.Integer)], + dataOut: [sockOut("value", FgType.Boolean)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", isFgInt(a) ? a.value !== 0 : !!a); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/conversion/int-to-float.ts b/packages/babylon-lite/src/flow-graph/blocks/conversion/int-to-float.ts new file mode 100644 index 0000000000..bcb5e8831f --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/conversion/int-to-float.ts @@ -0,0 +1,21 @@ +// IntToFloat (BJS FlowGraphIntToFloat, glTF op `type/intToFloat`). +// Data block (PULL): FlowGraphInteger -> number (direct payload). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { isFgInt } from "../../custom-types/fg-integer.js"; + +export const intToFloatDef: FgBlockDef = { + type: FgBlockType.IntToFloat, + build: () => ({ + dataIn: [sockIn("a", FgType.Integer)], + dataOut: [sockOut("value", FgType.Number)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", isFgInt(a) ? a.value : (a as number)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/data/constant.ts b/packages/babylon-lite/src/flow-graph/blocks/data/constant.ts new file mode 100644 index 0000000000..da87a4942a --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/data/constant.ts @@ -0,0 +1,21 @@ +// Constant (BJS FlowGraphConstantBlock, no dedicated glTF op — constant values +// are represented as inline literals in KHR_interactivity assets). Used for +// internal-only graphs and unit tests. +// config.value: the constant value to emit. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import type { FgValue } from "../../types.js"; +import { setDataValue } from "../../runtime.js"; +import { sockOut } from "../../sockets.js"; + +export const constantDef: FgBlockDef = { + type: FgBlockType.Constant, + build: () => ({ + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx) { + setDataValue(ctx, block, "value", (block.config?.value as FgValue) ?? undefined); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/data/get-property.ts b/packages/babylon-lite/src/flow-graph/blocks/data/get-property.ts new file mode 100644 index 0000000000..b0a4a03cd8 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/data/get-property.ts @@ -0,0 +1,27 @@ +// GetProperty (BJS FlowGraphGetPropertyBlock + JsonPointerParser, glTF op +// `pointer/get`). Data block (PULL): emits `value` read through a pre-resolved +// accessor. +// +// LITE DIVERGENCE: BJS resolves the JSON pointer at runtime via a separate +// JsonPointerParser block (object + propertyName + getter/setter). Lite's loader +// pre-resolves the pointer to an `FgAccessor` (get/set closures) at load time +// and stores it in `env.accessors`, keyed by `config.accessor`. So a single +// block reads via the accessor — the parser/path-converter owns pointer +// resolution. See flow-graph/gltf/path-converter.ts. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { setDataValue } from "../../runtime.js"; +import { sockOut } from "../../sockets.js"; + +export const getPropertyDef: FgBlockDef = { + type: FgBlockType.GetProperty, + build: () => ({ + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const accessor = env.accessors[block.config?.accessor as string]; + setDataValue(ctx, block, "value", accessor ? accessor.get() : undefined); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/data/get-variable.ts b/packages/babylon-lite/src/flow-graph/blocks/data/get-variable.ts new file mode 100644 index 0000000000..5acf165eab --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/data/get-variable.ts @@ -0,0 +1,20 @@ +// GetVariable (BJS FlowGraphGetVariableBlock, glTF op `variable/get`). +// Data block (PULL): emits `value` = the live graph variable named by +// `config.variable`, recomputed on every read. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { setDataValue } from "../../runtime.js"; +import { sockOut } from "../../sockets.js"; + +export const getVariableDef: FgBlockDef = { + type: FgBlockType.GetVariable, + build: () => ({ + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx) { + const name = block.config?.variable as string; + setDataValue(ctx, block, "value", ctx.userVariables[name]); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/data/set-property.ts b/packages/babylon-lite/src/flow-graph/blocks/data/set-property.ts new file mode 100644 index 0000000000..6fa4cd0aaa --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/data/set-property.ts @@ -0,0 +1,31 @@ +// SetProperty (BJS FlowGraphSetPropertyBlock + JsonPointerParser, glTF op +// `pointer/set`). Execution block: writes `value` through a pre-resolved +// accessor, then fires `out` (or `error` when the accessor is missing / +// read-only). +// +// LITE DIVERGENCE: see get-property.ts — pointer resolution happens in the +// loader, not at runtime; the block writes via `env.accessors[config.accessor]`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { activateSignal, getDataValue } from "../../runtime.js"; +import { sigIn, sigOut, sockIn } from "../../sockets.js"; + +export const setPropertyDef: FgBlockDef = { + type: FgBlockType.SetProperty, + build: () => ({ + dataIn: [sockIn("value", FgType.Any)], + signalIn: [sigIn("in")], + signalOut: [sigOut("out"), sigOut("error")], + }), + execute(block, ctx, env) { + const accessor = env.accessors[block.config?.accessor as string]; + if (accessor?.set) { + accessor.set(getDataValue(ctx, env, block, "value")); + activateSignal(ctx, env, block, "out"); + } else { + activateSignal(ctx, env, block, "error"); + } + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/data/set-variable.ts b/packages/babylon-lite/src/flow-graph/blocks/data/set-variable.ts new file mode 100644 index 0000000000..fdfb9e0924 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/data/set-variable.ts @@ -0,0 +1,25 @@ +// SetVariable (BJS FlowGraphSetVariableBlock, glTF op `variable/set`). +// Execution block: writes `value` into the live graph variable named by +// `config.variable`, then fires `out`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { activateSignal, getDataValue } from "../../runtime.js"; +import { sigIn, sigOut, sockIn } from "../../sockets.js"; + +export const setVariableDef: FgBlockDef = { + type: FgBlockType.SetVariable, + build: () => ({ + dataIn: [sockIn("value", FgType.Any)], + signalIn: [sigIn("in")], + signalOut: [sigOut("out")], + }), + execute(block, ctx, env) { + const name = block.config?.variable as string; + if (name !== undefined) { + ctx.userVariables[name] = getDataValue(ctx, env, block, "value"); + } + activateSignal(ctx, env, block, "out"); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/debug/console-log.ts b/packages/babylon-lite/src/flow-graph/blocks/debug/console-log.ts new file mode 100644 index 0000000000..be9f5d9d71 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/debug/console-log.ts @@ -0,0 +1,85 @@ +// ConsoleLog (BJS FlowGraphConsoleLogBlock, glTF ops `flow/log` in the BABYLON +// extension and `debug/log` in core). Execution block: logs a value to the +// console, then fires `out`. +// +// Two modes (mirrors BJS): +// - `flow/log`: logs the `message` data input directly (raw value). +// - `debug/log`: `config.messageTemplate` is a string with `{name}` +// placeholders. Each placeholder becomes its own data input; at execution the +// template is rendered by substituting each `{name}` with the serialized value +// of the matching input (or, when `message` is an object, its same-named key). + +import type { FgBlockDef, FgBlockShape } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import type { FgValue } from "../../types.js"; +import { activateSignal, getDataValue } from "../../runtime.js"; +import { sigIn, sigOut, sockIn } from "../../sockets.js"; + +const PLACEHOLDER = /\{([^}]+)\}/g; + +function templateMatches(template: string): string[] { + const matches: string[] = []; + let m: RegExpExecArray | null; + PLACEHOLDER.lastIndex = 0; + while ((m = PLACEHOLDER.exec(template)) !== null) { + matches.push(m[1]!); + } + return matches; +} + +function serializeValue(value: FgValue): string { + if (value === null || value === undefined) { + return String(value); + } + if (typeof value === "object") { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + +export const consoleLogDef: FgBlockDef = { + type: FgBlockType.ConsoleLog, + build: (config) => { + const template = config?.messageTemplate as string | undefined; + const shape: FgBlockShape = { + dataIn: [sockIn("message", FgType.Any)], + signalIn: [sigIn("in")], + signalOut: [sigOut("out")], + }; + if (template) { + for (const name of templateMatches(template)) { + if (name !== "message" && !shape.dataIn!.some((s) => s.name === name)) { + shape.dataIn!.push(sockIn(name, FgType.Any)); + } + } + } + return shape; + }, + execute(block, ctx, env) { + const template = block.config?.messageTemplate as string | undefined; + let message: FgValue; + if (template) { + const messageVal = getDataValue(ctx, env, block, "message"); + const messageObj = messageVal !== null && typeof messageVal === "object" ? (messageVal as unknown as Record) : null; + let rendered = template; + for (const name of templateMatches(template)) { + const value = messageObj && name in messageObj ? messageObj[name] : getDataValue(ctx, env, block, name); + if (value !== undefined) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + rendered = rendered.replace(new RegExp(`\\{${escaped}\\}`, "g"), serializeValue(value)); + } + } + message = rendered; + } else { + message = getDataValue(ctx, env, block, "message"); + } + // eslint-disable-next-line no-console + console.log(message); + activateSignal(ctx, env, block, "out"); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/events/on-select.ts b/packages/babylon-lite/src/flow-graph/blocks/events/on-select.ts new file mode 100644 index 0000000000..d91b29ccb6 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/events/on-select.ts @@ -0,0 +1,43 @@ +// OnSelect (glTF op `event/onSelect`, extension `KHR_node_selectability`). +// Event block: fires when its configured node (`config.nodeIndex`) is picked. +// The picking/input layer pumps the Pointer channel with `{ nodeIndex, +// controllerIndex }`; this block fires `out`/`done` only when the payload's +// node matches its own. With no picking wired (e.g. an onStart-only demo) it +// stays inert. +// +// ⚠️ SPEC-VOLATILE — KHR_node_selectability is an UNRATIFIED draft; re-sync the +// output sockets against BJS PR #18455 when it lands. The Calculator sample +// reads none of onSelect's value outputs, so they are best-effort. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { FgEventType } from "../../event-bus.js"; +import { fgInt } from "../../custom-types/fg-integer.js"; +import { activateSignal, getExecVar, setDataValue } from "../../runtime.js"; +import { sigOut, sockOut } from "../../sockets.js"; + +interface SelectPayload { + nodeIndex?: number; + controllerIndex?: number; +} + +export const onSelectDef: FgBlockDef = { + type: FgBlockType.OnSelect, + build: () => ({ + dataOut: [sockOut("selectedNodeIndex", FgType.Integer), sockOut("controllerIndex", FgType.Integer)], + signalOut: [sigOut("out"), sigOut("done")], + event: FgEventType.Pointer, + }), + execute(block, ctx, env) { + const payload = getExecVar(ctx, block, "lastEvent", undefined); + const target = block.config?.nodeIndex as number | undefined; + if (!payload || payload.nodeIndex !== target) { + return; + } + setDataValue(ctx, block, "selectedNodeIndex", fgInt(payload.nodeIndex ?? -1)); + setDataValue(ctx, block, "controllerIndex", fgInt(payload.controllerIndex ?? 0)); + activateSignal(ctx, env, block, "done"); + activateSignal(ctx, env, block, "out"); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/events/receive-custom-event.ts b/packages/babylon-lite/src/flow-graph/blocks/events/receive-custom-event.ts new file mode 100644 index 0000000000..d2621a2591 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/events/receive-custom-event.ts @@ -0,0 +1,60 @@ +// ReceiveCustomEvent (BJS FlowGraphReceiveCustomEventBlock, glTF op `event/receive`). +// Event block: subscribes to `FgEventType.CustomEvent` on the shared bus. +// The runtime's `startFlowGraph` subscribes it BEFORE Start blocks fire, so a +// Send triggered by SceneStart is guaranteed to reach this receiver. +// +// When the bus fires, `execute` reads the stashed payload, checks that +// `payload.eventName` matches `config.eventId`, writes named data outputs from +// `payload.values`, then fires `out` (and the BJS-compatible `done`). +// Events whose name does not match are silently ignored. +// +// Config: +// `eventId` — string identifier that must match the sender's (required). +// `valueNames` — optional string[] of data-output socket names populated from +// `payload.values`. Derived from the glTF events table (deferred +// wiring — see report). +// +// glTF: `event/receive`. `configuration["event"]` index → `config.eventId` +// (configKeys). Value outputs from the events table are a deferred Phase 3i+ item. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgEventType } from "../../event-bus.js"; +import { FgType } from "../../types.js"; +import type { FgValue } from "../../types.js"; +import { activateSignal, getExecVar, setDataValue } from "../../runtime.js"; +import { sigOut, sockOut } from "../../sockets.js"; + +export const receiveCustomEventDef: FgBlockDef = { + type: FgBlockType.ReceiveCustomEvent, + build(config) { + const valueNames = (config?.valueNames as string[] | undefined) ?? []; + return { + dataOut: valueNames.map((name) => sockOut(name, FgType.Any)), + signalOut: [sigOut("out"), sigOut("done")], + event: FgEventType.CustomEvent, + }; + }, + execute(block, ctx, env) { + const eventId = (block.config?.eventId as string | undefined) ?? ""; + const valueNames = (block.config?.valueNames as string[] | undefined) ?? []; + + const payload = getExecVar<{ eventName?: string; values?: Record } | undefined>(ctx, block, "lastEvent", undefined); + + // Filter: only react to events that match this block's eventId. + if (!payload || payload.eventName !== eventId) { + return; + } + + // Write named values from the payload into data outputs. + for (const name of valueNames) { + const v = payload.values?.[name]; + if (v !== undefined) { + setDataValue(ctx, block, name, v); + } + } + + activateSignal(ctx, env, block, "done"); + activateSignal(ctx, env, block, "out"); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/events/scene-start.ts b/packages/babylon-lite/src/flow-graph/blocks/events/scene-start.ts new file mode 100644 index 0000000000..fc19a1accb --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/events/scene-start.ts @@ -0,0 +1,24 @@ +// SceneStart (BJS FlowGraphSceneReadyEventBlock, glTF op `event/onStart`). +// Event block: fires once when the graph starts. The runtime's startFlowGraph +// invokes `execute` after all non-start receivers are subscribed. +// +// BJS event blocks fire BOTH `done` (KHR_interactivity graphs wire to this) and +// `out` (editor graphs) — we replicate that so either wiring style works. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgEventType } from "../../event-bus.js"; +import { activateSignal } from "../../runtime.js"; +import { sigOut } from "../../sockets.js"; + +export const sceneStartDef: FgBlockDef = { + type: FgBlockType.SceneStart, + build: () => ({ + signalOut: [sigOut("out"), sigOut("done")], + event: FgEventType.Start, + }), + execute(block, ctx, env) { + activateSignal(ctx, env, block, "done"); + activateSignal(ctx, env, block, "out"); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/events/scene-tick.ts b/packages/babylon-lite/src/flow-graph/blocks/events/scene-tick.ts new file mode 100644 index 0000000000..a7a629774c --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/events/scene-tick.ts @@ -0,0 +1,41 @@ +// SceneTick (BJS FlowGraphSceneTickEventBlock, glTF op `event/onTick`). +// Event block fired every frame on the Tick channel. Outputs the elapsed time +// since start and the last frame delta (both in SECONDS, matching BJS), then +// fires `out`/`done`. +// +// The scene driver pumps the Tick event each frame with payload +// `{ deltaMs, deltaTime }`; the runtime stashes it at +// `executionVariables[`${id}:lastEvent`]` before calling `execute`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { FgEventType } from "../../event-bus.js"; +import { activateSignal, getExecVar, setDataValue, setExecVar } from "../../runtime.js"; +import { sigOut, sockOut } from "../../sockets.js"; + +interface TickPayload { + deltaTime?: number; +} + +export const sceneTickDef: FgBlockDef = { + type: FgBlockType.SceneTick, + build: () => ({ + dataOut: [sockOut("timeSinceStart", FgType.Number), sockOut("deltaTime", FgType.Number)], + signalOut: [sigOut("out"), sigOut("done")], + event: FgEventType.Tick, + }), + updateOutputs(block, ctx) { + setDataValue(ctx, block, "timeSinceStart", getExecVar(ctx, block, "elapsed", 0)); + setDataValue(ctx, block, "deltaTime", getExecVar(ctx, block, "delta", 0)); + }, + execute(block, ctx, env) { + const payload = getExecVar(ctx, block, "lastEvent", undefined); + const delta = payload?.deltaTime ?? 0; + setExecVar(ctx, block, "elapsed", getExecVar(ctx, block, "elapsed", 0) + delta); + setExecVar(ctx, block, "delta", delta); + this.updateOutputs!(block, ctx, env); + activateSignal(ctx, env, block, "done"); + activateSignal(ctx, env, block, "out"); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/events/send-custom-event.ts b/packages/babylon-lite/src/flow-graph/blocks/events/send-custom-event.ts new file mode 100644 index 0000000000..f582a9ed22 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/events/send-custom-event.ts @@ -0,0 +1,46 @@ +// SendCustomEvent (BJS FlowGraphSendCustomEventBlock, glTF op `event/send`). +// Execution block: gathers named data inputs and pumps a custom event on the +// shared bus so any ReceiveCustomEvent block in any graph can consume it. +// +// Config: +// `eventId` — string identifier for the event channel (required). +// `valueNames` — optional string[] of data-input socket names whose values +// are bundled into `payload.values`. Derived from the glTF +// events table (deferred wiring — see report). +// +// glTF: `event/send`. The `configuration["event"]` integer index is mapped to +// `config.eventId` by declaration-mapper (configKeys). Full value-parameter +// sockets from the glTF events table are a deferred Phase 3i+ item. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgEventType } from "../../event-bus.js"; +import { FgType } from "../../types.js"; +import type { FgValue } from "../../types.js"; +import { activateSignal, getDataValue } from "../../runtime.js"; +import { pumpFgEvent } from "../../event-bus.js"; +import { sigIn, sigOut, sockIn } from "../../sockets.js"; + +export const sendCustomEventDef: FgBlockDef = { + type: FgBlockType.SendCustomEvent, + build(config) { + const valueNames = (config?.valueNames as string[] | undefined) ?? []; + return { + dataIn: valueNames.map((name) => sockIn(name, FgType.Any)), + signalIn: [sigIn("in")], + signalOut: [sigOut("out")], + }; + }, + execute(block, ctx, env) { + const eventId = (block.config?.eventId as string | undefined) ?? ""; + const valueNames = (block.config?.valueNames as string[] | undefined) ?? []; + + const values: Record = {}; + for (const name of valueNames) { + values[name] = getDataValue(ctx, env, block, name); + } + + pumpFgEvent(env.events, FgEventType.CustomEvent, { eventName: eventId, values }); + activateSignal(ctx, env, block, "out"); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/abs.ts b/packages/babylon-lite/src/flow-graph/blocks/math/abs.ts new file mode 100644 index 0000000000..0c4f6f5721 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/abs.ts @@ -0,0 +1,21 @@ +// Abs (BJS FlowGraphAbsBlock, glTF op `math/abs`). +// Data block (PULL): emits `value` = |a|, type-generic across number / +// FlowGraphInteger / Vector2-4 via fg-math's `fgAbs`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAbs } from "../../fg-math.js"; + +export const absDef: FgBlockDef = { + type: FgBlockType.Abs, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + setDataValue(ctx, block, "value", fgAbs(getDataValue(ctx, env, block, "a"))); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/acos.ts b/packages/babylon-lite/src/flow-graph/blocks/math/acos.ts new file mode 100644 index 0000000000..2f20076f6c --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/acos.ts @@ -0,0 +1,21 @@ +// Acos (BJS FlowGraphAcosBlock, glTF op `math/acos`). +// Data block (PULL): emits `value` via fg-math's `fgAcos`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAcos } from "../../fg-math.js"; + +export const acosDef: FgBlockDef = { + type: FgBlockType.Acos, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgAcos(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/acosh.ts b/packages/babylon-lite/src/flow-graph/blocks/math/acosh.ts new file mode 100644 index 0000000000..7173a6515e --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/acosh.ts @@ -0,0 +1,21 @@ +// Acosh (BJS FlowGraphAcoshBlock, glTF op `math/acosh`). +// Data block (PULL): emits `value` via fg-math's `fgAcosh`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAcosh } from "../../fg-math.js"; + +export const acoshDef: FgBlockDef = { + type: FgBlockType.Acosh, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgAcosh(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/add.ts b/packages/babylon-lite/src/flow-graph/blocks/math/add.ts new file mode 100644 index 0000000000..a04f350062 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/add.ts @@ -0,0 +1,23 @@ +// Add (BJS FlowGraphAddBlock, glTF op `math/add`). +// Data block (PULL): emits `value` = a + b, type-generic across number / +// FlowGraphInteger / Vector2-4 via fg-math's `fgAdd`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAdd } from "../../fg-math.js"; + +export const addDef: FgBlockDef = { + type: FgBlockType.Add, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgAdd(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/angle-between.ts b/packages/babylon-lite/src/flow-graph/blocks/math/angle-between.ts new file mode 100644 index 0000000000..6f0ed9afff --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/angle-between.ts @@ -0,0 +1,22 @@ +// AngleBetween (BJS FlowGraphAngleBetweenBlock, glTF op `math/quatAngleBetween`). +// Data block (PULL): emits scalar `value` = 2·acos(clamp(dot(a,b),-1,1)). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAngleBetween } from "../../fg-math.js"; + +export const angleBetweenDef: FgBlockDef = { + type: FgBlockType.AngleBetween, + build: () => ({ + dataIn: [sockIn("a", FgType.Quaternion), sockIn("b", FgType.Quaternion)], + dataOut: [sockOut("value", FgType.Number)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgAngleBetween(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/asin.ts b/packages/babylon-lite/src/flow-graph/blocks/math/asin.ts new file mode 100644 index 0000000000..157979dc0d --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/asin.ts @@ -0,0 +1,21 @@ +// Asin (BJS FlowGraphAsinBlock, glTF op `math/asin`). +// Data block (PULL): emits `value` via fg-math's `fgAsin`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAsin } from "../../fg-math.js"; + +export const asinDef: FgBlockDef = { + type: FgBlockType.Asin, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgAsin(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/asinh.ts b/packages/babylon-lite/src/flow-graph/blocks/math/asinh.ts new file mode 100644 index 0000000000..bf59198ad7 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/asinh.ts @@ -0,0 +1,21 @@ +// Asinh (BJS FlowGraphAsinhBlock, glTF op `math/asinh`). +// Data block (PULL): emits `value` via fg-math's `fgAsinh`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAsinh } from "../../fg-math.js"; + +export const asinhDef: FgBlockDef = { + type: FgBlockType.Asinh, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgAsinh(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/atan.ts b/packages/babylon-lite/src/flow-graph/blocks/math/atan.ts new file mode 100644 index 0000000000..0a64f9e07c --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/atan.ts @@ -0,0 +1,21 @@ +// Atan (BJS FlowGraphAtanBlock, glTF op `math/atan`). +// Data block (PULL): emits `value` via fg-math's `fgAtan`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAtan } from "../../fg-math.js"; + +export const atanDef: FgBlockDef = { + type: FgBlockType.Atan, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgAtan(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/atan2.ts b/packages/babylon-lite/src/flow-graph/blocks/math/atan2.ts new file mode 100644 index 0000000000..15692b8294 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/atan2.ts @@ -0,0 +1,22 @@ +// Atan2 (BJS FlowGraphATan2Block, glTF op `math/atan2`). +// Data block (PULL): emits `value` via fg-math's `fgAtan2`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAtan2 } from "../../fg-math.js"; + +export const atan2Def: FgBlockDef = { + type: FgBlockType.Atan2, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgAtan2(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/atanh.ts b/packages/babylon-lite/src/flow-graph/blocks/math/atanh.ts new file mode 100644 index 0000000000..cc978593bd --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/atanh.ts @@ -0,0 +1,21 @@ +// Atanh (BJS FlowGraphAtanhBlock, glTF op `math/atanh`). +// Data block (PULL): emits `value` via fg-math's `fgAtanh`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAtanh } from "../../fg-math.js"; + +export const atanhDef: FgBlockDef = { + type: FgBlockType.Atanh, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgAtanh(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/axis-angle-from-quaternion.ts b/packages/babylon-lite/src/flow-graph/blocks/math/axis-angle-from-quaternion.ts new file mode 100644 index 0000000000..1687528db0 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/axis-angle-from-quaternion.ts @@ -0,0 +1,25 @@ +// AxisAngleFromQuaternion (BJS FlowGraphAxisAngleFromQuaternionBlock, +// glTF op `math/quatToAxisAngle`). +// Data block (PULL): `a` = Quaternion → `axis` (Vec3), `angle` (number), +// `isValid` (boolean). When sin(angle/2) is near zero, axis defaults to (1,0,0). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAxisAngleFromQuaternion } from "../../fg-math.js"; + +export const axisAngleFromQuaternionDef: FgBlockDef = { + type: FgBlockType.AxisAngleFromQuaternion, + build: () => ({ + dataIn: [sockIn("a", FgType.Quaternion)], + dataOut: [sockOut("axis", FgType.Vector3), sockOut("angle", FgType.Number), sockOut("isValid", FgType.Boolean)], + }), + updateOutputs(block, ctx, env) { + const { axis, angle, isValid } = fgAxisAngleFromQuaternion(getDataValue(ctx, env, block, "a")); + setDataValue(ctx, block, "axis", axis); + setDataValue(ctx, block, "angle", angle); + setDataValue(ctx, block, "isValid", isValid); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-and.ts b/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-and.ts new file mode 100644 index 0000000000..ca83870784 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-and.ts @@ -0,0 +1,22 @@ +// BitwiseAnd (BJS FlowGraphBitwiseAndBlock, glTF op `math/and`). +// Data block (PULL): emits `value` via fg-math's `fgAnd`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAnd } from "../../fg-math.js"; + +export const bitwiseAndDef: FgBlockDef = { + type: FgBlockType.BitwiseAnd, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgAnd(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-left-shift.ts b/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-left-shift.ts new file mode 100644 index 0000000000..7e2c88fcc6 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-left-shift.ts @@ -0,0 +1,22 @@ +// BitwiseLeftShift (BJS FlowGraphBitwiseLeftShiftBlock, glTF op `math/lsl`). +// Data block (PULL): emits `value` via fg-math's `fgLsl`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgLsl } from "../../fg-math.js"; + +export const bitwiseLeftShiftDef: FgBlockDef = { + type: FgBlockType.BitwiseLeftShift, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgLsl(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-not.ts b/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-not.ts new file mode 100644 index 0000000000..92ec4c14c8 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-not.ts @@ -0,0 +1,21 @@ +// BitwiseNot (BJS FlowGraphBitwiseNotBlock, glTF op `math/not`). +// Data block (PULL): emits `value` via fg-math's `fgNot`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgNot } from "../../fg-math.js"; + +export const bitwiseNotDef: FgBlockDef = { + type: FgBlockType.BitwiseNot, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgNot(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-or.ts b/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-or.ts new file mode 100644 index 0000000000..3e25eab419 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-or.ts @@ -0,0 +1,22 @@ +// BitwiseOr (BJS FlowGraphBitwiseOrBlock, glTF op `math/or`). +// Data block (PULL): emits `value` via fg-math's `fgOr`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgOr } from "../../fg-math.js"; + +export const bitwiseOrDef: FgBlockDef = { + type: FgBlockType.BitwiseOr, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgOr(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-right-shift.ts b/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-right-shift.ts new file mode 100644 index 0000000000..17008c2223 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-right-shift.ts @@ -0,0 +1,22 @@ +// BitwiseRightShift (BJS FlowGraphBitwiseRightShiftBlock, glTF op `math/asr`). +// Data block (PULL): emits `value` via fg-math's `fgAsr`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgAsr } from "../../fg-math.js"; + +export const bitwiseRightShiftDef: FgBlockDef = { + type: FgBlockType.BitwiseRightShift, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgAsr(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-xor.ts b/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-xor.ts new file mode 100644 index 0000000000..a4a91edf0e --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/bitwise-xor.ts @@ -0,0 +1,22 @@ +// BitwiseXor (BJS FlowGraphBitwiseXorBlock, glTF op `math/xor`). +// Data block (PULL): emits `value` via fg-math's `fgXor`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgXor } from "../../fg-math.js"; + +export const bitwiseXorDef: FgBlockDef = { + type: FgBlockType.BitwiseXor, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgXor(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/ceil.ts b/packages/babylon-lite/src/flow-graph/blocks/math/ceil.ts new file mode 100644 index 0000000000..ab7a4725b0 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/ceil.ts @@ -0,0 +1,21 @@ +// Ceil (BJS FlowGraphCeilBlock, glTF op `math/ceil`). +// Data block (PULL): emits `value` via fg-math's `fgCeil`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgCeil } from "../../fg-math.js"; + +export const ceilDef: FgBlockDef = { + type: FgBlockType.Ceil, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgCeil(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/clamp.ts b/packages/babylon-lite/src/flow-graph/blocks/math/clamp.ts new file mode 100644 index 0000000000..7a9d6de01f --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/clamp.ts @@ -0,0 +1,25 @@ +// Clamp (BJS FlowGraphClampBlock, glTF op `math/clamp`). +// Data block (PULL): emits `value` = min(max(a, b), c) — `b` is the lower bound, +// `c` the upper — type-generic across number / FlowGraphInteger / Vector2-4 via +// fg-math's `fgClamp`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgClamp } from "../../fg-math.js"; + +export const clampDef: FgBlockDef = { + type: FgBlockType.Clamp, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any), sockIn("c", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + const c = getDataValue(ctx, env, block, "c"); + setDataValue(ctx, block, "value", fgClamp(a, b, c)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/combine-matrix.ts b/packages/babylon-lite/src/flow-graph/blocks/math/combine-matrix.ts new file mode 100644 index 0000000000..fc4c09faa6 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/combine-matrix.ts @@ -0,0 +1,25 @@ +// CombineMatrix (BJS FlowGraphCombineMatrixBlock, glTF op `math/combine4x4`). +// Data block (PULL): 16 scalar inputs `input_0..input_15` (column-major order) +// → `value` (Mat4). Inputs map directly: input_i = m[i]. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgCombineMatrix } from "../../fg-math.js"; + +export const combineMatrixDef: FgBlockDef = { + type: FgBlockType.CombineMatrix, + build: () => ({ + dataIn: Array.from({ length: 16 }, (_, i) => sockIn(`input_${i}`, FgType.Number)), + dataOut: [sockOut("value", FgType.Matrix)], + }), + updateOutputs(block, ctx, env) { + const inputs = Array.from({ length: 16 }, (_, i) => { + const v = getDataValue(ctx, env, block, `input_${i}`); + return typeof v === "number" ? v : 0; + }); + setDataValue(ctx, block, "value", fgCombineMatrix(inputs)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/combine-matrix2d.ts b/packages/babylon-lite/src/flow-graph/blocks/math/combine-matrix2d.ts new file mode 100644 index 0000000000..a04ce881e4 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/combine-matrix2d.ts @@ -0,0 +1,25 @@ +// CombineMatrix2D (BJS FlowGraphCombineMatrix2DBlock, glTF op `math/combine2x2`). +// Data block (PULL): 4 scalar inputs `input_0..input_3` (column-major order) +// → `value` (FgMatrix2D). Inputs map directly: input_i = m[i]. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgCombineMatrix2D } from "../../fg-math.js"; + +export const combineMatrix2DDef: FgBlockDef = { + type: FgBlockType.CombineMatrix2D, + build: () => ({ + dataIn: Array.from({ length: 4 }, (_, i) => sockIn(`input_${i}`, FgType.Number)), + dataOut: [sockOut("value", FgType.Matrix2D)], + }), + updateOutputs(block, ctx, env) { + const inputs = Array.from({ length: 4 }, (_, i) => { + const v = getDataValue(ctx, env, block, `input_${i}`); + return typeof v === "number" ? v : 0; + }); + setDataValue(ctx, block, "value", fgCombineMatrix2D(inputs)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/combine-matrix3d.ts b/packages/babylon-lite/src/flow-graph/blocks/math/combine-matrix3d.ts new file mode 100644 index 0000000000..3deab1317b --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/combine-matrix3d.ts @@ -0,0 +1,25 @@ +// CombineMatrix3D (BJS FlowGraphCombineMatrix3DBlock, glTF op `math/combine3x3`). +// Data block (PULL): 9 scalar inputs `input_0..input_8` (column-major order) +// → `value` (FgMatrix3D). Inputs map directly: input_i = m[i]. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgCombineMatrix3D } from "../../fg-math.js"; + +export const combineMatrix3DDef: FgBlockDef = { + type: FgBlockType.CombineMatrix3D, + build: () => ({ + dataIn: Array.from({ length: 9 }, (_, i) => sockIn(`input_${i}`, FgType.Number)), + dataOut: [sockOut("value", FgType.Matrix3D)], + }), + updateOutputs(block, ctx, env) { + const inputs = Array.from({ length: 9 }, (_, i) => { + const v = getDataValue(ctx, env, block, `input_${i}`); + return typeof v === "number" ? v : 0; + }); + setDataValue(ctx, block, "value", fgCombineMatrix3D(inputs)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/combine2.ts b/packages/babylon-lite/src/flow-graph/blocks/math/combine2.ts new file mode 100644 index 0000000000..fb97aeba62 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/combine2.ts @@ -0,0 +1,23 @@ +// CombineVector2 (BJS FlowGraphCombineVector2Block, glTF op `math/combine2`). +// Data block (PULL): emits `value` = Vec2{x:a, y:b} from two scalars via +// fg-math's `fgCombine2`. glTF input sockets are `a`/`b`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgCombine2 } from "../../fg-math.js"; + +export const combine2Def: FgBlockDef = { + type: FgBlockType.CombineVector2, + build: () => ({ + dataIn: [sockIn("a", FgType.Number), sockIn("b", FgType.Number)], + dataOut: [sockOut("value", FgType.Vector2)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgCombine2(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/combine3.ts b/packages/babylon-lite/src/flow-graph/blocks/math/combine3.ts new file mode 100644 index 0000000000..a198a10bcd --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/combine3.ts @@ -0,0 +1,23 @@ +// CombineVector3 (BJS FlowGraphCombineVector3Block, glTF op `math/combine3`). +// Data block (PULL): emits Vec3{a,b,c} from three scalars via fg-math. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgCombine3 } from "../../fg-math.js"; + +export const combine3Def: FgBlockDef = { + type: FgBlockType.CombineVector3, + build: () => ({ + dataIn: [sockIn("a", FgType.Number), sockIn("b", FgType.Number), sockIn("c", FgType.Number)], + dataOut: [sockOut("value", FgType.Vector3)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + const c = getDataValue(ctx, env, block, "c"); + setDataValue(ctx, block, "value", fgCombine3(a, b, c)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/combine4.ts b/packages/babylon-lite/src/flow-graph/blocks/math/combine4.ts new file mode 100644 index 0000000000..67f12535fc --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/combine4.ts @@ -0,0 +1,24 @@ +// CombineVector4 (BJS FlowGraphCombineVector4Block, glTF op `math/combine4`). +// Data block (PULL): emits Vec4{a,b,c,d} from four scalars via fg-math. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgCombine4 } from "../../fg-math.js"; + +export const combine4Def: FgBlockDef = { + type: FgBlockType.CombineVector4, + build: () => ({ + dataIn: [sockIn("a", FgType.Number), sockIn("b", FgType.Number), sockIn("c", FgType.Number), sockIn("d", FgType.Number)], + dataOut: [sockOut("value", FgType.Vector4)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + const c = getDataValue(ctx, env, block, "c"); + const d = getDataValue(ctx, env, block, "d"); + setDataValue(ctx, block, "value", fgCombine4(a, b, c, d)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/conditional.ts b/packages/babylon-lite/src/flow-graph/blocks/math/conditional.ts new file mode 100644 index 0000000000..e26c5bf3ad --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/conditional.ts @@ -0,0 +1,22 @@ +// Conditional / select (BJS FlowGraphConditionalBlock, glTF op `math/select`). +// Data block (PULL): emits `onTrue` when `condition` is truthy, else `onFalse`. +// glTF inputs a/b map to onTrue/onFalse (see declaration-mapper). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; + +export const conditionalDef: FgBlockDef = { + type: FgBlockType.Conditional, + build: () => ({ + dataIn: [sockIn("condition", FgType.Boolean, false), sockIn("onTrue", FgType.Any), sockIn("onFalse", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const condition = getDataValue(ctx, env, block, "condition"); + const chosen = condition ? getDataValue(ctx, env, block, "onTrue") : getDataValue(ctx, env, block, "onFalse"); + setDataValue(ctx, block, "value", chosen); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/constant-e.ts b/packages/babylon-lite/src/flow-graph/blocks/math/constant-e.ts new file mode 100644 index 0000000000..e89ade2fa0 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/constant-e.ts @@ -0,0 +1,18 @@ +// E constant (BJS FlowGraphEBlock, glTF op `math/E`). +// Data block (PULL): emits the constant `value`; no inputs. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { setDataValue } from "../../runtime.js"; +import { sockOut } from "../../sockets.js"; + +export const eDef: FgBlockDef = { + type: FgBlockType.E, + build: () => ({ + dataOut: [sockOut("value", FgType.Number)], + }), + updateOutputs(block, ctx) { + setDataValue(ctx, block, "value", Math.E); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/constant-inf.ts b/packages/babylon-lite/src/flow-graph/blocks/math/constant-inf.ts new file mode 100644 index 0000000000..583d22f517 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/constant-inf.ts @@ -0,0 +1,18 @@ +// Inf constant (BJS FlowGraphInfBlock, glTF op `math/Inf`). +// Data block (PULL): emits the constant `value`; no inputs. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { setDataValue } from "../../runtime.js"; +import { sockOut } from "../../sockets.js"; + +export const infDef: FgBlockDef = { + type: FgBlockType.Inf, + build: () => ({ + dataOut: [sockOut("value", FgType.Number)], + }), + updateOutputs(block, ctx) { + setDataValue(ctx, block, "value", Number.POSITIVE_INFINITY); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/constant-nan.ts b/packages/babylon-lite/src/flow-graph/blocks/math/constant-nan.ts new file mode 100644 index 0000000000..a41631b7cd --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/constant-nan.ts @@ -0,0 +1,18 @@ +// NaN constant (BJS FlowGraphNaNBlock, glTF op `math/NaN`). +// Data block (PULL): emits the constant `value`; no inputs. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { setDataValue } from "../../runtime.js"; +import { sockOut } from "../../sockets.js"; + +export const nanDef: FgBlockDef = { + type: FgBlockType.NaN, + build: () => ({ + dataOut: [sockOut("value", FgType.Number)], + }), + updateOutputs(block, ctx) { + setDataValue(ctx, block, "value", Number.NaN); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/constant-pi.ts b/packages/babylon-lite/src/flow-graph/blocks/math/constant-pi.ts new file mode 100644 index 0000000000..8088aedac5 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/constant-pi.ts @@ -0,0 +1,18 @@ +// PI constant (BJS FlowGraphPIBlock, glTF op `math/Pi`). +// Data block (PULL): emits the constant `value`; no inputs. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { setDataValue } from "../../runtime.js"; +import { sockOut } from "../../sockets.js"; + +export const piDef: FgBlockDef = { + type: FgBlockType.PI, + build: () => ({ + dataOut: [sockOut("value", FgType.Number)], + }), + updateOutputs(block, ctx) { + setDataValue(ctx, block, "value", Math.PI); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/cos.ts b/packages/babylon-lite/src/flow-graph/blocks/math/cos.ts new file mode 100644 index 0000000000..5d66d65176 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/cos.ts @@ -0,0 +1,21 @@ +// Cos (BJS FlowGraphCosBlock, glTF op `math/cos`). +// Data block (PULL): emits `value` via fg-math's `fgCos`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgCos } from "../../fg-math.js"; + +export const cosDef: FgBlockDef = { + type: FgBlockType.Cos, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgCos(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/cosh.ts b/packages/babylon-lite/src/flow-graph/blocks/math/cosh.ts new file mode 100644 index 0000000000..3bc6ba9486 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/cosh.ts @@ -0,0 +1,21 @@ +// Cosh (BJS FlowGraphCoshBlock, glTF op `math/cosh`). +// Data block (PULL): emits `value` via fg-math's `fgCosh`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgCosh } from "../../fg-math.js"; + +export const coshDef: FgBlockDef = { + type: FgBlockType.Cosh, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgCosh(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/cross.ts b/packages/babylon-lite/src/flow-graph/blocks/math/cross.ts new file mode 100644 index 0000000000..187177e7e3 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/cross.ts @@ -0,0 +1,22 @@ +// Cross (BJS FlowGraphCrossBlock, glTF op `math/cross`). +// Data block (PULL): emits `value` via fg-math's `fgCross`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgCross } from "../../fg-math.js"; + +export const crossDef: FgBlockDef = { + type: FgBlockType.Cross, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Vector3)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgCross(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/cube-root.ts b/packages/babylon-lite/src/flow-graph/blocks/math/cube-root.ts new file mode 100644 index 0000000000..e2291cf4f5 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/cube-root.ts @@ -0,0 +1,21 @@ +// CubeRoot (BJS FlowGraphCubeRootBlock, glTF op `math/cbrt`). +// Data block (PULL): emits `value` via fg-math's `fgCbrt`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgCbrt } from "../../fg-math.js"; + +export const cubeRootDef: FgBlockDef = { + type: FgBlockType.CubeRoot, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgCbrt(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/data-switch.ts b/packages/babylon-lite/src/flow-graph/blocks/math/data-switch.ts new file mode 100644 index 0000000000..3d3e6fe565 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/data-switch.ts @@ -0,0 +1,31 @@ +// DataSwitch (BJS FlowGraphDataSwitchBlock, glTF op `math/switch`). +// Data block (PULL): selects one of several value inputs by a numeric selector. +// `config.cases` is an array of numeric case keys; each creates an `in_` +// data input. Reads `case`, looks up `in_`, falls back to `default`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { isFgInt } from "../../custom-types/fg-integer.js"; + +export const dataSwitchDef: FgBlockDef = { + type: FgBlockType.DataSwitch, + build: (config) => { + const cases = (config?.cases as number[] | undefined) ?? []; + const dataIn = [sockIn("case", FgType.Any), sockIn("default", FgType.Any)]; + for (const c of cases) { + dataIn.push(sockIn(`in_${c | 0}`, FgType.Any)); + } + return { dataIn, dataOut: [sockOut("value", FgType.Any)] }; + }, + updateOutputs(block, ctx, env) { + const cases = (block.config?.cases as number[] | undefined) ?? []; + const selector = getDataValue(ctx, env, block, "case"); + const key = isFgInt(selector) ? selector.value : (selector as number); + const matched = typeof key === "number" && cases.some((c) => (c | 0) === (key | 0)); + const socket = matched ? `in_${key | 0}` : "default"; + setDataValue(ctx, block, "value", getDataValue(ctx, env, block, socket)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/deg-to-rad.ts b/packages/babylon-lite/src/flow-graph/blocks/math/deg-to-rad.ts new file mode 100644 index 0000000000..eabeceef4b --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/deg-to-rad.ts @@ -0,0 +1,21 @@ +// DegToRad (BJS FlowGraphDegToRadBlock, glTF op `math/rad`). +// Data block (PULL): emits `value` via fg-math's `fgDegToRad`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgDegToRad } from "../../fg-math.js"; + +export const degToRadDef: FgBlockDef = { + type: FgBlockType.DegToRad, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgDegToRad(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/determinant.ts b/packages/babylon-lite/src/flow-graph/blocks/math/determinant.ts new file mode 100644 index 0000000000..2ad30b2b6a --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/determinant.ts @@ -0,0 +1,22 @@ +// Determinant (BJS FlowGraphDeterminantBlock, glTF op `math/determinant`). +// Data block (PULL): emits scalar `value` = det(a). +// Supports FgMatrix2D, FgMatrix3D, and Mat4. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgDeterminant } from "../../fg-math.js"; + +export const determinantDef: FgBlockDef = { + type: FgBlockType.Determinant, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Number)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgDeterminant(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/divide.ts b/packages/babylon-lite/src/flow-graph/blocks/math/divide.ts new file mode 100644 index 0000000000..6f1f0bfffa --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/divide.ts @@ -0,0 +1,23 @@ +// Divide (BJS FlowGraphDivideBlock, glTF op `math/div`). +// Data block (PULL): emits `value` = a ÷ b, type-generic across number / +// FlowGraphInteger / Vector2-4 via fg-math's `fgDiv`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgDiv } from "../../fg-math.js"; + +export const divideDef: FgBlockDef = { + type: FgBlockType.Divide, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgDiv(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/dot.ts b/packages/babylon-lite/src/flow-graph/blocks/math/dot.ts new file mode 100644 index 0000000000..41a5088fd7 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/dot.ts @@ -0,0 +1,22 @@ +// Dot (BJS FlowGraphDotBlock, glTF op `math/dot`). +// Data block (PULL): emits `value` via fg-math's `fgDot`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgDot } from "../../fg-math.js"; + +export const dotDef: FgBlockDef = { + type: FgBlockType.Dot, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Number)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgDot(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/equality.ts b/packages/babylon-lite/src/flow-graph/blocks/math/equality.ts new file mode 100644 index 0000000000..a4b8d21083 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/equality.ts @@ -0,0 +1,22 @@ +// Equality (BJS FlowGraphEqualityBlock, glTF op `math/eq`). +// Data block (PULL): emits `value` via fg-math's `fgEq`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgEq } from "../../fg-math.js"; + +export const equalityDef: FgBlockDef = { + type: FgBlockType.Equality, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Boolean)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgEq(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/exponential.ts b/packages/babylon-lite/src/flow-graph/blocks/math/exponential.ts new file mode 100644 index 0000000000..a0a39fff64 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/exponential.ts @@ -0,0 +1,21 @@ +// Exponential (BJS FlowGraphExpBlock, glTF op `math/exp`). +// Data block (PULL): emits `value` via fg-math's `fgExp`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgExp } from "../../fg-math.js"; + +export const exponentialDef: FgBlockDef = { + type: FgBlockType.Exponential, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgExp(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/extract-matrix.ts b/packages/babylon-lite/src/flow-graph/blocks/math/extract-matrix.ts new file mode 100644 index 0000000000..4c2b777b79 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/extract-matrix.ts @@ -0,0 +1,24 @@ +// ExtractMatrix (BJS FlowGraphExtractMatrixBlock, glTF op `math/extract4x4`). +// Data block (PULL): Mat4 input `input` → 16 scalar outputs `output_0..output_15` +// in column-major order (output_i = m[i]). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgExtractMatrix } from "../../fg-math.js"; + +export const extractMatrixDef: FgBlockDef = { + type: FgBlockType.ExtractMatrix, + build: () => ({ + dataIn: [sockIn("input", FgType.Matrix)], + dataOut: Array.from({ length: 16 }, (_, i) => sockOut(`output_${i}`, FgType.Number)), + }), + updateOutputs(block, ctx, env) { + const elems = fgExtractMatrix(getDataValue(ctx, env, block, "input")); + for (let i = 0; i < 16; i++) { + setDataValue(ctx, block, `output_${i}`, elems[i]!); + } + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/extract-matrix2d.ts b/packages/babylon-lite/src/flow-graph/blocks/math/extract-matrix2d.ts new file mode 100644 index 0000000000..c7d0f137f5 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/extract-matrix2d.ts @@ -0,0 +1,24 @@ +// ExtractMatrix2D (BJS FlowGraphExtractMatrix2DBlock, glTF op `math/extract2x2`). +// Data block (PULL): FgMatrix2D input `input` → 4 scalar outputs `output_0..output_3` +// in column-major order (output_i = m.m[i]). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgExtractMatrix2D } from "../../fg-math.js"; + +export const extractMatrix2DDef: FgBlockDef = { + type: FgBlockType.ExtractMatrix2D, + build: () => ({ + dataIn: [sockIn("input", FgType.Matrix2D)], + dataOut: Array.from({ length: 4 }, (_, i) => sockOut(`output_${i}`, FgType.Number)), + }), + updateOutputs(block, ctx, env) { + const elems = fgExtractMatrix2D(getDataValue(ctx, env, block, "input")); + for (let i = 0; i < 4; i++) { + setDataValue(ctx, block, `output_${i}`, elems[i]!); + } + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/extract-matrix3d.ts b/packages/babylon-lite/src/flow-graph/blocks/math/extract-matrix3d.ts new file mode 100644 index 0000000000..9a905b0c44 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/extract-matrix3d.ts @@ -0,0 +1,24 @@ +// ExtractMatrix3D (BJS FlowGraphExtractMatrix3DBlock, glTF op `math/extract3x3`). +// Data block (PULL): FgMatrix3D input `input` → 9 scalar outputs `output_0..output_8` +// in column-major order (output_i = m.m[i]). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgExtractMatrix3D } from "../../fg-math.js"; + +export const extractMatrix3DDef: FgBlockDef = { + type: FgBlockType.ExtractMatrix3D, + build: () => ({ + dataIn: [sockIn("input", FgType.Matrix3D)], + dataOut: Array.from({ length: 9 }, (_, i) => sockOut(`output_${i}`, FgType.Number)), + }), + updateOutputs(block, ctx, env) { + const elems = fgExtractMatrix3D(getDataValue(ctx, env, block, "input")); + for (let i = 0; i < 9; i++) { + setDataValue(ctx, block, `output_${i}`, elems[i]!); + } + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/extract2.ts b/packages/babylon-lite/src/flow-graph/blocks/math/extract2.ts new file mode 100644 index 0000000000..fb5bba27b8 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/extract2.ts @@ -0,0 +1,24 @@ +// ExtractVector2 (BJS FlowGraphExtractVector2Block, glTF op `math/extract2`). +// Data block (PULL): one Vec2 input `a` → two scalar outputs. glTF names the +// outputs by index ("0"/"1"); the declaration mapper maps those to Lite sockets +// `x`/`y`. Values come from fg-math's `fgExtract2`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgExtract2 } from "../../fg-math.js"; + +export const extract2Def: FgBlockDef = { + type: FgBlockType.ExtractVector2, + build: () => ({ + dataIn: [sockIn("a", FgType.Vector2)], + dataOut: [sockOut("x", FgType.Number), sockOut("y", FgType.Number)], + }), + updateOutputs(block, ctx, env) { + const [x, y] = fgExtract2(getDataValue(ctx, env, block, "a")); + setDataValue(ctx, block, "x", x); + setDataValue(ctx, block, "y", y); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/extract3.ts b/packages/babylon-lite/src/flow-graph/blocks/math/extract3.ts new file mode 100644 index 0000000000..b441989ece --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/extract3.ts @@ -0,0 +1,24 @@ +// ExtractVector3 (BJS FlowGraphExtractVector3Block, glTF op `math/extract3`). +// Data block (PULL): one Vec3 input `a` -> three scalar outputs x/y/z (glTF +// indices 0/1/2 remapped in declaration-mapper). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgExtract3 } from "../../fg-math.js"; + +export const extract3Def: FgBlockDef = { + type: FgBlockType.ExtractVector3, + build: () => ({ + dataIn: [sockIn("a", FgType.Vector3)], + dataOut: [sockOut("x", FgType.Number), sockOut("y", FgType.Number), sockOut("z", FgType.Number)], + }), + updateOutputs(block, ctx, env) { + const [x, y, z] = fgExtract3(getDataValue(ctx, env, block, "a")); + setDataValue(ctx, block, "x", x); + setDataValue(ctx, block, "y", y); + setDataValue(ctx, block, "z", z); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/extract4.ts b/packages/babylon-lite/src/flow-graph/blocks/math/extract4.ts new file mode 100644 index 0000000000..60da37ef44 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/extract4.ts @@ -0,0 +1,25 @@ +// ExtractVector4 (BJS FlowGraphExtractVector4Block, glTF op `math/extract4`). +// Data block (PULL): one Vec4 input `a` -> four scalar outputs x/y/z/w (glTF +// indices 0/1/2/3 remapped in declaration-mapper). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgExtract4 } from "../../fg-math.js"; + +export const extract4Def: FgBlockDef = { + type: FgBlockType.ExtractVector4, + build: () => ({ + dataIn: [sockIn("a", FgType.Vector4)], + dataOut: [sockOut("x", FgType.Number), sockOut("y", FgType.Number), sockOut("z", FgType.Number), sockOut("w", FgType.Number)], + }), + updateOutputs(block, ctx, env) { + const [x, y, z, w] = fgExtract4(getDataValue(ctx, env, block, "a")); + setDataValue(ctx, block, "x", x); + setDataValue(ctx, block, "y", y); + setDataValue(ctx, block, "z", z); + setDataValue(ctx, block, "w", w); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/floor.ts b/packages/babylon-lite/src/flow-graph/blocks/math/floor.ts new file mode 100644 index 0000000000..361ab7ba01 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/floor.ts @@ -0,0 +1,21 @@ +// Floor (BJS FlowGraphFloorBlock, glTF op `math/floor`). +// Data block (PULL): emits `value` = floor(a), type-generic across number / +// FlowGraphInteger / Vector2-4 via fg-math's `fgFloor`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgFloor } from "../../fg-math.js"; + +export const floorDef: FgBlockDef = { + type: FgBlockType.Floor, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + setDataValue(ctx, block, "value", fgFloor(getDataValue(ctx, env, block, "a"))); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/fraction.ts b/packages/babylon-lite/src/flow-graph/blocks/math/fraction.ts new file mode 100644 index 0000000000..2293019756 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/fraction.ts @@ -0,0 +1,21 @@ +// Fraction (BJS FlowGraphFractionBlock, glTF op `math/fract`). +// Data block (PULL): emits `value` via fg-math's `fgFract`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgFract } from "../../fg-math.js"; + +export const fractionDef: FgBlockDef = { + type: FgBlockType.Fraction, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgFract(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/greater-than-or-equal.ts b/packages/babylon-lite/src/flow-graph/blocks/math/greater-than-or-equal.ts new file mode 100644 index 0000000000..c0a0e9b3c4 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/greater-than-or-equal.ts @@ -0,0 +1,22 @@ +// GreaterThanOrEqual (BJS FlowGraphGreaterThanOrEqualBlock, glTF op `math/ge`). +// Data block (PULL): emits `value` via fg-math's `fgGe`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgGe } from "../../fg-math.js"; + +export const greaterThanOrEqualDef: FgBlockDef = { + type: FgBlockType.GreaterThanOrEqual, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Boolean)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgGe(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/greater-than.ts b/packages/babylon-lite/src/flow-graph/blocks/math/greater-than.ts new file mode 100644 index 0000000000..e95c71f58f --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/greater-than.ts @@ -0,0 +1,22 @@ +// GreaterThan (BJS FlowGraphGreaterThanBlock, glTF op `math/gt`). +// Data block (PULL): emits `value` via fg-math's `fgGt`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgGt } from "../../fg-math.js"; + +export const greaterThanDef: FgBlockDef = { + type: FgBlockType.GreaterThan, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Boolean)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgGt(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/invert-matrix.ts b/packages/babylon-lite/src/flow-graph/blocks/math/invert-matrix.ts new file mode 100644 index 0000000000..48915fd53f --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/invert-matrix.ts @@ -0,0 +1,22 @@ +// InvertMatrix (BJS FlowGraphInvertMatrixBlock, glTF op `math/inverse`). +// Data block (PULL): emits `value` = inverse of input matrix `a`. +// Returns identity when singular. Supports FgMatrix2D, FgMatrix3D, and Mat4. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgInvertMatrix } from "../../fg-math.js"; + +export const invertMatrixDef: FgBlockDef = { + type: FgBlockType.InvertMatrix, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgInvertMatrix(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/is-infinity.ts b/packages/babylon-lite/src/flow-graph/blocks/math/is-infinity.ts new file mode 100644 index 0000000000..0b8779945a --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/is-infinity.ts @@ -0,0 +1,21 @@ +// IsInfinity (BJS FlowGraphIsInfBlock, glTF op `math/isInf`). +// Data block (PULL): emits `value` via fg-math's `fgIsInf`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgIsInf } from "../../fg-math.js"; + +export const isInfinityDef: FgBlockDef = { + type: FgBlockType.IsInfinity, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Boolean)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgIsInf(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/is-nan.ts b/packages/babylon-lite/src/flow-graph/blocks/math/is-nan.ts new file mode 100644 index 0000000000..256e4469be --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/is-nan.ts @@ -0,0 +1,21 @@ +// IsNaN (BJS FlowGraphIsNaNBlock, glTF op `math/isNaN`). +// Data block (PULL): emits `value` via fg-math's `fgIsNaN`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgIsNaN } from "../../fg-math.js"; + +export const isNaNDef: FgBlockDef = { + type: FgBlockType.IsNaN, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Boolean)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgIsNaN(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/leading-zeros.ts b/packages/babylon-lite/src/flow-graph/blocks/math/leading-zeros.ts new file mode 100644 index 0000000000..1ac33fc15d --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/leading-zeros.ts @@ -0,0 +1,21 @@ +// LeadingZeros (BJS FlowGraphLeadingZerosBlock, glTF op `math/clz`). +// Data block (PULL): emits `value` via fg-math's `fgClz`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgClz } from "../../fg-math.js"; + +export const leadingZerosDef: FgBlockDef = { + type: FgBlockType.LeadingZeros, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgClz(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/length.ts b/packages/babylon-lite/src/flow-graph/blocks/math/length.ts new file mode 100644 index 0000000000..d98efc3876 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/length.ts @@ -0,0 +1,21 @@ +// Length (BJS FlowGraphLengthBlock, glTF op `math/length`). +// Data block (PULL): emits `value` via fg-math's `fgLength`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgLength } from "../../fg-math.js"; + +export const lengthDef: FgBlockDef = { + type: FgBlockType.Length, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Number)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgLength(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/less-than-or-equal.ts b/packages/babylon-lite/src/flow-graph/blocks/math/less-than-or-equal.ts new file mode 100644 index 0000000000..ca187f99f3 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/less-than-or-equal.ts @@ -0,0 +1,22 @@ +// LessThanOrEqual (BJS FlowGraphLessThanOrEqualBlock, glTF op `math/le`). +// Data block (PULL): emits `value` via fg-math's `fgLe`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgLe } from "../../fg-math.js"; + +export const lessThanOrEqualDef: FgBlockDef = { + type: FgBlockType.LessThanOrEqual, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Boolean)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgLe(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/less-than.ts b/packages/babylon-lite/src/flow-graph/blocks/math/less-than.ts new file mode 100644 index 0000000000..4023e75e8f --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/less-than.ts @@ -0,0 +1,23 @@ +// LessThan (BJS FlowGraphLessThanBlock, glTF op `math/lt`). +// Data block (PULL): emits boolean `value` = a < b (scalar comparison on the +// numeric payload of number / FlowGraphInteger) via fg-math's `fgLt`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgLt } from "../../fg-math.js"; + +export const lessThanDef: FgBlockDef = { + type: FgBlockType.LessThan, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Boolean)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgLt(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/log.ts b/packages/babylon-lite/src/flow-graph/blocks/math/log.ts new file mode 100644 index 0000000000..d2c83cfc7e --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/log.ts @@ -0,0 +1,21 @@ +// Log (BJS FlowGraphLogBlock, glTF op `math/log`). +// Data block (PULL): emits `value` via fg-math's `fgLog`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgLog } from "../../fg-math.js"; + +export const logDef: FgBlockDef = { + type: FgBlockType.Log, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgLog(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/log10.ts b/packages/babylon-lite/src/flow-graph/blocks/math/log10.ts new file mode 100644 index 0000000000..a3570cd543 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/log10.ts @@ -0,0 +1,21 @@ +// Log10 (BJS FlowGraphLog10Block, glTF op `math/log10`). +// Data block (PULL): emits `value` via fg-math's `fgLog10`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgLog10 } from "../../fg-math.js"; + +export const log10Def: FgBlockDef = { + type: FgBlockType.Log10, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgLog10(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/log2.ts b/packages/babylon-lite/src/flow-graph/blocks/math/log2.ts new file mode 100644 index 0000000000..4b1e98699c --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/log2.ts @@ -0,0 +1,21 @@ +// Log2 (BJS FlowGraphLog2Block, glTF op `math/log2`). +// Data block (PULL): emits `value` via fg-math's `fgLog2`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgLog2 } from "../../fg-math.js"; + +export const log2Def: FgBlockDef = { + type: FgBlockType.Log2, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgLog2(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/matrix-compose.ts b/packages/babylon-lite/src/flow-graph/blocks/math/matrix-compose.ts new file mode 100644 index 0000000000..0462a6eee5 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/matrix-compose.ts @@ -0,0 +1,24 @@ +// MatrixCompose (BJS FlowGraphMatrixComposeBlock, glTF op `math/matCompose`). +// Data block (PULL): composes a Mat4 from position (Vec3), rotationQuaternion +// (Quaternion), and scaling (Vec3). Uses core mat4Compose (column-major). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgMatrixCompose } from "../../fg-math.js"; + +export const matrixComposeDef: FgBlockDef = { + type: FgBlockType.MatrixCompose, + build: () => ({ + dataIn: [sockIn("position", FgType.Vector3), sockIn("rotationQuaternion", FgType.Quaternion), sockIn("scaling", FgType.Vector3)], + dataOut: [sockOut("value", FgType.Matrix)], + }), + updateOutputs(block, ctx, env) { + const pos = getDataValue(ctx, env, block, "position"); + const quat = getDataValue(ctx, env, block, "rotationQuaternion"); + const scale = getDataValue(ctx, env, block, "scaling"); + setDataValue(ctx, block, "value", fgMatrixCompose(pos, quat, scale)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/matrix-decompose.ts b/packages/babylon-lite/src/flow-graph/blocks/math/matrix-decompose.ts new file mode 100644 index 0000000000..8974c607ac --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/matrix-decompose.ts @@ -0,0 +1,26 @@ +// MatrixDecompose (BJS FlowGraphMatrixDecomposeBlock, glTF op `math/matDecompose`). +// Data block (PULL): decomposes a Mat4 into position (Vec3), rotationQuaternion +// (Quaternion), scaling (Vec3), and isValid (boolean). +// `isValid` is false if the matrix is not a valid TRS matrix (bottom row ≠ [0,0,0,1]). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgMatrixDecompose } from "../../fg-math.js"; + +export const matrixDecomposeDef: FgBlockDef = { + type: FgBlockType.MatrixDecompose, + build: () => ({ + dataIn: [sockIn("input", FgType.Matrix)], + dataOut: [sockOut("position", FgType.Vector3), sockOut("rotationQuaternion", FgType.Quaternion), sockOut("scaling", FgType.Vector3), sockOut("isValid", FgType.Boolean)], + }), + updateOutputs(block, ctx, env) { + const { position, rotationQuaternion, scaling, isValid } = fgMatrixDecompose(getDataValue(ctx, env, block, "input")); + setDataValue(ctx, block, "position", position); + setDataValue(ctx, block, "rotationQuaternion", rotationQuaternion); + setDataValue(ctx, block, "scaling", scaling); + setDataValue(ctx, block, "isValid", isValid); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/matrix-multiplication.ts b/packages/babylon-lite/src/flow-graph/blocks/math/matrix-multiplication.ts new file mode 100644 index 0000000000..0c3a36ba80 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/matrix-multiplication.ts @@ -0,0 +1,23 @@ +// MatrixMultiplication (BJS FlowGraphMatrixMultiplicationBlock, glTF op `math/matMul`). +// Data block (PULL): emits `value` = a × b (standard matrix product). +// Supports FgMatrix2D×FgMatrix2D, FgMatrix3D×FgMatrix3D, Mat4×Mat4. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgMatrixMultiplication } from "../../fg-math.js"; + +export const matrixMultiplicationDef: FgBlockDef = { + type: FgBlockType.MatrixMultiplication, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgMatrixMultiplication(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/max.ts b/packages/babylon-lite/src/flow-graph/blocks/math/max.ts new file mode 100644 index 0000000000..45960d0fe3 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/max.ts @@ -0,0 +1,22 @@ +// Max (BJS FlowGraphMaxBlock, glTF op `math/max`). +// Data block (PULL): emits `value` via fg-math's `fgMax`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgMax } from "../../fg-math.js"; + +export const maxDef: FgBlockDef = { + type: FgBlockType.Max, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgMax(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/min.ts b/packages/babylon-lite/src/flow-graph/blocks/math/min.ts new file mode 100644 index 0000000000..d65f3a6350 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/min.ts @@ -0,0 +1,22 @@ +// Min (BJS FlowGraphMinBlock, glTF op `math/min`). +// Data block (PULL): emits `value` via fg-math's `fgMin`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgMin } from "../../fg-math.js"; + +export const minDef: FgBlockDef = { + type: FgBlockType.Min, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgMin(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/mix.ts b/packages/babylon-lite/src/flow-graph/blocks/math/mix.ts new file mode 100644 index 0000000000..b8f2d46748 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/mix.ts @@ -0,0 +1,23 @@ +// MathInterpolation (BJS FlowGraphMathInterpolationBlock, glTF op `math/mix`). +// Data block (PULL): linear blend (1 - c)*a + c*b via fg-math (`a`,`b`,`c`). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgMix } from "../../fg-math.js"; + +export const mathInterpolationDef: FgBlockDef = { + type: FgBlockType.MathInterpolation, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any), sockIn("c", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + const c = getDataValue(ctx, env, block, "c"); + setDataValue(ctx, block, "value", fgMix(a, b, c)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/modulo.ts b/packages/babylon-lite/src/flow-graph/blocks/math/modulo.ts new file mode 100644 index 0000000000..9aadf36677 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/modulo.ts @@ -0,0 +1,23 @@ +// Modulo (BJS FlowGraphModuloBlock, glTF op `math/rem`). +// Data block (PULL): emits `value` = a mod b, type-generic across number / +// FlowGraphInteger / Vector2-4 via fg-math's `fgRem`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgRem } from "../../fg-math.js"; + +export const moduloDef: FgBlockDef = { + type: FgBlockType.Modulo, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgRem(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/multiply.ts b/packages/babylon-lite/src/flow-graph/blocks/math/multiply.ts new file mode 100644 index 0000000000..dd06fe016b --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/multiply.ts @@ -0,0 +1,23 @@ +// Multiply (BJS FlowGraphMultiplyBlock, glTF op `math/mul`). +// Data block (PULL): emits `value` = a · b (per-component), type-generic across number / +// FlowGraphInteger / Vector2-4 via fg-math's `fgMul`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgMul } from "../../fg-math.js"; + +export const multiplyDef: FgBlockDef = { + type: FgBlockType.Multiply, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgMul(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/negation.ts b/packages/babylon-lite/src/flow-graph/blocks/math/negation.ts new file mode 100644 index 0000000000..c4747fb6fe --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/negation.ts @@ -0,0 +1,21 @@ +// Negation (BJS FlowGraphNegationBlock, glTF op `math/neg`). +// Data block (PULL): emits `value` via fg-math's `fgNeg`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgNeg } from "../../fg-math.js"; + +export const negationDef: FgBlockDef = { + type: FgBlockType.Negation, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgNeg(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/normalize.ts b/packages/babylon-lite/src/flow-graph/blocks/math/normalize.ts new file mode 100644 index 0000000000..244812c6c3 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/normalize.ts @@ -0,0 +1,21 @@ +// Normalize (BJS FlowGraphNormalizeBlock, glTF op `math/normalize`). +// Data block (PULL): emits `value` via fg-math's `fgNormalize`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgNormalize } from "../../fg-math.js"; + +export const normalizeDef: FgBlockDef = { + type: FgBlockType.Normalize, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgNormalize(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/one-bits-counter.ts b/packages/babylon-lite/src/flow-graph/blocks/math/one-bits-counter.ts new file mode 100644 index 0000000000..fe0dd764c7 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/one-bits-counter.ts @@ -0,0 +1,21 @@ +// OneBitsCounter (BJS FlowGraphOneBitsCounterBlock, glTF op `math/popcnt`). +// Data block (PULL): emits `value` via fg-math's `fgPopcnt`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgPopcnt } from "../../fg-math.js"; + +export const oneBitsCounterDef: FgBlockDef = { + type: FgBlockType.OneBitsCounter, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgPopcnt(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/power.ts b/packages/babylon-lite/src/flow-graph/blocks/math/power.ts new file mode 100644 index 0000000000..7bc6d54b85 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/power.ts @@ -0,0 +1,22 @@ +// Power (BJS FlowGraphPowerBlock, glTF op `math/pow`). +// Data block (PULL): emits `value` via fg-math's `fgPow`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgPow } from "../../fg-math.js"; + +export const powerDef: FgBlockDef = { + type: FgBlockType.Power, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgPow(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/quat-conjugate.ts b/packages/babylon-lite/src/flow-graph/blocks/math/quat-conjugate.ts new file mode 100644 index 0000000000..eb13f96761 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/quat-conjugate.ts @@ -0,0 +1,21 @@ +// QuatConjugate (BJS FlowGraphConjugateBlock, glTF op `math/quatConjugate`). +// Data block (PULL): emits `value` = (-x, -y, -z, w) via fg-math's fgConjugate. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgConjugate } from "../../fg-math.js"; + +export const quatConjugateDef: FgBlockDef = { + type: FgBlockType.Conjugate, + build: () => ({ + dataIn: [sockIn("a", FgType.Quaternion)], + dataOut: [sockOut("value", FgType.Quaternion)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgConjugate(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/quaternion-from-axis-angle.ts b/packages/babylon-lite/src/flow-graph/blocks/math/quaternion-from-axis-angle.ts new file mode 100644 index 0000000000..f218d2f7f5 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/quaternion-from-axis-angle.ts @@ -0,0 +1,24 @@ +// QuaternionFromAxisAngle (BJS FlowGraphQuaternionFromAxisAngleBlock, +// glTF op `math/quatFromAxisAngle`). +// Data block (PULL): `a` = Vec3 axis, `b` = number angle → `value` = Quaternion. +// Does NOT pre-normalize axis (replicates BJS Quaternion.RotationAxis). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgQuaternionFromAxisAngle } from "../../fg-math.js"; + +export const quaternionFromAxisAngleDef: FgBlockDef = { + type: FgBlockType.QuaternionFromAxisAngle, + build: () => ({ + dataIn: [sockIn("a", FgType.Vector3), sockIn("b", FgType.Number)], + dataOut: [sockOut("value", FgType.Quaternion)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgQuaternionFromAxisAngle(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/quaternion-from-directions.ts b/packages/babylon-lite/src/flow-graph/blocks/math/quaternion-from-directions.ts new file mode 100644 index 0000000000..beab3b1e17 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/quaternion-from-directions.ts @@ -0,0 +1,24 @@ +// QuaternionFromDirections (BJS FlowGraphQuaternionFromDirectionsBlock, +// glTF op `math/quatFromDirections`). +// Data block (PULL): `a`, `b` = Vec3 (assumed unit) → `value` = Quaternion. +// Does NOT pre-normalize inputs. Computes cross(a,b) as axis, acos(dot(a,b)) as angle. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgQuaternionFromDirections } from "../../fg-math.js"; + +export const quaternionFromDirectionsDef: FgBlockDef = { + type: FgBlockType.QuaternionFromDirections, + build: () => ({ + dataIn: [sockIn("a", FgType.Vector3), sockIn("b", FgType.Vector3)], + dataOut: [sockOut("value", FgType.Quaternion)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgQuaternionFromDirections(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/quaternion-multiplication.ts b/packages/babylon-lite/src/flow-graph/blocks/math/quaternion-multiplication.ts new file mode 100644 index 0000000000..21e04affd8 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/quaternion-multiplication.ts @@ -0,0 +1,28 @@ +// QuaternionMultiplication (BJS FlowGraphMultiply with `config.type = +// Quaternion`, glTF op `math/quatMul`). Data block (PULL): emits the Hamilton +// product `a ⊗ b` via fg-math's `fgQuatMul`. +// +// LITE DIVERGENCE: BJS reuses the generic Multiply block and switches it to the +// Quaternion path via config. Lite's generic Multiply is component-wise (Vec4 +// and Quat are shape-identical `{x,y,z,w}` at runtime), so quaternion +// multiplication needs its own dedicated block to avoid mangling Vec4 math. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgQuatMul } from "../../fg-math.js"; + +export const quaternionMultiplicationDef: FgBlockDef = { + type: FgBlockType.QuaternionMultiplication, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Quaternion)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgQuatMul(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/rad-to-deg.ts b/packages/babylon-lite/src/flow-graph/blocks/math/rad-to-deg.ts new file mode 100644 index 0000000000..c0af8c9b6e --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/rad-to-deg.ts @@ -0,0 +1,21 @@ +// RadToDeg (BJS FlowGraphRadToDegBlock, glTF op `math/deg`). +// Data block (PULL): emits `value` via fg-math's `fgRadToDeg`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgRadToDeg } from "../../fg-math.js"; + +export const radToDegDef: FgBlockDef = { + type: FgBlockType.RadToDeg, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgRadToDeg(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/random.ts b/packages/babylon-lite/src/flow-graph/blocks/math/random.ts new file mode 100644 index 0000000000..b17b9e7be9 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/random.ts @@ -0,0 +1,24 @@ +// Random (BJS FlowGraphRandomBlock, glTF op `math/random`). +// Data block (PULL): emits a fresh random `value` in [min, max) each pull +// (min/max default 0/1). getDataValue re-runs updateOutputs on every read, so a +// downstream consumer that reads twice in one cascade sees two draws. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgRandom } from "../../fg-math.js"; + +export const randomDef: FgBlockDef = { + type: FgBlockType.Random, + build: () => ({ + dataIn: [sockIn("min", FgType.Number, 0), sockIn("max", FgType.Number, 1)], + dataOut: [sockOut("value", FgType.Number)], + }), + updateOutputs(block, ctx, env) { + const min = getDataValue(ctx, env, block, "min") as number; + const max = getDataValue(ctx, env, block, "max") as number; + setDataValue(ctx, block, "value", fgRandom(min, max)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/rotate2d.ts b/packages/babylon-lite/src/flow-graph/blocks/math/rotate2d.ts new file mode 100644 index 0000000000..96abc1771f --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/rotate2d.ts @@ -0,0 +1,23 @@ +// Rotate2D (BJS FlowGraphRotate2DBlock, glTF op `math/rotate2D`). +// Data block (PULL): rotates Vector2 `a` by `b` radians (CCW) via fg-math. +// glTF input `angle` maps to socket `b` (see declaration-mapper). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgRotate2D } from "../../fg-math.js"; + +export const rotate2DDef: FgBlockDef = { + type: FgBlockType.Rotate2D, + build: () => ({ + dataIn: [sockIn("a", FgType.Vector2), sockIn("b", FgType.Number)], + dataOut: [sockOut("value", FgType.Vector2)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgRotate2D(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/rotate3d.ts b/packages/babylon-lite/src/flow-graph/blocks/math/rotate3d.ts new file mode 100644 index 0000000000..7ca5991f90 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/rotate3d.ts @@ -0,0 +1,22 @@ +// Rotate3D (BJS FlowGraphRotate3DBlock, glTF op `math/rotate3D`). +// Data block (PULL): rotates Vector3 `a` by Quaternion `b` via fg-math. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgRotate3D } from "../../fg-math.js"; + +export const rotate3DDef: FgBlockDef = { + type: FgBlockType.Rotate3D, + build: () => ({ + dataIn: [sockIn("a", FgType.Vector3), sockIn("b", FgType.Quaternion)], + dataOut: [sockOut("value", FgType.Vector3)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgRotate3D(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/round.ts b/packages/babylon-lite/src/flow-graph/blocks/math/round.ts new file mode 100644 index 0000000000..2a975212c4 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/round.ts @@ -0,0 +1,21 @@ +// Round (BJS FlowGraphRoundBlock, glTF op `math/round`). +// Data block (PULL): emits `value` via fg-math's `fgRound`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgRound } from "../../fg-math.js"; + +export const roundDef: FgBlockDef = { + type: FgBlockType.Round, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgRound(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/saturate.ts b/packages/babylon-lite/src/flow-graph/blocks/math/saturate.ts new file mode 100644 index 0000000000..7fb94dac26 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/saturate.ts @@ -0,0 +1,21 @@ +// Saturate (BJS FlowGraphSaturateBlock, glTF op `math/saturate`). +// Data block (PULL): emits `value` via fg-math's `fgSaturate`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgSaturate } from "../../fg-math.js"; + +export const saturateDef: FgBlockDef = { + type: FgBlockType.Saturate, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgSaturate(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/sign.ts b/packages/babylon-lite/src/flow-graph/blocks/math/sign.ts new file mode 100644 index 0000000000..b31e6da6f4 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/sign.ts @@ -0,0 +1,21 @@ +// Sign (BJS FlowGraphSignBlock, glTF op `math/sign`). +// Data block (PULL): emits `value` via fg-math's `fgSign`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgSign } from "../../fg-math.js"; + +export const signDef: FgBlockDef = { + type: FgBlockType.Sign, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgSign(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/sin.ts b/packages/babylon-lite/src/flow-graph/blocks/math/sin.ts new file mode 100644 index 0000000000..8ebc898d00 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/sin.ts @@ -0,0 +1,21 @@ +// Sin (BJS FlowGraphSinBlock, glTF op `math/sin`). +// Data block (PULL): emits `value` via fg-math's `fgSin`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgSin } from "../../fg-math.js"; + +export const sinDef: FgBlockDef = { + type: FgBlockType.Sin, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgSin(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/sinh.ts b/packages/babylon-lite/src/flow-graph/blocks/math/sinh.ts new file mode 100644 index 0000000000..c903f403fb --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/sinh.ts @@ -0,0 +1,21 @@ +// Sinh (BJS FlowGraphSinhBlock, glTF op `math/sinh`). +// Data block (PULL): emits `value` via fg-math's `fgSinh`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgSinh } from "../../fg-math.js"; + +export const sinhDef: FgBlockDef = { + type: FgBlockType.Sinh, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgSinh(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/square-root.ts b/packages/babylon-lite/src/flow-graph/blocks/math/square-root.ts new file mode 100644 index 0000000000..4ca5164ac0 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/square-root.ts @@ -0,0 +1,21 @@ +// SquareRoot (BJS FlowGraphSquareRootBlock, glTF op `math/sqrt`). +// Data block (PULL): emits `value` via fg-math's `fgSqrt`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgSqrt } from "../../fg-math.js"; + +export const squareRootDef: FgBlockDef = { + type: FgBlockType.SquareRoot, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgSqrt(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/subtract.ts b/packages/babylon-lite/src/flow-graph/blocks/math/subtract.ts new file mode 100644 index 0000000000..9424b50f6e --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/subtract.ts @@ -0,0 +1,23 @@ +// Subtract (BJS FlowGraphSubtractBlock, glTF op `math/sub`). +// Data block (PULL): emits `value` = a − b, type-generic across number / +// FlowGraphInteger / Vector2-4 via fg-math's `fgSub`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgSub } from "../../fg-math.js"; + +export const subtractDef: FgBlockDef = { + type: FgBlockType.Subtract, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgSub(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/tan.ts b/packages/babylon-lite/src/flow-graph/blocks/math/tan.ts new file mode 100644 index 0000000000..1c2a9ca4b8 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/tan.ts @@ -0,0 +1,21 @@ +// Tan (BJS FlowGraphTanBlock, glTF op `math/tan`). +// Data block (PULL): emits `value` via fg-math's `fgTan`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgTan } from "../../fg-math.js"; + +export const tanDef: FgBlockDef = { + type: FgBlockType.Tan, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgTan(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/tanh.ts b/packages/babylon-lite/src/flow-graph/blocks/math/tanh.ts new file mode 100644 index 0000000000..b1ec680a25 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/tanh.ts @@ -0,0 +1,21 @@ +// Tanh (BJS FlowGraphTanhBlock, glTF op `math/tanh`). +// Data block (PULL): emits `value` via fg-math's `fgTanh`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgTanh } from "../../fg-math.js"; + +export const tanhDef: FgBlockDef = { + type: FgBlockType.Tanh, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgTanh(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/trailing-zeros.ts b/packages/babylon-lite/src/flow-graph/blocks/math/trailing-zeros.ts new file mode 100644 index 0000000000..11061ab572 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/trailing-zeros.ts @@ -0,0 +1,21 @@ +// TrailingZeros (BJS FlowGraphTrailingZerosBlock, glTF op `math/ctz`). +// Data block (PULL): emits `value` via fg-math's `fgCtz`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgCtz } from "../../fg-math.js"; + +export const trailingZerosDef: FgBlockDef = { + type: FgBlockType.TrailingZeros, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgCtz(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/transform-vector.ts b/packages/babylon-lite/src/flow-graph/blocks/math/transform-vector.ts new file mode 100644 index 0000000000..9cd8c07750 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/transform-vector.ts @@ -0,0 +1,23 @@ +// TransformVector (BJS FlowGraphTransformVectorBlock, glTF op `math/transform`). +// Data block (PULL): `value` = M · v. Input `a` is the vector, `b` is the matrix. +// Dispatches on runtime shape: Vec2×Matrix2D, Vec3×Matrix3D, Vec4×Mat4. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgTransformVector } from "../../fg-math.js"; + +export const transformVectorDef: FgBlockDef = { + type: FgBlockType.TransformVector, + build: () => ({ + dataIn: [sockIn("a", FgType.Any), sockIn("b", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + const b = getDataValue(ctx, env, block, "b"); + setDataValue(ctx, block, "value", fgTransformVector(a, b)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/transpose.ts b/packages/babylon-lite/src/flow-graph/blocks/math/transpose.ts new file mode 100644 index 0000000000..305b53d514 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/transpose.ts @@ -0,0 +1,22 @@ +// Transpose (BJS FlowGraphTransposeBlock, glTF op `math/transpose`). +// Data block (PULL): emits `value` = transpose of input matrix `a`. +// Supports FgMatrix2D, FgMatrix3D, and Mat4 (all column-major). + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgTranspose } from "../../fg-math.js"; + +export const transposeDef: FgBlockDef = { + type: FgBlockType.Transpose, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgTranspose(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/blocks/math/trunc.ts b/packages/babylon-lite/src/flow-graph/blocks/math/trunc.ts new file mode 100644 index 0000000000..402727b07b --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/blocks/math/trunc.ts @@ -0,0 +1,21 @@ +// Trunc (BJS FlowGraphTruncBlock, glTF op `math/trunc`). +// Data block (PULL): emits `value` via fg-math's `fgTrunc`. + +import type { FgBlockDef } from "../../block-def.js"; +import { FgBlockType } from "../../block-type.js"; +import { FgType } from "../../types.js"; +import { getDataValue, setDataValue } from "../../runtime.js"; +import { sockIn, sockOut } from "../../sockets.js"; +import { fgTrunc } from "../../fg-math.js"; + +export const truncDef: FgBlockDef = { + type: FgBlockType.Trunc, + build: () => ({ + dataIn: [sockIn("a", FgType.Any)], + dataOut: [sockOut("value", FgType.Any)], + }), + updateOutputs(block, ctx, env) { + const a = getDataValue(ctx, env, block, "a"); + setDataValue(ctx, block, "value", fgTrunc(a)); + }, +}; diff --git a/packages/babylon-lite/src/flow-graph/context.ts b/packages/babylon-lite/src/flow-graph/context.ts new file mode 100644 index 0000000000..887707355d --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/context.ts @@ -0,0 +1,102 @@ +// Per-execution MUTABLE state (`FgContext`) and per-graph RESOLVED capabilities +// (`FgEnv`) — both plain data. A def is stateless; everything mutable lives here +// keyed by block id. The loader wires `FgEnv` (accessors, animations, caps, bus) +// before the graph runs so blocks never touch the scene directly. + +import type { AnimationGroup } from "../animation/animation-group.js"; +import type { FgBlockDef } from "./block-def.js"; +import type { FgEventBus } from "./event-bus.js"; +import type { FgGraph, FgType, FgValue } from "./types.js"; + +/** Per-execution-instance MUTABLE state — plain data. */ +export interface FgContext { + /** Data transport slots: `${blockId}:${socket}` → last value the producer + * wrote. NOT a validity cache — producers recompute on every pull. */ + readonly connectionValues: Record; + /** Per-block scratch: `${blockId}:${key}` → value (counters, async tokens, + * the data-pull resolving guard, last event payload). */ + readonly executionVariables: Record; + /** Live graph variable values (seeded from `FgGraph.variables`). */ + readonly userVariables: Record; + /** Async task records, ticked each frame (deduped; carry cancel tokens). */ + readonly pending: FgPendingTask[]; + /** glTF graphs are right-handed; drives Z/handedness coercion on read. */ + readonly rightHanded: boolean; + /** @internal Monotonic token source for `addPending`. */ + _tokenSeq: number; +} + +/** One outstanding async task (a delay, an animation). The unique `token` + * enables precise cancellation and lets a block own several tasks at once. */ +export interface FgPendingTask { + readonly blockId: string; + /** Unique per task; a block may own several concurrently. */ + readonly token: number; + canceled: boolean; + /** Set by a def's `onTick` when the task has finished; compacted out after + * the frame's pending loop. */ + done: boolean; + /** Task-local state (e.g. `remainingMs`, `delayIndex`, animation handle). */ + state: Record; +} + +/** Scene-owned capabilities a block may invoke WITHOUT a scene reference. + * Provided by the loader/animation subsystem. All optional — Phase 1 ships + * none; animation/interpolation blocks (Phase 3) consume these. */ +export interface FgCapabilities { + /** Play an animation group (resolved from a glTF animation index). */ + readonly playAnimation?: (group: AnimationGroup, opts?: { speed?: number; loop?: boolean; from?: number; to?: number }) => void; + /** Stop a playing animation group. */ + readonly stopAnimation?: (group: AnimationGroup) => void; + /** Halt a playing animation group at a specific frame (glTF `animation/stopAt`). */ + readonly stopAnimationAt?: (group: AnimationGroup, frame: number) => void; + /** Subscribe to an animation group's end; returns an unsubscribe fn. */ + readonly onAnimationEnd?: (group: AnimationGroup, cb: () => void) => () => void; +} + +/** Per-graph RESOLVED capabilities, wired by the loader. Read-mostly. */ +export interface FgEnv { + readonly graph: FgGraph; + /** Block defs resolved up-front (awaited dynamic imports), type → def. */ + readonly defs: Record; + /** Scene-object accessors resolved from JSON pointers, keyed by pointer id. */ + readonly accessors: Record; + /** Animation handles by glTF animation index. */ + readonly animations: readonly AnimationGroup[]; + /** Scene-owned capabilities blocks may invoke without a scene reference. */ + readonly caps: FgCapabilities; + /** Event bus the scene driver feeds (shared across graphs in a scene). */ + readonly events: FgEventBus; +} + +/** A resolved JSON-pointer accessor onto a scene object property. */ +export interface FgAccessor { + readonly type: FgType; + readonly get: () => FgValue; + readonly set?: (value: FgValue) => void; + readonly target?: unknown; +} + +/** Pre-resolved inputs to `createFgEnv`. Everything scene-dependent is wired + * here by the loader; the runtime only resolves block defs from these + the + * registry. */ +export interface FgWiring { + accessors?: Record; + animations?: readonly AnimationGroup[]; + caps?: FgCapabilities; + /** Shared scene/coordinator bus. A fresh one is created if omitted. */ + events?: FgEventBus; + /** Pre-supplied defs by type — bypasses the dynamic-import registry. Used by + * tests (hand-built defs) and to override/extend the registry. */ + defs?: Record; +} + +/** A flow graph loaded from a file (e.g. glTF KHR_interactivity), carried on the + * `AssetContainer` until `addToScene` wires it to the scene. Spec-agnostic: the + * graph + its JSON-pointer accessors are fully resolved at load time; animations + * and capabilities are bound at attach time from the scene/container. */ +export interface LoadedFlowGraph { + readonly graph: FgGraph; + /** JSON-pointer-string → resolved scene-object accessor. */ + readonly accessors: Record; +} diff --git a/packages/babylon-lite/src/flow-graph/custom-types/fg-integer.ts b/packages/babylon-lite/src/flow-graph/custom-types/fg-integer.ts new file mode 100644 index 0000000000..5bcac1e9d5 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/custom-types/fg-integer.ts @@ -0,0 +1,20 @@ +// glTF `int` is distinct from `float`. Represented as a tagged plain object +// (no class) so type coercion and bitwise ops can detect and operate on it. +// Pure helpers only; zero module-level allocation. + +/** A tagged 32-bit integer value. glTF `int` maps to this. */ +export interface FgInteger { + readonly value: number; + /** @internal Discriminant tag. */ + readonly __fgInt: true; +} + +/** Construct an `FgInteger`, normalizing to a 32-bit signed integer. */ +export function fgInt(n: number): FgInteger { + return { value: n | 0, __fgInt: true }; +} + +/** Type guard for `FgInteger`. */ +export function isFgInt(v: unknown): v is FgInteger { + return typeof v === "object" && v !== null && (v as FgInteger).__fgInt === true; +} diff --git a/packages/babylon-lite/src/flow-graph/custom-types/fg-matrix.ts b/packages/babylon-lite/src/flow-graph/custom-types/fg-matrix.ts new file mode 100644 index 0000000000..2b25afd7a5 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/custom-types/fg-matrix.ts @@ -0,0 +1,52 @@ +// glTF `float2x2` / `float3x3` matrix types. Core math/ only has 4x4 (`Mat4`), +// so the flow-graph subsystem owns these. Plain `Float32Array`-backed tagged +// objects (no class). Pure helpers only; zero module-level allocation. + +/** A 2x2 matrix (glTF `float2x2`), column-major, 4 elements. */ +export interface FgMatrix2D { + readonly m: Float32Array; + /** @internal Discriminant tag. */ + readonly __fgMat: 2; +} + +/** A 3x3 matrix (glTF `float3x3`), column-major, 9 elements. */ +export interface FgMatrix3D { + readonly m: Float32Array; + /** @internal Discriminant tag. */ + readonly __fgMat: 3; +} + +/** Construct an `FgMatrix2D` from 4 column-major elements (defaults to identity). */ +export function fgMatrix2D(elements?: ArrayLike): FgMatrix2D { + const m = new Float32Array(4); + if (elements) { + m.set(elements); + } else { + m[0] = 1; + m[3] = 1; + } + return { m, __fgMat: 2 }; +} + +/** Construct an `FgMatrix3D` from 9 column-major elements (defaults to identity). */ +export function fgMatrix3D(elements?: ArrayLike): FgMatrix3D { + const m = new Float32Array(9); + if (elements) { + m.set(elements); + } else { + m[0] = 1; + m[4] = 1; + m[8] = 1; + } + return { m, __fgMat: 3 }; +} + +/** Type guard for `FgMatrix2D`. */ +export function isFgMatrix2D(v: unknown): v is FgMatrix2D { + return typeof v === "object" && v !== null && (v as FgMatrix2D).__fgMat === 2; +} + +/** Type guard for `FgMatrix3D`. */ +export function isFgMatrix3D(v: unknown): v is FgMatrix3D { + return typeof v === "object" && v !== null && (v as FgMatrix3D).__fgMat === 3; +} diff --git a/packages/babylon-lite/src/flow-graph/event-bus.ts b/packages/babylon-lite/src/flow-graph/event-bus.ts new file mode 100644 index 0000000000..056c2bbe10 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/event-bus.ts @@ -0,0 +1,67 @@ +// Scene/coordinator-scoped event bus feeding flow-graph event blocks. +// Pure data (`FgEventBus`) + standalone subscribe/pump functions — NO methods +// on the interface (GUIDANCE §4b′). One bus is shared across all graphs in a +// scene so multiple `KHR_interactivity` graphs can exchange custom events. + +import { FgEventType } from "./types.js"; + +export { FgEventType }; + +/** Payload carried by a pumped event. `tick` carries `{ deltaMs, deltaTime }`; + * `customEvent` carries `{ eventName, values }`. */ +export interface FgEventPayload { + [key: string]: unknown; +} + +export type FgEventHandler = (payload: FgEventPayload) => void; + +/** Pure-data event bus. Channel name → ordered list of handlers. */ +export interface FgEventBus { + /** @internal channel → handlers, in subscription order. */ + readonly _listeners: Map; +} + +/** Create an empty event bus. (Allocation is inside the factory, never at + * module scope, so the module stays tree-shakable.) */ +export function createFgEventBus(): FgEventBus { + return { _listeners: new Map() }; +} + +/** Subscribe `handler` to a channel. Returns an unsubscribe function. + * Handlers fire in subscription order — callers control ordering by the order + * in which they subscribe (see runtime init-priority). */ +export function subscribeFgEvent(bus: FgEventBus, channel: string, handler: FgEventHandler): () => void { + let handlers = bus._listeners.get(channel); + if (!handlers) { + handlers = []; + bus._listeners.set(channel, handlers); + } + handlers.push(handler); + return () => { + const list = bus._listeners.get(channel); + if (!list) { + return; + } + const i = list.indexOf(handler); + if (i >= 0) { + list.splice(i, 1); + } + }; +} + +/** Dispatch `payload` to every handler subscribed to `channel`. Iterates a + * snapshot so a handler may safely (un)subscribe during dispatch. */ +export function pumpFgEvent(bus: FgEventBus, channel: string, payload: FgEventPayload): void { + const handlers = bus._listeners.get(channel); + if (!handlers || handlers.length === 0) { + return; + } + for (const handler of handlers.slice()) { + handler(payload); + } +} + +/** Remove every listener from the bus. */ +export function clearFgEventBus(bus: FgEventBus): void { + bus._listeners.clear(); +} diff --git a/packages/babylon-lite/src/flow-graph/fg-math.ts b/packages/babylon-lite/src/flow-graph/fg-math.ts new file mode 100644 index 0000000000..8fcc50fe82 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/fg-math.ts @@ -0,0 +1,1098 @@ +// Block-specific math, lazily imported by the blocks that need it so core +// `math/` (Vec3-centric, minimal) stays untouched and non-interactivity scenes +// stay byte-identical (GUIDANCE pillar: tree-shakable, no core bloat). +// +// Ops here are TYPE-GENERIC: they branch on the runtime value shape +// (number | FgInteger | Vec2 | Vec3 | Vec4), mirroring BJS's per-component +// handling of the same `math/*` ops. Add more dispatchers (fgSub, fgMul, …) in +// Phase 3 as the math block library broadens. + +import { fgInt, isFgInt } from "./custom-types/fg-integer.js"; +import { isFgMatrix2D, isFgMatrix3D, fgMatrix2D, fgMatrix3D } from "./custom-types/fg-matrix.js"; +import type { FgMatrix2D, FgMatrix3D } from "./custom-types/fg-matrix.js"; +import type { FgValue, Vec2 } from "./types.js"; +import type { Mat4, Quat, Vec3, Vec4 } from "../math/types.js"; +import { crossVec3 } from "../math/cross-vec3.js"; +import { dotVec3 } from "../math/dot-vec3.js"; +import { mat4Compose } from "../math/mat4-compose.js"; +import { mat4Decompose } from "../math/mat4-decompose.js"; + +function isVec2(v: unknown): v is Vec2 { + return typeof v === "object" && v !== null && "x" in v && "y" in v && !("z" in v); +} +/** Runtime guard for `Mat4` (column-major Float32Array of length 16). */ +function isMat4(v: unknown): v is Mat4 { + return (v instanceof Float32Array || v instanceof Float64Array) && v.length === 16; +} +function isVec3(v: unknown): v is Vec3 { + return typeof v === "object" && v !== null && "x" in v && "y" in v && "z" in v && !("w" in v); +} +function isVec4(v: unknown): v is Vec4 { + return typeof v === "object" && v !== null && "x" in v && "y" in v && "z" in v && "w" in v; +} + +/** Apply a component-wise binary op across number / FlowGraphInteger / Vector2-4. + * Mixed/unknown shapes return the left operand (BJS behaviour) so the runtime + * loop never throws. */ +function binary(a: FgValue, b: FgValue, f: (x: number, y: number) => number): FgValue { + if (isFgInt(a) && isFgInt(b)) { + return fgInt(f(a.value, b.value)); + } + if (typeof a === "number" && typeof b === "number") { + return f(a, b); + } + if (isVec4(a) && isVec4(b)) { + return { x: f(a.x, b.x), y: f(a.y, b.y), z: f(a.z, b.z), w: f(a.w, b.w) }; + } + if (isVec3(a) && isVec3(b)) { + return { x: f(a.x, b.x), y: f(a.y, b.y), z: f(a.z, b.z) }; + } + if (isVec2(a) && isVec2(b)) { + return { x: f(a.x, b.x), y: f(a.y, b.y) }; + } + return a; +} + +/** Apply a component-wise unary op across number / FlowGraphInteger / Vector2-4. */ +function unary(a: FgValue, f: (x: number) => number): FgValue { + if (isFgInt(a)) { + return fgInt(f(a.value)); + } + if (typeof a === "number") { + return f(a); + } + if (isVec4(a)) { + return { x: f(a.x), y: f(a.y), z: f(a.z), w: f(a.w) }; + } + if (isVec3(a)) { + return { x: f(a.x), y: f(a.y), z: f(a.z) }; + } + if (isVec2(a)) { + return { x: f(a.x), y: f(a.y) }; + } + return a; +} + +/** Type-generic component-wise add (glTF `math/add`). */ +export function fgAdd(a: FgValue, b: FgValue): FgValue { + return binary(a, b, (x, y) => x + y); +} + +/** Type-generic component-wise subtract, a − b (glTF `math/sub`). */ +export function fgSub(a: FgValue, b: FgValue): FgValue { + return binary(a, b, (x, y) => x - y); +} + +/** Type-generic component-wise (Hadamard) multiply (glTF `math/mul`, which is + * per-component for vectors — `useMatrixPerComponent` in BJS). */ +export function fgMul(a: FgValue, b: FgValue): FgValue { + return binary(a, b, (x, y) => x * y); +} + +/** Type-generic component-wise divide, a ÷ b (glTF `math/div`). */ +export function fgDiv(a: FgValue, b: FgValue): FgValue { + return binary(a, b, (x, y) => x / y); +} + +/** Type-generic component-wise remainder, a − b·trunc(a/b) (glTF `math/rem`, + * matching JS `%`). */ +export function fgRem(a: FgValue, b: FgValue): FgValue { + return binary(a, b, (x, y) => x % y); +} + +/** Type-generic component-wise absolute value (glTF `math/abs`). */ +export function fgAbs(a: FgValue): FgValue { + return unary(a, Math.abs); +} + +/** Type-generic component-wise floor (glTF `math/floor`). */ +export function fgFloor(a: FgValue): FgValue { + return unary(a, Math.floor); +} + +/** Type-generic component-wise clamp, min(max(a, b), c) (glTF `math/clamp`, + * b = min, c = max). */ +export function fgClamp(a: FgValue, b: FgValue, c: FgValue): FgValue { + const lo = binary(a, b, (x, y) => Math.max(x, y)); + return binary(lo, c, (x, y) => Math.min(x, y)); +} + +/** Scalar less-than, `a < b` → boolean (glTF `math/lt`). Operates on the numeric + * payload of number / FlowGraphInteger; returns false for non-scalar shapes. */ +export function fgLt(a: FgValue, b: FgValue): boolean { + const x = isFgInt(a) ? a.value : a; + const y = isFgInt(b) ? b.value : b; + if (typeof x === "number" && typeof y === "number") { + return x < y; + } + return false; +} + +/** Combine two scalars into a Vector2 (glTF `math/combine2`). */ +export function fgCombine2(a: FgValue, b: FgValue): Vec2 { + const x = isFgInt(a) ? a.value : (a as number); + const y = isFgInt(b) ? b.value : (b as number); + return { x: x ?? 0, y: y ?? 0 }; +} + +/** Extract a Vector2's components (glTF `math/extract2`) → `[x, y]`. */ +export function fgExtract2(v: FgValue): [number, number] { + if (isVec2(v) || isVec3(v) || isVec4(v)) { + return [v.x, v.y]; + } + return [0, 0]; +} + +// ─── Phase 3 helpers ──────────────────────────────────────────────────────── + +/** Numeric payload of a number / FlowGraphInteger; `NaN` for other shapes. */ +function num(v: FgValue): number { + if (isFgInt(v)) { + return v.value; + } + return typeof v === "number" ? v : NaN; +} +/** Numeric payload, or 0 when the value is not scalar (combine inputs). */ +function num0(v: FgValue): number { + const n = num(v); + return Number.isNaN(n) && !(typeof v === "number") ? 0 : n; +} +/** True for number | FlowGraphInteger (the comparison/bitwise scalar domain). */ +function isNumeric(v: FgValue): boolean { + return typeof v === "number" || isFgInt(v); +} +/** Coerce to a 32-bit signed int payload (integer bitwise ops). */ +function toI(v: FgValue): number { + return (isFgInt(v) ? v.value : (v as number)) | 0; +} + +/** Component-wise ternary across number / FlowGraphInteger / Vector2-4. */ +function ternary(a: FgValue, b: FgValue, c: FgValue, f: (x: number, y: number, z: number) => number): FgValue { + if (isFgInt(a) && isFgInt(b) && isFgInt(c)) { + return fgInt(f(a.value, b.value, c.value)); + } + if (typeof a === "number" && typeof b === "number" && typeof c === "number") { + return f(a, b, c); + } + if (isVec4(a) && isVec4(b) && isVec4(c)) { + return { x: f(a.x, b.x, c.x), y: f(a.y, b.y, c.y), z: f(a.z, b.z, c.z), w: f(a.w, b.w, c.w) }; + } + if (isVec3(a) && isVec3(b) && isVec3(c)) { + return { x: f(a.x, b.x, c.x), y: f(a.y, b.y, c.y), z: f(a.z, b.z, c.z) }; + } + if (isVec2(a) && isVec2(b) && isVec2(c)) { + return { x: f(a.x, b.x, c.x), y: f(a.y, b.y, c.y) }; + } + return a; +} + +// ─── Arithmetic (binary) ──────────────────────────────────────────────────── + +/** Component-wise minimum (glTF `math/min`). */ +export function fgMin(a: FgValue, b: FgValue): FgValue { + return binary(a, b, Math.min); +} +/** Component-wise maximum (glTF `math/max`). */ +export function fgMax(a: FgValue, b: FgValue): FgValue { + return binary(a, b, Math.max); +} +/** Component-wise power, a^b (glTF `math/pow`). */ +export function fgPow(a: FgValue, b: FgValue): FgValue { + return binary(a, b, Math.pow); +} +/** Component-wise atan2(a, b) where a = y, b = x (glTF `math/atan2`). */ +export function fgAtan2(a: FgValue, b: FgValue): FgValue { + return binary(a, b, Math.atan2); +} + +// ─── Unary scalar / component-wise ────────────────────────────────────────── + +/** Component-wise negation, −a (glTF `math/neg`). */ +export function fgNeg(a: FgValue): FgValue { + return unary(a, (x) => -x); +} +/** Component-wise sign (glTF `math/sign`). */ +export function fgSign(a: FgValue): FgValue { + return unary(a, Math.sign); +} +/** Component-wise ceil (glTF `math/ceil`). */ +export function fgCeil(a: FgValue): FgValue { + return unary(a, Math.ceil); +} +/** Component-wise round, half toward +∞ (glTF `math/round`). */ +export function fgRound(a: FgValue): FgValue { + return unary(a, Math.round); +} +/** Component-wise truncation toward zero (glTF `math/trunc`). */ +export function fgTrunc(a: FgValue): FgValue { + return unary(a, Math.trunc); +} +/** Component-wise fractional part, `x − floor(x)` (glTF `math/fract`). */ +export function fgFract(a: FgValue): FgValue { + return unary(a, (x) => x - Math.floor(x)); +} +/** Component-wise clamp to [0, 1] (glTF `math/saturate`). */ +export function fgSaturate(a: FgValue): FgValue { + return unary(a, (x) => Math.min(Math.max(x, 0), 1)); +} +/** Component-wise square root (glTF `math/sqrt`). */ +export function fgSqrt(a: FgValue): FgValue { + return unary(a, Math.sqrt); +} +/** Component-wise cube root (glTF `math/cbrt`). */ +export function fgCbrt(a: FgValue): FgValue { + return unary(a, Math.cbrt); +} +/** Component-wise e^x (glTF `math/exp`). */ +export function fgExp(a: FgValue): FgValue { + return unary(a, Math.exp); +} +/** Component-wise natural log (glTF `math/log`). */ +export function fgLog(a: FgValue): FgValue { + return unary(a, Math.log); +} +/** Component-wise base-2 log (glTF `math/log2`). */ +export function fgLog2(a: FgValue): FgValue { + return unary(a, Math.log2); +} +/** Component-wise base-10 log (glTF `math/log10`). */ +export function fgLog10(a: FgValue): FgValue { + return unary(a, Math.log10); +} +/** Component-wise degrees → radians (glTF `math/rad`). */ +export function fgDegToRad(a: FgValue): FgValue { + return unary(a, (x) => (x * Math.PI) / 180); +} +/** Component-wise radians → degrees (glTF `math/deg`). */ +export function fgRadToDeg(a: FgValue): FgValue { + return unary(a, (x) => (x * 180) / Math.PI); +} +/** Component-wise sine (glTF `math/sin`). */ +export function fgSin(a: FgValue): FgValue { + return unary(a, Math.sin); +} +/** Component-wise cosine (glTF `math/cos`). */ +export function fgCos(a: FgValue): FgValue { + return unary(a, Math.cos); +} +/** Component-wise tangent (glTF `math/tan`). */ +export function fgTan(a: FgValue): FgValue { + return unary(a, Math.tan); +} +/** Component-wise arcsine (glTF `math/asin`). */ +export function fgAsin(a: FgValue): FgValue { + return unary(a, Math.asin); +} +/** Component-wise arccosine (glTF `math/acos`). */ +export function fgAcos(a: FgValue): FgValue { + return unary(a, Math.acos); +} +/** Component-wise arctangent (glTF `math/atan`). */ +export function fgAtan(a: FgValue): FgValue { + return unary(a, Math.atan); +} +/** Component-wise hyperbolic sine (glTF `math/sinh`). */ +export function fgSinh(a: FgValue): FgValue { + return unary(a, Math.sinh); +} +/** Component-wise hyperbolic cosine (glTF `math/cosh`). */ +export function fgCosh(a: FgValue): FgValue { + return unary(a, Math.cosh); +} +/** Component-wise hyperbolic tangent (glTF `math/tanh`). */ +export function fgTanh(a: FgValue): FgValue { + return unary(a, Math.tanh); +} +/** Component-wise inverse hyperbolic sine (glTF `math/asinh`). */ +export function fgAsinh(a: FgValue): FgValue { + return unary(a, Math.asinh); +} +/** Component-wise inverse hyperbolic cosine (glTF `math/acosh`). */ +export function fgAcosh(a: FgValue): FgValue { + return unary(a, Math.acosh); +} +/** Component-wise inverse hyperbolic tangent (glTF `math/atanh`). */ +export function fgAtanh(a: FgValue): FgValue { + return unary(a, Math.atanh); +} + +// ─── Interpolation (ternary) ──────────────────────────────────────────────── + +/** Linear blend `(1 − t)·a + t·b` (glTF `math/mix`). Supports vector a/b with a + * scalar `t`, mirroring BJS's component-wise interpolation. */ +export function fgMix(a: FgValue, b: FgValue, t: FgValue): FgValue { + const tv = num(t); + if (Number.isNaN(tv)) { + return a; + } + const lerp = (x: number, y: number): number => (1 - tv) * x + tv * y; + if (isVec4(a) && isVec4(b)) { + return { x: lerp(a.x, b.x), y: lerp(a.y, b.y), z: lerp(a.z, b.z), w: lerp(a.w, b.w) }; + } + if (isVec3(a) && isVec3(b)) { + return { x: lerp(a.x, b.x), y: lerp(a.y, b.y), z: lerp(a.z, b.z) }; + } + if (isVec2(a) && isVec2(b)) { + return { x: lerp(a.x, b.x), y: lerp(a.y, b.y) }; + } + if (isNumeric(a) && isNumeric(b)) { + const r = lerp(num(a), num(b)); + return isFgInt(a) && isFgInt(b) ? fgInt(r) : r; + } + return ternary(a, b, t, (x, y, z) => (1 - z) * x + z * y); +} + +// ─── Comparison (→ boolean) ───────────────────────────────────────────────── + +/** Strict equality (glTF `math/eq`): exact, zero-tolerance, component-wise for + * vectors/quaternions; mismatched types compare unequal (BJS behaviour). */ +export function fgEq(a: FgValue, b: FgValue): boolean { + if (isFgInt(a) && isFgInt(b)) { + return a.value === b.value; + } + if (isFgInt(a) !== isFgInt(b)) { + return false; + } + if (isVec4(a) && isVec4(b)) { + return a.x === b.x && a.y === b.y && a.z === b.z && a.w === b.w; + } + if (isVec3(a) && isVec3(b)) { + return a.x === b.x && a.y === b.y && a.z === b.z; + } + if (isVec2(a) && isVec2(b)) { + return a.x === b.x && a.y === b.y; + } + return a === b; +} +/** Scalar `a ≤ b` (glTF `math/le`); false for non-scalar shapes. */ +export function fgLe(a: FgValue, b: FgValue): boolean { + return isNumeric(a) && isNumeric(b) ? num(a) <= num(b) : false; +} +/** Scalar `a > b` (glTF `math/gt`); false for non-scalar shapes. */ +export function fgGt(a: FgValue, b: FgValue): boolean { + return isNumeric(a) && isNumeric(b) ? num(a) > num(b) : false; +} +/** Scalar `a ≥ b` (glTF `math/ge`); false for non-scalar shapes. */ +export function fgGe(a: FgValue, b: FgValue): boolean { + return isNumeric(a) && isNumeric(b) ? num(a) >= num(b) : false; +} +/** Scalar NaN test (glTF `math/isNaN`); false for non-scalar shapes. */ +export function fgIsNaN(a: FgValue): boolean { + return isNumeric(a) ? Number.isNaN(num(a)) : false; +} +/** Scalar non-finite test — ±Infinity and NaN (glTF `math/isInf`). */ +export function fgIsInf(a: FgValue): boolean { + return isNumeric(a) ? !Number.isFinite(num(a)) : false; +} + +/** Random value in `[min, max)` (glTF `math/random`); defaults to `[0, 1)`. */ +export function fgRandom(min = 0, max = 1): number { + return Math.random() * (max - min) + min; +} + +// ─── Boolean / bitwise (type-dispatched) ──────────────────────────────────── + +/** Logical/bitwise AND (glTF `math/and`): `&&` for booleans, `&` for ints. */ +export function fgAnd(a: FgValue, b: FgValue): FgValue { + if (typeof a === "boolean" && typeof b === "boolean") { + return a && b; + } + if (typeof a === "number" && typeof b === "number") { + return a & b; + } + if (isFgInt(a) && isFgInt(b)) { + return fgInt(a.value & b.value); + } + return a; +} +/** Logical/bitwise OR (glTF `math/or`): `||` for booleans, `|` for ints. */ +export function fgOr(a: FgValue, b: FgValue): FgValue { + if (typeof a === "boolean" && typeof b === "boolean") { + return a || b; + } + if (typeof a === "number" && typeof b === "number") { + return a | b; + } + if (isFgInt(a) && isFgInt(b)) { + return fgInt(a.value | b.value); + } + return a; +} +/** Logical/bitwise XOR (glTF `math/xor`): `a≠b` for booleans, `^` for ints. */ +export function fgXor(a: FgValue, b: FgValue): FgValue { + if (typeof a === "boolean" && typeof b === "boolean") { + return a !== b; + } + if (typeof a === "number" && typeof b === "number") { + return a ^ b; + } + if (isFgInt(a) && isFgInt(b)) { + return fgInt(a.value ^ b.value); + } + return a; +} +/** Logical/bitwise NOT (glTF `math/not`): `!a` for booleans, `~a` for ints. */ +export function fgNot(a: FgValue): FgValue { + if (typeof a === "boolean") { + return !a; + } + if (typeof a === "number") { + return ~a; + } + if (isFgInt(a)) { + return fgInt(~a.value); + } + return a; +} + +// ─── Integer bitwise ──────────────────────────────────────────────────────── + +/** Logical left shift, `a << b` (glTF `math/lsl`). */ +export function fgLsl(a: FgValue, b: FgValue): FgValue { + return fgInt(toI(a) << toI(b)); +} +/** Arithmetic right shift, `a >> b`, sign-extending (glTF `math/asr`). */ +export function fgAsr(a: FgValue, b: FgValue): FgValue { + return fgInt(toI(a) >> toI(b)); +} +/** Count leading zero bits over 32 bits (glTF `math/clz`). */ +export function fgClz(a: FgValue): FgValue { + return fgInt(Math.clz32(toI(a))); +} +/** Count trailing zero bits; 32 for input 0 (glTF `math/ctz`). */ +export function fgCtz(a: FgValue): FgValue { + const n = toI(a); + return fgInt(n ? 31 - Math.clz32(n & -n) : 32); +} +/** Population count — number of set bits over 32 bits (glTF `math/popcnt`). */ +export function fgPopcnt(a: FgValue): FgValue { + let n = toI(a) >>> 0; + let r = 0; + while (n) { + r += n & 1; + n >>>= 1; + } + return fgInt(r); +} + +// ─── Vector ops ───────────────────────────────────────────────────────────── + +/** Euclidean length of a Vector2/3/4 or Quaternion (glTF `math/length`). */ +export function fgLength(a: FgValue): number { + if (isVec2(a)) { + return Math.hypot(a.x, a.y); + } + if (isVec4(a)) { + return Math.hypot(a.x, a.y, a.z, a.w); + } + if (isVec3(a)) { + return Math.hypot(a.x, a.y, a.z); + } + return 0; +} +/** Normalize a Vector2/3/4 or Quaternion to unit length; zero-vector stays zero + * (glTF `math/normalize`). */ +export function fgNormalize(a: FgValue): FgValue { + const len = fgLength(a); + const s = len === 0 ? 0 : 1 / len; + if (isVec2(a)) { + return { x: a.x * s, y: a.y * s }; + } + if (isVec4(a)) { + return { x: a.x * s, y: a.y * s, z: a.z * s, w: a.w * s }; + } + if (isVec3(a)) { + return { x: a.x * s, y: a.y * s, z: a.z * s }; + } + return a; +} +/** Dot product of two same-size vectors/quaternions (glTF `math/dot`). */ +export function fgDot(a: FgValue, b: FgValue): number { + if (isVec2(a) && isVec2(b)) { + return a.x * b.x + a.y * b.y; + } + if (isVec4(a) && isVec4(b)) { + return a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w; + } + if (isVec3(a) && isVec3(b)) { + return dotVec3(a, b); + } + return 0; +} +/** 3D cross product (glTF `math/cross`); Vector3 only. */ +export function fgCross(a: FgValue, b: FgValue): FgValue { + if (isVec3(a) && isVec3(b)) { + return crossVec3(a, b); + } + return a; +} +/** Rotate a Vector2 by `angle` radians, CCW (glTF `math/rotate2D`). */ +export function fgRotate2D(a: FgValue, angle: FgValue): FgValue { + if (isVec2(a) && typeof angle === "number") { + const c = Math.cos(angle); + const s = Math.sin(angle); + return { x: c * a.x - s * a.y, y: s * a.x + c * a.y }; + } + return a; +} +/** Rotate a Vector3 by a Quaternion (glTF `math/rotate3D`). */ +export function fgRotate3D(a: FgValue, q: FgValue): FgValue { + if (isVec3(a) && isVec4(q)) { + const { x, y, z } = a; + const tx = 2 * (q.y * z - q.z * y); + const ty = 2 * (q.z * x - q.x * z); + const tz = 2 * (q.x * y - q.y * x); + return { + x: x + q.w * tx + (q.y * tz - q.z * ty), + y: y + q.w * ty + (q.z * tx - q.x * tz), + z: z + q.w * tz + (q.x * ty - q.y * tx), + }; + } + return a; +} + +// ─── Combine / extract (vectors) ──────────────────────────────────────────── + +/** Combine three scalars into a Vector3 (glTF `math/combine3`). */ +export function fgCombine3(a: FgValue, b: FgValue, c: FgValue): Vec3 { + return { x: num0(a), y: num0(b), z: num0(c) }; +} +/** Combine four scalars into a Vector4 (glTF `math/combine4`). */ +export function fgCombine4(a: FgValue, b: FgValue, c: FgValue, d: FgValue): Vec4 { + return { x: num0(a), y: num0(b), z: num0(c), w: num0(d) }; +} +/** Extract a Vector3's components (glTF `math/extract3`) → `[x, y, z]`. */ +export function fgExtract3(v: FgValue): [number, number, number] { + if (isVec3(v) || isVec4(v)) { + return [v.x, v.y, v.z]; + } + return [0, 0, 0]; +} +/** Extract a Vector4's / Quaternion's components (glTF `math/extract4`). */ +export function fgExtract4(v: FgValue): [number, number, number, number] { + if (isVec4(v)) { + return [v.x, v.y, v.z, v.w]; + } + return [0, 0, 0, 0]; +} + +/** Quaternion conjugate, `(−x, −y, −z, w)` (glTF `math/quatConjugate`). */ +export function fgConjugate(a: FgValue): FgValue { + if (isVec4(a)) { + return { x: -a.x, y: -a.y, z: -a.z, w: a.w } as Quat; + } + return a; +} + +/** + * Hamilton product of two quaternions, `a ⊗ b` (glTF `math/quatMul`). Self- + * contained (no core import) so the block chunk never shares a core module with + * non-flow-graph scene chunks. Convention matches BJS `Quaternion.multiply` + * (`a.multiply(b)`). Non-quaternion inputs return `a` unchanged. */ +export function fgQuatMul(a: FgValue, b: FgValue): FgValue { + if (isVec4(a) && isVec4(b)) { + return { + x: a.x * b.w + a.w * b.x + a.y * b.z - a.z * b.y, + y: a.y * b.w + a.w * b.y + a.z * b.x - a.x * b.z, + z: a.z * b.w + a.w * b.z + a.x * b.y - a.y * b.x, + w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z, + } as Quat; + } + return a; +} + +// ─── Matrix ops (Phase 3f) ──────────────────────────────────────────────────── + +/** + * Transform a vector by a matrix (`M · v`, column-major). + * + * Dispatch: + * - `Vec2 × FgMatrix2D` — 2×2 column-major multiply + * - `Vec3 × FgMatrix3D` — 3×3 column-major multiply + * - `Vec4 × Mat4` — 4×4 column-major multiply (glTF `math/transform`) + * + * Equivalent to BJS `v × M` (row-vector) because Lite stores column-major while + * BJS stores row-major: the mathematical result is identical. + */ +export function fgTransformVector(v: FgValue, m: FgValue): FgValue { + if (isVec2(v) && isFgMatrix2D(m)) { + const mm = m.m; + return { x: mm[0]! * v.x + mm[2]! * v.y, y: mm[1]! * v.x + mm[3]! * v.y }; + } + if (isVec3(v) && isFgMatrix3D(m)) { + const mm = m.m; + return { + x: mm[0]! * v.x + mm[3]! * v.y + mm[6]! * v.z, + y: mm[1]! * v.x + mm[4]! * v.y + mm[7]! * v.z, + z: mm[2]! * v.x + mm[5]! * v.y + mm[8]! * v.z, + }; + } + if (isVec4(v) && isMat4(m)) { + return { + x: m[0]! * v.x + m[4]! * v.y + m[8]! * v.z + m[12]! * v.w, + y: m[1]! * v.x + m[5]! * v.y + m[9]! * v.z + m[13]! * v.w, + z: m[2]! * v.x + m[6]! * v.y + m[10]! * v.z + m[14]! * v.w, + w: m[3]! * v.x + m[7]! * v.y + m[11]! * v.z + m[15]! * v.w, + }; + } + return v; +} + +/** + * Build an `FgMatrix2D` from 4 column-major scalar inputs (glTF `math/combine2x2`). + * Inputs map directly to storage: `input_i = m[i]`. + */ +export function fgCombineMatrix2D(inputs: readonly number[]): FgMatrix2D { + return fgMatrix2D(inputs); +} + +/** + * Build an `FgMatrix3D` from 9 column-major scalar inputs (glTF `math/combine3x3`). + * Inputs map directly to storage: `input_i = m[i]`. + */ +export function fgCombineMatrix3D(inputs: readonly number[]): FgMatrix3D { + return fgMatrix3D(inputs); +} + +/** + * Build a `Mat4` from 16 column-major scalar inputs (glTF `math/combine4x4`). + * Inputs map directly to storage: `input_i = m[i]`. + */ +export function fgCombineMatrix(inputs: readonly number[]): Mat4 { + const out = new Float32Array(16); + for (let i = 0; i < 16; i++) { + out[i] = inputs[i] ?? 0; + } + return out as unknown as Mat4; +} + +/** + * Extract `FgMatrix2D` elements in column-major order (glTF `math/extract2x2`). + * Output `output_i = m.m[i]` — Lite's column-major storage order. + */ +export function fgExtractMatrix2D(m: FgValue): [number, number, number, number] { + if (isFgMatrix2D(m)) { + return [m.m[0]!, m.m[1]!, m.m[2]!, m.m[3]!]; + } + return [0, 0, 0, 0]; +} + +/** + * Extract `FgMatrix3D` elements in column-major order (glTF `math/extract3x3`). + * Output `output_i = m.m[i]` — Lite's column-major storage order. + */ +export function fgExtractMatrix3D(m: FgValue): [number, number, number, number, number, number, number, number, number] { + if (isFgMatrix3D(m)) { + return [m.m[0]!, m.m[1]!, m.m[2]!, m.m[3]!, m.m[4]!, m.m[5]!, m.m[6]!, m.m[7]!, m.m[8]!]; + } + return [0, 0, 0, 0, 0, 0, 0, 0, 0]; +} + +/** + * Extract `Mat4` elements in column-major order (glTF `math/extract4x4`). + * Output `output_i = m[i]` — Lite's column-major storage order. + */ +export function fgExtractMatrix(m: FgValue): number[] { + if (isMat4(m)) { + return Array.from({ length: 16 }, (_, i) => m[i]!); + } + return Array.from({ length: 16 }, () => 0); +} + +/** + * Transpose a matrix (glTF `math/transpose`). Column-major in, column-major out. + * Supports `FgMatrix2D`, `FgMatrix3D`, and `Mat4`. + */ +export function fgTranspose(m: FgValue): FgValue { + if (isFgMatrix2D(m)) { + const mm = m.m; + // swap off-diagonal: [m0,m1,m2,m3] → [m0,m2,m1,m3] + return fgMatrix2D([mm[0]!, mm[2]!, mm[1]!, mm[3]!]); + } + if (isFgMatrix3D(m)) { + const mm = m.m; + return fgMatrix3D([mm[0]!, mm[3]!, mm[6]!, mm[1]!, mm[4]!, mm[7]!, mm[2]!, mm[5]!, mm[8]!]); + } + if (isMat4(m)) { + const out = new Float32Array(16); + for (let i = 0; i < 4; i++) { + for (let j = 0; j < 4; j++) { + out[j * 4 + i] = m[i * 4 + j]!; + } + } + return out as unknown as Mat4; + } + return m; +} + +/** + * Compute the scalar determinant of a matrix (glTF `math/determinant`). + * Determinant is layout-independent (`det(M) = det(M^T)`), so the column-major + * formula yields the same scalar as BJS's row-major formula. + */ +export function fgDeterminant(m: FgValue): number { + if (isFgMatrix2D(m)) { + const mm = m.m; + return mm[0]! * mm[3]! - mm[1]! * mm[2]!; + } + if (isFgMatrix3D(m)) { + const mm = m.m; + return mm[0]! * (mm[4]! * mm[8]! - mm[7]! * mm[5]!) - mm[3]! * (mm[1]! * mm[8]! - mm[7]! * mm[2]!) + mm[6]! * (mm[1]! * mm[5]! - mm[4]! * mm[2]!); + } + if (isMat4(m)) { + const a00 = m[0]!, + a01 = m[1]!, + a02 = m[2]!, + a03 = m[3]!; + const a10 = m[4]!, + a11 = m[5]!, + a12 = m[6]!, + a13 = m[7]!; + const a20 = m[8]!, + a21 = m[9]!, + a22 = m[10]!, + a23 = m[11]!; + const a30 = m[12]!, + a31 = m[13]!, + a32 = m[14]!, + a33 = m[15]!; + const b00 = a00 * a11 - a01 * a10, + b01 = a00 * a12 - a02 * a10; + const b02 = a00 * a13 - a03 * a10, + b03 = a01 * a12 - a02 * a11; + const b04 = a01 * a13 - a03 * a11, + b05 = a02 * a13 - a03 * a12; + const b06 = a20 * a31 - a21 * a30, + b07 = a20 * a32 - a22 * a30; + const b08 = a20 * a33 - a23 * a30, + b09 = a21 * a32 - a22 * a31; + const b10 = a21 * a33 - a23 * a31, + b11 = a22 * a33 - a23 * a32; + return b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; + } + return 0; +} + +// ─── Local Mat4 invert/multiply ─────────────────────────────────────────────── +// fg-math keeps its own column-major Mat4 invert/multiply instead of importing +// the core `mat4Invert`/`mat4Multiply`. Those core modules are also used by the +// skeleton/animation runtime, and the flow-graph block chunks are emitted into +// EVERY glTF scene's bundle (via the lazy gltf-feature-interactivity → getBlockDef +// chunk graph). Sharing the core modules would make Rollup hoist them into shared +// chunks that the live skeleton chunk then loads at runtime, perturbing existing +// scenes' bundles and breaking their size ceilings. Local copies keep those bytes +// inside the lazily-loaded flow-graph block chunks only. Math is identical to core. + +/** Inverse of a column-major Mat4. Returns null when singular. */ +function fgMat4Invert(input: Mat4): Mat4 | null { + const m = input as unknown as Float32Array; + const a00 = m[0]!, + a01 = m[1]!, + a02 = m[2]!, + a03 = m[3]!; + const a10 = m[4]!, + a11 = m[5]!, + a12 = m[6]!, + a13 = m[7]!; + const a20 = m[8]!, + a21 = m[9]!, + a22 = m[10]!, + a23 = m[11]!; + const a30 = m[12]!, + a31 = m[13]!, + a32 = m[14]!, + a33 = m[15]!; + + const b00 = a00 * a11 - a01 * a10; + const b01 = a00 * a12 - a02 * a10; + const b02 = a00 * a13 - a03 * a10; + const b03 = a01 * a12 - a02 * a11; + const b04 = a01 * a13 - a03 * a11; + const b05 = a02 * a13 - a03 * a12; + const b06 = a20 * a31 - a21 * a30; + const b07 = a20 * a32 - a22 * a30; + const b08 = a20 * a33 - a23 * a30; + const b09 = a21 * a32 - a22 * a31; + const b10 = a21 * a33 - a23 * a31; + const b11 = a22 * a33 - a23 * a32; + + let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; + if (Math.abs(det) < 1e-10) { + return null; + } + det = 1 / det; + + const out = new Float32Array(16); + out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det; + out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det; + out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det; + out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det; + out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det; + out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det; + out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det; + out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det; + out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det; + out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det; + out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det; + out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det; + out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det; + out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det; + out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det; + out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det; + return out as unknown as Mat4; +} + +/** Column-major Mat4 product: `out = a * b`. */ +function fgMat4Multiply(a: Mat4, b: Mat4): Mat4 { + const x = a as unknown as Float32Array; + const y = b as unknown as Float32Array; + const a0 = x[0]!, + a1 = x[1]!, + a2 = x[2]!, + a3 = x[3]!; + const a4 = x[4]!, + a5 = x[5]!, + a6 = x[6]!, + a7 = x[7]!; + const a8 = x[8]!, + a9 = x[9]!, + a10 = x[10]!, + a11 = x[11]!; + const a12 = x[12]!, + a13 = x[13]!, + a14 = x[14]!, + a15 = x[15]!; + const out = new Float32Array(16); + let b0 = y[0]!, + b1 = y[1]!, + b2 = y[2]!, + b3 = y[3]!; + out[0] = a0 * b0 + a4 * b1 + a8 * b2 + a12 * b3; + out[1] = a1 * b0 + a5 * b1 + a9 * b2 + a13 * b3; + out[2] = a2 * b0 + a6 * b1 + a10 * b2 + a14 * b3; + out[3] = a3 * b0 + a7 * b1 + a11 * b2 + a15 * b3; + b0 = y[4]!; + b1 = y[5]!; + b2 = y[6]!; + b3 = y[7]!; + out[4] = a0 * b0 + a4 * b1 + a8 * b2 + a12 * b3; + out[5] = a1 * b0 + a5 * b1 + a9 * b2 + a13 * b3; + out[6] = a2 * b0 + a6 * b1 + a10 * b2 + a14 * b3; + out[7] = a3 * b0 + a7 * b1 + a11 * b2 + a15 * b3; + b0 = y[8]!; + b1 = y[9]!; + b2 = y[10]!; + b3 = y[11]!; + out[8] = a0 * b0 + a4 * b1 + a8 * b2 + a12 * b3; + out[9] = a1 * b0 + a5 * b1 + a9 * b2 + a13 * b3; + out[10] = a2 * b0 + a6 * b1 + a10 * b2 + a14 * b3; + out[11] = a3 * b0 + a7 * b1 + a11 * b2 + a15 * b3; + b0 = y[12]!; + b1 = y[13]!; + b2 = y[14]!; + b3 = y[15]!; + out[12] = a0 * b0 + a4 * b1 + a8 * b2 + a12 * b3; + out[13] = a1 * b0 + a5 * b1 + a9 * b2 + a13 * b3; + out[14] = a2 * b0 + a6 * b1 + a10 * b2 + a14 * b3; + out[15] = a3 * b0 + a7 * b1 + a11 * b2 + a15 * b3; + return out as unknown as Mat4; +} + +/** 4x4 column-major identity. */ +function fgMat4Identity(): Mat4 { + const out = new Float32Array(16); + out[0] = 1; + out[5] = 1; + out[10] = 1; + out[15] = 1; + return out as unknown as Mat4; +} + +/** + * Invert a matrix (glTF `math/inverse`). Returns the identity when singular. + * Supports `FgMatrix2D`, `FgMatrix3D`, and `Mat4` (local column-major invert). + */ +export function fgInvertMatrix(m: FgValue): FgValue { + if (isFgMatrix2D(m)) { + const mm = m.m; + const det = mm[0]! * mm[3]! - mm[1]! * mm[2]!; + if (Math.abs(det) < 1e-10) { + return fgMatrix2D(); + } + const d = 1 / det; + return fgMatrix2D([mm[3]! * d, -mm[1]! * d, -mm[2]! * d, mm[0]! * d]); + } + if (isFgMatrix3D(m)) { + const mm = m.m; + const det = mm[0]! * (mm[4]! * mm[8]! - mm[7]! * mm[5]!) - mm[3]! * (mm[1]! * mm[8]! - mm[7]! * mm[2]!) + mm[6]! * (mm[1]! * mm[5]! - mm[4]! * mm[2]!); + if (Math.abs(det) < 1e-10) { + return fgMatrix3D(); + } + const d = 1 / det; + return fgMatrix3D([ + (mm[4]! * mm[8]! - mm[7]! * mm[5]!) * d, + -(mm[1]! * mm[8]! - mm[7]! * mm[2]!) * d, + (mm[1]! * mm[5]! - mm[4]! * mm[2]!) * d, + -(mm[3]! * mm[8]! - mm[6]! * mm[5]!) * d, + (mm[0]! * mm[8]! - mm[6]! * mm[2]!) * d, + -(mm[0]! * mm[5]! - mm[3]! * mm[2]!) * d, + (mm[3]! * mm[7]! - mm[6]! * mm[4]!) * d, + -(mm[0]! * mm[7]! - mm[6]! * mm[1]!) * d, + (mm[0]! * mm[4]! - mm[3]! * mm[1]!) * d, + ]); + } + if (isMat4(m)) { + return fgMat4Invert(m) ?? fgMat4Identity(); + } + return m; +} + +/** + * Multiply two same-size matrices: `out = A × B` (standard matrix product). + * Supports `FgMatrix2D × FgMatrix2D`, `FgMatrix3D × FgMatrix3D`, and + * `Mat4 × Mat4` (local column-major multiply). (glTF `math/matMul`) + */ +export function fgMatrixMultiplication(a: FgValue, b: FgValue): FgValue { + if (isFgMatrix2D(a) && isFgMatrix2D(b)) { + const am = a.m, + bm = b.m; + return fgMatrix2D([am[0]! * bm[0]! + am[2]! * bm[1]!, am[1]! * bm[0]! + am[3]! * bm[1]!, am[0]! * bm[2]! + am[2]! * bm[3]!, am[1]! * bm[2]! + am[3]! * bm[3]!]); + } + if (isFgMatrix3D(a) && isFgMatrix3D(b)) { + const am = a.m, + bm = b.m; + return fgMatrix3D([ + am[0]! * bm[0]! + am[3]! * bm[1]! + am[6]! * bm[2]!, + am[1]! * bm[0]! + am[4]! * bm[1]! + am[7]! * bm[2]!, + am[2]! * bm[0]! + am[5]! * bm[1]! + am[8]! * bm[2]!, + am[0]! * bm[3]! + am[3]! * bm[4]! + am[6]! * bm[5]!, + am[1]! * bm[3]! + am[4]! * bm[4]! + am[7]! * bm[5]!, + am[2]! * bm[3]! + am[5]! * bm[4]! + am[8]! * bm[5]!, + am[0]! * bm[6]! + am[3]! * bm[7]! + am[6]! * bm[8]!, + am[1]! * bm[6]! + am[4]! * bm[7]! + am[7]! * bm[8]!, + am[2]! * bm[6]! + am[5]! * bm[7]! + am[8]! * bm[8]!, + ]); + } + if (isMat4(a) && isMat4(b)) { + return fgMat4Multiply(a, b); + } + return a; +} + +/** + * Compose a TRS `Mat4` from translation, rotation quaternion, and scale + * (glTF `math/matCompose`). Uses core `mat4Compose` (column-major). + */ +export function fgMatrixCompose(pos: FgValue, quat: FgValue, scale: FgValue): Mat4 { + const p = isVec3(pos) ? pos : { x: 0, y: 0, z: 0 }; + const q = isVec4(quat) ? quat : { x: 0, y: 0, z: 0, w: 1 }; + const s = isVec3(scale) ? scale : { x: 1, y: 1, z: 1 }; + return mat4Compose(p.x, p.y, p.z, q.x, q.y, q.z, q.w, s.x, s.y, s.z); +} + +/** Result of `fgMatrixDecompose`. */ +export interface FgDecomposeResult { + position: Vec3; + rotationQuaternion: Quat; + scaling: Vec3; + isValid: boolean; +} + +/** + * Decompose a `Mat4` into translation, rotation (unit quaternion), and scale + * (glTF `math/matDecompose`). Uses core `mat4Decompose`. + * + * Validity pre-check: the bottom row of the column-major matrix (`m[3]`, `m[7]`, + * `m[11]`, `m[15]`) must round to `[0, 0, 0, 1]` at 4 decimal places; + * otherwise `isValid = false` and zero/identity defaults are returned. + */ +export function fgMatrixDecompose(m: FgValue): FgDecomposeResult { + const zero: Vec3 = { x: 0, y: 0, z: 0 }; + const identQ: Quat = { x: 0, y: 0, z: 0, w: 1 }; + const oneS: Vec3 = { x: 1, y: 1, z: 1 }; + if (!isMat4(m)) { + return { position: zero, rotationQuaternion: identQ, scaling: oneS, isValid: false }; + } + // Bottom row in column-major 4×4: indices m[3], m[7], m[11], m[15]. + const r0 = Math.round(m[3]! * 1e4) / 1e4; + const r1 = Math.round(m[7]! * 1e4) / 1e4; + const r2 = Math.round(m[11]! * 1e4) / 1e4; + const r3 = Math.round(m[15]! * 1e4) / 1e4; + if (r0 !== 0 || r1 !== 0 || r2 !== 0 || r3 !== 1) { + return { position: zero, rotationQuaternion: identQ, scaling: oneS, isValid: false }; + } + const { translation, rotation, scale } = mat4Decompose(m); + return { position: translation, rotationQuaternion: rotation, scaling: scale, isValid: true }; +} + +// ─── Quaternion ops (Phase 3f) ──────────────────────────────────────────────── + +/** + * Angle between two unit quaternions: `2 · acos(clamp(dot(a, b), −1, 1))` + * (glTF `math/quatAngleBetween`). Returns 0 for non-quaternion inputs. + */ +export function fgAngleBetween(a: FgValue, b: FgValue): number { + if (isVec4(a) && isVec4(b)) { + const d = Math.min(1, Math.max(-1, a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w)); + return 2 * Math.acos(d); + } + return 0; +} + +/** + * Quaternion from axis and angle (glTF `math/quatFromAxisAngle`). + * Does NOT pre-normalize the axis (replicates BJS `Quaternion.RotationAxis`). + * Result: `[sin(θ/2)·axis, cos(θ/2)]`. + */ +export function fgQuaternionFromAxisAngle(axis: FgValue, angle: FgValue): Quat { + const n = num(angle); + if (!isVec3(axis) || Number.isNaN(n)) { + return { x: 0, y: 0, z: 0, w: 1 }; + } + const half = n / 2; + const s = Math.sin(half); + return { x: axis.x * s, y: axis.y * s, z: axis.z * s, w: Math.cos(half) }; +} + +/** Result of `fgAxisAngleFromQuaternion`. */ +export interface FgAxisAngleResult { + axis: Vec3; + angle: number; + isValid: boolean; +} + +/** + * Extract axis and angle from a unit quaternion (glTF `math/quatToAxisAngle`). + * `angle = 2·acos(w)`. If `sin(angle/2)` is near zero the axis defaults to + * `(1, 0, 0)`. Always sets `isValid = true` for a valid quaternion input. + */ +export function fgAxisAngleFromQuaternion(q: FgValue): FgAxisAngleResult { + if (!isVec4(q)) { + return { axis: { x: 1, y: 0, z: 0 }, angle: 0, isValid: false }; + } + const w = Math.min(1, Math.max(-1, q.w)); + const angle = 2 * Math.acos(w); + const s = Math.sqrt(Math.max(0, 1 - w * w)); + if (s < 1e-7) { + return { axis: { x: 1, y: 0, z: 0 }, angle, isValid: true }; + } + return { axis: { x: q.x / s, y: q.y / s, z: q.z / s }, angle, isValid: true }; +} + +/** + * Quaternion that rotates direction `a` to direction `b` (glTF `math/quatFromDirections`). + * Assumes inputs are already unit vectors — does NOT pre-normalize. + * Computes `axis = cross(a, b)`, `angle = acos(clamp(dot(a,b), −1, 1))`, + * then `quatFromAxisAngle(axis, angle)`. + */ +export function fgQuaternionFromDirections(a: FgValue, b: FgValue): Quat { + if (!isVec3(a) || !isVec3(b)) { + return { x: 0, y: 0, z: 0, w: 1 }; + } + const axis = crossVec3(a, b); + const dot = Math.min(1, Math.max(-1, dotVec3(a, b))); + const angle = Math.acos(dot); + const half = angle / 2; + const s = Math.sin(half); + return { x: axis.x * s, y: axis.y * s, z: axis.z * s, w: Math.cos(half) }; +} diff --git a/packages/babylon-lite/src/flow-graph/gltf/declaration-mapper.ts b/packages/babylon-lite/src/flow-graph/gltf/declaration-mapper.ts new file mode 100644 index 0000000000..39abdf05b0 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/gltf/declaration-mapper.ts @@ -0,0 +1,369 @@ +// ⚠️ SPEC-VOLATILE — KHR_interactivity is an UNRATIFIED glTF draft. Quarantined +// here so the runtime core never changes when the spec churns. Mirrored against +// Babylon.js commit 8f728b23ea (2026-06-24). Re-sync against BJS PR #18455 +// ("KHR_interactivity rework") when it lands. +// See docs/lite/architecture/42-flow-graph.md → glTF KHR_interactivity Loader. +// +// declaration-mapper: maps each glTF interactivity `op` string to the Lite block +// it instantiates, plus the socket/config translation. Mirrors BJS +// `declarationMapper.ts`, but COLLAPSED: BJS expands pointer/animation ops into +// multiple blocks + inter-block connectors (JsonPointerParser, ArrayIndex, +// GLTFDataProvider). Lite pre-resolves pointers to accessors and animations to +// caps in the loader, so each op maps to a SINGLE block. + +import { FgBlockType } from "../block-type.js"; + +/** Default glTF value-output socket name (used when a reference omits `socket`). */ +export const DEFAULT_VALUE_SOCKET = "value"; +/** Default glTF flow-input socket name (used when a flow omits `socket`). */ +export const DEFAULT_FLOW_SOCKET = "in"; +/** Frames-per-second used to convert animation seconds → frames (glTF default). */ +export const ANIMATION_FPS = 60; + +/** Lite-side mapping descriptor for one glTF interactivity op. */ +export interface FgOpMapping { + /** The Lite block this op instantiates. */ + readonly block: FgBlockType; + /** glTF value-INPUT socket name → Lite data-input name. Unlisted inputs that + * are NOT pointer segments are passed through by their glTF name. */ + readonly valueInputs?: Readonly>; + /** Per-input numeric transform applied to a literal value array before + * coercion (e.g. animation seconds → frames). Keyed by glTF input name. */ + readonly valueTransform?: Readonly number[]>>; + /** glTF value-OUTPUT socket name → Lite data-output name (for data references + * that read this block). Default output `value` passes through. */ + readonly outputValues?: Readonly>; + /** glTF flow-OUTPUT socket name → Lite signal-output name. */ + readonly flowOutputs?: Readonly>; + /** Pointer op: resolve `config.accessor` from the `pointer` configuration + + * literal segment value sockets; segment inputs are NOT block data inputs. */ + readonly pointer?: boolean; + /** Sequence-style: `outputSignalCount` = number of flow keys; the i-th flow + * (sorted) maps to signal `out_i`. */ + readonly dynamicSequence?: boolean; + /** Variable op: the glTF configuration key holding the variable index/indices + * (`variable` for get, `variables` for set). */ + readonly variableConfigKey?: string; + /** Event op: copy a numeric glTF `configuration` value into `config[key]` + * (e.g. `nodeIndex` for `event/onSelect`). */ + readonly nodeConfigKey?: string; + /** + * glTF `configuration` keys to copy as SCALAR values into block config. + * Parser copies `node.configuration[gltfKey].value[0]` → `config[liteName]`. + * Use for booleans, numbers, counts, and other single-element values. + * ⚠️ SPEC-VOLATILE: quarantined here; re-sync against BJS PR #18455. + */ + readonly configKeys?: Readonly>; + /** + * glTF `configuration` keys to copy as ARRAY values into block config. + * Parser copies `node.configuration[gltfKey].value` (full array) → + * `config[liteName]`. Use for `cases` arrays and other multi-element lists. + * ⚠️ SPEC-VOLATILE: quarantined here; re-sync against BJS PR #18455. + */ + readonly configArrayKeys?: Readonly>; + /** + * Switch-style dynamic output renaming: glTF flow key `"N"` → Lite signal + * output `"out_N"` (except `"default"` which passes through unchanged). + * Mirrors BJS FlowGraphSwitchBlock's `extraProcessor` renaming logic. + */ + readonly switchOutputs?: boolean; +} + +const FPS = (arr: number[]): number[] => [(arr[0] ?? 0) * ANIMATION_FPS]; + +/** Native (no-extension) KHR_interactivity op → Lite block mapping. */ +const NATIVE_OPS: Readonly> = { + "event/onStart": { block: FgBlockType.SceneStart, flowOutputs: { out: "done" } }, + "event/onTick": { block: FgBlockType.SceneTick, outputValues: { timeSinceLastTick: "deltaTime" }, flowOutputs: { out: "done" } }, + + "flow/branch": { block: FgBlockType.Branch, valueInputs: { condition: "condition" }, flowOutputs: { true: "onTrue", false: "onFalse" } }, + "flow/sequence": { block: FgBlockType.Sequence, dynamicSequence: true }, + + // ─── Phase 3h control-flow ops ──────────────────────────────────────────── + // flow/switch: glTF input `selection` → Lite `case`; flow keys are the raw + // case integers ("0","1",...) — `switchOutputs` prefixes them to `out_N`. + "flow/switch": { block: FgBlockType.Switch, valueInputs: { selection: "case" }, configArrayKeys: { cases: "cases" }, switchOutputs: true }, + // flow/for: `loopBody` → `executionFlow` (glTF name differs from BJS internal). + "flow/for": { block: FgBlockType.ForLoop, configKeys: { initialIndex: "initialIndex" }, flowOutputs: { loopBody: "executionFlow" } }, + // flow/while: same loopBody rename. + "flow/while": { block: FgBlockType.WhileLoop, flowOutputs: { loopBody: "executionFlow" } }, + // flow/doN: glTF `n` → `maxExecutions`; `currentCount` → `executionCount`. + "flow/doN": { block: FgBlockType.DoN, valueInputs: { n: "maxExecutions" }, outputValues: { currentCount: "executionCount" } }, + // flow/multiGate: output count from flow count (dynamicSequence); booleans from configKeys. + "flow/multiGate": { block: FgBlockType.MultiGate, dynamicSequence: true, configKeys: { isRandom: "isRandom", isLoop: "isLoop" } }, + // flow/waitAll: input count from glTF config key `inputFlows`. + "flow/waitAll": { block: FgBlockType.WaitAll, configKeys: { inputFlows: "inputSignalCount" } }, + // flow/throttle, flow/setDelay: `err` glTF output → `error` Lite signal. + "flow/throttle": { block: FgBlockType.Throttle, flowOutputs: { err: "error" } }, + "flow/setDelay": { block: FgBlockType.SetDelay, flowOutputs: { err: "error" } }, + "flow/cancelDelay": { block: FgBlockType.CancelDelay }, + + "math/add": { block: FgBlockType.Add, valueInputs: { a: "a", b: "b" } }, + "math/sub": { block: FgBlockType.Subtract, valueInputs: { a: "a", b: "b" } }, + "math/mul": { block: FgBlockType.Multiply, valueInputs: { a: "a", b: "b" } }, + "math/div": { block: FgBlockType.Divide, valueInputs: { a: "a", b: "b" } }, + "math/rem": { block: FgBlockType.Modulo, valueInputs: { a: "a", b: "b" } }, + "math/abs": { block: FgBlockType.Abs, valueInputs: { a: "a" } }, + "math/floor": { block: FgBlockType.Floor, valueInputs: { a: "a" } }, + "math/lt": { block: FgBlockType.LessThan, valueInputs: { a: "a", b: "b" } }, + "math/clamp": { block: FgBlockType.Clamp, valueInputs: { a: "a", b: "b", c: "c" } }, + "math/combine2": { block: FgBlockType.CombineVector2, valueInputs: { a: "a", b: "b" } }, + "math/extract2": { block: FgBlockType.ExtractVector2, valueInputs: { a: "a" }, outputValues: { "0": "x", "1": "y" } }, + // ─── Phase 3 math (pass-through a/b/c sockets) ─────────────────────────── + "math/neg": { block: FgBlockType.Negation }, + "math/sign": { block: FgBlockType.Sign }, + "math/ceil": { block: FgBlockType.Ceil }, + "math/round": { block: FgBlockType.Round }, + "math/trunc": { block: FgBlockType.Trunc }, + "math/fract": { block: FgBlockType.Fraction }, + "math/saturate": { block: FgBlockType.Saturate }, + "math/sqrt": { block: FgBlockType.SquareRoot }, + "math/cbrt": { block: FgBlockType.CubeRoot }, + "math/exp": { block: FgBlockType.Exponential }, + "math/log": { block: FgBlockType.Log }, + "math/log2": { block: FgBlockType.Log2 }, + "math/log10": { block: FgBlockType.Log10 }, + "math/rad": { block: FgBlockType.DegToRad }, + "math/deg": { block: FgBlockType.RadToDeg }, + "math/sin": { block: FgBlockType.Sin }, + "math/cos": { block: FgBlockType.Cos }, + "math/tan": { block: FgBlockType.Tan }, + "math/asin": { block: FgBlockType.Asin }, + "math/acos": { block: FgBlockType.Acos }, + "math/atan": { block: FgBlockType.Atan }, + "math/sinh": { block: FgBlockType.Sinh }, + "math/cosh": { block: FgBlockType.Cosh }, + "math/tanh": { block: FgBlockType.Tanh }, + "math/asinh": { block: FgBlockType.Asinh }, + "math/acosh": { block: FgBlockType.Acosh }, + "math/atanh": { block: FgBlockType.Atanh }, + "math/min": { block: FgBlockType.Min }, + "math/max": { block: FgBlockType.Max }, + "math/pow": { block: FgBlockType.Power }, + "math/atan2": { block: FgBlockType.Atan2 }, + "math/eq": { block: FgBlockType.Equality }, + "math/le": { block: FgBlockType.LessThanOrEqual }, + "math/gt": { block: FgBlockType.GreaterThan }, + "math/ge": { block: FgBlockType.GreaterThanOrEqual }, + "math/isNaN": { block: FgBlockType.IsNaN }, + "math/isInf": { block: FgBlockType.IsInfinity }, + "math/and": { block: FgBlockType.BitwiseAnd }, + "math/or": { block: FgBlockType.BitwiseOr }, + "math/xor": { block: FgBlockType.BitwiseXor }, + "math/not": { block: FgBlockType.BitwiseNot }, + "math/lsl": { block: FgBlockType.BitwiseLeftShift }, + "math/asr": { block: FgBlockType.BitwiseRightShift }, + "math/clz": { block: FgBlockType.LeadingZeros }, + "math/ctz": { block: FgBlockType.TrailingZeros }, + "math/popcnt": { block: FgBlockType.OneBitsCounter }, + "math/length": { block: FgBlockType.Length }, + "math/normalize": { block: FgBlockType.Normalize }, + "math/dot": { block: FgBlockType.Dot }, + "math/cross": { block: FgBlockType.Cross }, + "math/rotate3D": { block: FgBlockType.Rotate3D }, + "math/mix": { block: FgBlockType.MathInterpolation }, + "math/combine3": { block: FgBlockType.CombineVector3 }, + "math/combine4": { block: FgBlockType.CombineVector4 }, + "math/E": { block: FgBlockType.E }, + "math/Pi": { block: FgBlockType.PI }, + "math/Inf": { block: FgBlockType.Inf }, + "math/NaN": { block: FgBlockType.NaN }, + "math/random": { block: FgBlockType.Random }, + "type/boolToFloat": { block: FgBlockType.BooleanToFloat }, + "type/boolToInt": { block: FgBlockType.BooleanToInt }, + "type/floatToBool": { block: FgBlockType.FloatToBoolean }, + "type/intToBool": { block: FgBlockType.IntToBoolean }, + "type/intToFloat": { block: FgBlockType.IntToFloat }, + "type/floatToInt": { block: FgBlockType.FloatToInt }, + // ─── Phase 3 math (custom socket / output mapping) ─────────────────────── + "math/extract3": { block: FgBlockType.ExtractVector3, outputValues: { "0": "x", "1": "y", "2": "z" } }, + "math/extract4": { block: FgBlockType.ExtractVector4, outputValues: { "0": "x", "1": "y", "2": "z", "3": "w" } }, + "math/rotate2D": { block: FgBlockType.Rotate2D, valueInputs: { a: "a", angle: "b" } }, + "math/select": { block: FgBlockType.Conditional, valueInputs: { condition: "condition", a: "onTrue", b: "onFalse" } }, + // math/switch: data switch; cases array from glTF configuration. + "math/switch": { block: FgBlockType.DataSwitch, configArrayKeys: { cases: "cases" } }, + + // ─── Phase 3f (matrix + quaternion) ────────────────────────────────────── + "math/transform": { block: FgBlockType.TransformVector }, + "math/transpose": { block: FgBlockType.Transpose }, + "math/determinant": { block: FgBlockType.Determinant }, + "math/inverse": { block: FgBlockType.InvertMatrix }, + "math/matMul": { block: FgBlockType.MatrixMultiplication }, + "math/matCompose": { + block: FgBlockType.MatrixCompose, + valueInputs: { translation: "position", rotation: "rotationQuaternion", scale: "scaling" }, + }, + "math/matDecompose": { + block: FgBlockType.MatrixDecompose, + valueInputs: { a: "input" }, + outputValues: { translation: "position", rotation: "rotationQuaternion", scale: "scaling" }, + }, + "math/combine2x2": { + block: FgBlockType.CombineMatrix2D, + valueInputs: { a: "input_0", b: "input_1", c: "input_2", d: "input_3" }, + }, + "math/combine3x3": { + block: FgBlockType.CombineMatrix3D, + valueInputs: { a: "input_0", b: "input_1", c: "input_2", d: "input_3", e: "input_4", f: "input_5", g: "input_6", h: "input_7", i: "input_8" }, + }, + "math/combine4x4": { + block: FgBlockType.CombineMatrix, + valueInputs: { + a: "input_0", + b: "input_1", + c: "input_2", + d: "input_3", + e: "input_4", + f: "input_5", + g: "input_6", + h: "input_7", + i: "input_8", + j: "input_9", + k: "input_10", + l: "input_11", + m: "input_12", + n: "input_13", + o: "input_14", + p: "input_15", + }, + }, + "math/extract2x2": { + block: FgBlockType.ExtractMatrix2D, + valueInputs: { a: "input" }, + outputValues: { "0": "output_0", "1": "output_1", "2": "output_2", "3": "output_3" }, + }, + "math/extract3x3": { + block: FgBlockType.ExtractMatrix3D, + valueInputs: { a: "input" }, + outputValues: { "0": "output_0", "1": "output_1", "2": "output_2", "3": "output_3", "4": "output_4", "5": "output_5", "6": "output_6", "7": "output_7", "8": "output_8" }, + }, + "math/extract4x4": { + block: FgBlockType.ExtractMatrix, + valueInputs: { a: "input" }, + outputValues: { + "0": "output_0", + "1": "output_1", + "2": "output_2", + "3": "output_3", + "4": "output_4", + "5": "output_5", + "6": "output_6", + "7": "output_7", + "8": "output_8", + "9": "output_9", + "10": "output_10", + "11": "output_11", + "12": "output_12", + "13": "output_13", + "14": "output_14", + "15": "output_15", + }, + }, + "math/quatConjugate": { block: FgBlockType.Conjugate }, + "math/quatAngleBetween": { block: FgBlockType.AngleBetween }, + "math/quatFromAxisAngle": { block: FgBlockType.QuaternionFromAxisAngle, valueInputs: { axis: "a", angle: "b" } }, + "math/quatToAxisAngle": { block: FgBlockType.AxisAngleFromQuaternion }, + "math/quatFromDirections": { block: FgBlockType.QuaternionFromDirections }, + // math/quatMul: BJS reuses the generic Multiply with config.type=Quaternion; + // Lite has a dedicated Hamilton-product block (generic Multiply is component- + // wise). glTF inputs `a`/`b` map to identically named sockets. + "math/quatMul": { block: FgBlockType.QuaternionMultiplication }, + + "variable/get": { block: FgBlockType.GetVariable, variableConfigKey: "variable" }, + "variable/set": { block: FgBlockType.SetVariable, variableConfigKey: "variables", valueInputs: { value: "value" } }, + + "pointer/get": { block: FgBlockType.GetProperty, pointer: true }, + "pointer/set": { block: FgBlockType.SetProperty, pointer: true, valueInputs: { value: "value" }, flowOutputs: { err: "error" } }, + + "animation/start": { + block: FgBlockType.PlayAnimation, + valueInputs: { animation: "animation", speed: "speed", startTime: "from", endTime: "to" }, + valueTransform: { startTime: FPS, endTime: FPS }, + flowOutputs: { err: "error" }, + }, + "animation/stop": { block: FgBlockType.StopAnimation, valueInputs: { animation: "animation" }, flowOutputs: { err: "error" } }, + // animation/stopAt: defers the stop until playback reaches `stopAtFrame`. + // glTF `stopTime` (seconds) → `stopAtFrame` via the FPS transform. + "animation/stopAt": { + block: FgBlockType.StopAnimation, + valueInputs: { animation: "animation", stopTime: "stopAtFrame" }, + valueTransform: { stopTime: FPS }, + flowOutputs: { err: "error" }, + }, + + // debug/log: core op (vs BABYLON-extension flow/log). The `message` config is + // a template with `{name}` placeholders that the ConsoleLog block expands. + "debug/log": { block: FgBlockType.ConsoleLog, configKeys: { message: "messageTemplate" } }, + + // ─── Phase 3i event ops ──────────────────────────────────────────────────── + // event/send: `configuration["event"]` holds the integer event-table index, + // copied into `config.eventId` as a scalar. Value parameters from the glTF + // events table are a deferred Phase 3i+ item (parser doesn't yet read + // `json.events`, so sockets beyond the eventId are not wired from glTF). + "event/send": { block: FgBlockType.SendCustomEvent, configKeys: { event: "eventId" } }, + // event/receive: same index → eventId mapping; glTF flow key `out` maps to + // the Lite `done` signal (BJS convention for event blocks). + "event/receive": { block: FgBlockType.ReceiveCustomEvent, configKeys: { event: "eventId" }, flowOutputs: { out: "done" } }, + + // ─── Phase 3i interpolation ops ─────────────────────────────────────────── + // variable/interpolate: glTF `value` input → Lite `endValue`; `duration` + // passes through. `useSlerp` config → `config.useSlerp` (boolean). + // Easing control points `p1`/`p2` (BezierCurveEasing) are deferred. + // Pointer/variable target wiring (SetProperty after done) is also deferred. + "variable/interpolate": { + block: FgBlockType.ValueInterpolation, + valueInputs: { value: "endValue", duration: "duration" }, + configKeys: { useSlerp: "useSlerp" }, + flowOutputs: { err: "error" }, + }, + // pointer/interpolate: same inputs; adds accessor resolution from the pointer + // configuration so the block can eventually write its `value` to a property. + "pointer/interpolate": { + block: FgBlockType.ValueInterpolation, + pointer: true, + valueInputs: { value: "endValue", duration: "duration" }, + configKeys: { useSlerp: "useSlerp" }, + flowOutputs: { err: "error" }, + }, +}; + +/** Babylon-extension ops (`declaration.extension === "BABYLON"`). */ +const BABYLON_OPS: Readonly> = { + "flow/log": { block: FgBlockType.ConsoleLog, valueInputs: { message: "message" } }, +}; + +/** KHR_node_selectability ops (`declaration.extension === "KHR_node_selectability"`). */ +const KHR_NODE_SELECTABILITY_OPS: Readonly> = { + "event/onSelect": { block: FgBlockType.OnSelect, nodeConfigKey: "nodeIndex" }, +}; + +/** Extension-namespaced op tables, keyed by `declaration.extension`. */ +const EXTENSION_OPS: Readonly>>> = { + BABYLON: BABYLON_OPS, + KHR_node_selectability: KHR_NODE_SELECTABILITY_OPS, +}; + +/** Look up the Lite mapping for a glTF op, honoring the declaration `extension`. + * Returns `null` for an unknown op so the parser can fail loudly. */ +export function getOpMapping(op: string, extension?: string): FgOpMapping | null { + if (extension) { + return EXTENSION_OPS[extension]?.[op] ?? null; + } + return NATIVE_OPS[op] ?? null; +} + +/** Every distinct Lite block type referenced by some op mapping (native or + * extension). Used by a coverage guard to prove every mapped op resolves to a + * registered block def. */ +export function allMappedBlockTypes(): FgBlockType[] { + const types = new Set(); + for (const m of Object.values(NATIVE_OPS)) { + types.add(m.block); + } + for (const table of Object.values(EXTENSION_OPS)) { + for (const m of Object.values(table)) { + types.add(m.block); + } + } + return [...types]; +} diff --git a/packages/babylon-lite/src/flow-graph/gltf/interactivity-parser.ts b/packages/babylon-lite/src/flow-graph/gltf/interactivity-parser.ts new file mode 100644 index 0000000000..6b0d1f3650 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/gltf/interactivity-parser.ts @@ -0,0 +1,316 @@ +// ⚠️ SPEC-VOLATILE — KHR_interactivity is an UNRATIFIED glTF draft. Quarantined +// here so the runtime core never changes when the spec churns. Mirrored against +// Babylon.js commit 8f728b23ea (2026-06-24). Re-sync against BJS PR #18455 +// ("KHR_interactivity rework") when it lands. +// See docs/lite/architecture/42-flow-graph.md → glTF KHR_interactivity Loader. +// +// interactivity-parser: walks a `glTF.extensions.KHR_interactivity` graph object +// (types → declarations → variables → nodes/values/flows) and produces a +// spec-agnostic `FgGraph` plus the list of JSON pointers to resolve into +// accessors. Mirrors BJS `interactivityGraphParser.ts`, collapsed for Lite's +// pre-resolved-accessor model (see declaration-mapper.ts). +// +// Unknown ops FAIL LOUDLY (structured error listing every offending op) so a +// KHR_interactivity asset can't silently render a broken interaction. + +import { fgInt } from "../custom-types/fg-integer.js"; +import { fgMatrix2D, fgMatrix3D } from "../custom-types/fg-matrix.js"; +import type { Mat4 } from "../../math/types.js"; +import { getBlockDef } from "../block-registry.js"; +import type { FgBlock, FgDataSocket, FgGraph, FgSignalSocket, FgValue } from "../types.js"; +import { FgType } from "../types.js"; +import { DEFAULT_FLOW_SOCKET, DEFAULT_VALUE_SOCKET, getOpMapping, type FgOpMapping } from "./declaration-mapper.js"; + +// ─── glTF wire-format shapes (loose; spec-volatile) ────────────────────────── + +interface GltfValueEntry { + /** Literal payload. Numeric for scalar/vector literals; a single-element + * STRING array (`["/materials/4/"]`) for `ref`-typed pointer values. */ + value?: (number | string)[]; + type?: number; + node?: number; + socket?: string; +} +interface GltfFlowEntry { + node: number; + socket?: string; +} +interface GltfNode { + declaration: number; + configuration?: Record; + values?: Record; + flows?: Record; +} +export interface GltfInteractivityGraph { + types?: { signature: string }[]; + declarations?: { op: string; extension?: string }[]; + variables?: { type: number; value?: number[] }[]; + events?: unknown[]; + nodes?: GltfNode[]; +} + +/** Result of parsing one interactivity graph. */ +export interface FgParseResult { + readonly graph: FgGraph; + /** Resolved JSON-pointer strings the loader must turn into accessors + * (keyed identically in `block.config.accessor`). */ + readonly pointers: readonly string[]; +} + +const SIGNATURE_TO_FGTYPE: Readonly> = { + bool: FgType.Boolean, + int: FgType.Integer, + float: FgType.Number, + float2: FgType.Vector2, + float3: FgType.Vector3, + float4: FgType.Vector4, + float2x2: FgType.Matrix2D, + float3x3: FgType.Matrix3D, + float4x4: FgType.Matrix, +}; + +/** Coerce a glTF flat value array into an `FgValue` of the given type. */ +function arrayToFgValue(arr: number[] | undefined, type: FgType): FgValue { + const a = arr ?? []; + switch (type) { + case FgType.Boolean: + return !!a[0]; + case FgType.Integer: + return fgInt(a[0] ?? 0); + case FgType.Number: + return a[0] ?? 0; + case FgType.Vector2: + return { x: a[0] ?? 0, y: a[1] ?? 0 }; + case FgType.Vector3: + return { x: a[0] ?? 0, y: a[1] ?? 0, z: a[2] ?? 0 }; + case FgType.Vector4: + case FgType.Quaternion: + return { x: a[0] ?? 0, y: a[1] ?? 0, z: a[2] ?? 0, w: a[3] ?? 0 }; + case FgType.Matrix2D: + // glTF matrix literals are column-major — stored directly (no transpose). + return fgMatrix2D(a); + case FgType.Matrix3D: + return fgMatrix3D(a); + case FgType.Matrix: { + const m = new Float32Array(16); + for (let i = 0; i < 16; i++) { + m[i] = a[i] ?? 0; + } + return m as unknown as Mat4; + } + default: + return a[0] ?? 0; + } +} + +/** Extract the trailing integer index from a glTF `ref` path string + * (`"/materials/4/"` → `"4"`). Returns `null` when there's no trailing index. */ +function refPathIndex(ref: string): string | null { + const m = /(\d+)\/?$/.exec(ref); + return m ? m[1]! : null; +} + +/** Resolve a pointer template against a node's value sockets. + * + * Two spec forms are supported (mirrors BJS `FlowGraphPathConverterComponent` + * + the newer relative-pointer draft targeted by PR #18455): + * 1. **Absolute + `{seg}` placeholders** — each `{seg}` reads `node.values[seg]`. + * A `ref`-typed value (`["/materials/4/"]`) contributes its trailing index + * (`4`); a numeric value is substituted directly. + * 2. **Relative** — when the substituted result doesn't start with `/`, prepend + * the path of a `ref`-typed value socket that wasn't consumed as a + * placeholder (e.g. `nodeRef` = `["/nodes/22/"]`). + * + * Returns `null` if a placeholder segment is missing/non-literal, or a relative + * template has no ref prefix to anchor it. */ +function resolvePointerTemplate(template: string, node: GltfNode): string | null { + let ok = true; + const usedSegments = new Set(); + const resolved = template.replace(/\{(\w+)\}/g, (_m, seg: string) => { + const entry = node.values?.[seg]; + const literal = entry?.value?.[0]; + if (literal === undefined || entry?.node !== undefined) { + ok = false; + return _m; + } + usedSegments.add(seg); + if (typeof literal === "string") { + const idx = refPathIndex(literal); + if (idx === null) { + ok = false; + return _m; + } + return idx; + } + return String(literal); + }); + if (!ok) { + return null; + } + if (resolved.startsWith("/")) { + return resolved; + } + // Relative pointer: anchor it on an unused ref-typed value socket. + for (const [key, entry] of Object.entries(node.values ?? {})) { + if (usedSegments.has(key) || entry.node !== undefined) { + continue; + } + const v = entry.value?.[0]; + if (typeof v === "string" && v.startsWith("/")) { + return v.replace(/\/$/, "") + "/" + resolved; + } + } + return null; +} + +/** Compute a node's per-flow-key → Lite signal-output-name map (handles the + * dynamic sequence renaming and switch-style integer-key prefixing). */ +function flowOutputName(mapping: FgOpMapping, flowKeys: string[], key: string): string { + if (mapping.dynamicSequence) { + return `out_${flowKeys.indexOf(key)}`; + } + // Switch-style: glTF flow keys are raw case integers; prefix with "out_" + // except for "default" which passes through unchanged. + if (mapping.switchOutputs && key !== "default") { + return `out_${key}`; + } + return mapping.flowOutputs?.[key] ?? key; +} + +/** Parse one KHR_interactivity graph object into an `FgGraph` + pointer list. */ +export async function parseInteractivityGraph(json: GltfInteractivityGraph): Promise { + const types = (json.types ?? []).map((t) => SIGNATURE_TO_FGTYPE[t.signature] ?? FgType.Any); + const declarations = json.declarations ?? []; + const nodes = json.nodes ?? []; + + // Resolve the mapping for every node first, collecting unknown ops. + const mappings: FgOpMapping[] = []; + const unsupported: string[] = []; + for (const node of nodes) { + const decl = declarations[node.declaration]; + const mapping = decl ? getOpMapping(decl.op, decl.extension) : null; + if (!mapping) { + const label = decl ? `${decl.extension ? decl.extension + ":" : ""}${decl.op}` : `declaration#${node.declaration}`; + if (!unsupported.includes(label)) { + unsupported.push(label); + } + } + mappings.push(mapping as FgOpMapping); + } + if (unsupported.length > 0) { + throw new Error(`KHR_interactivity: unsupported op(s): ${unsupported.join(", ")}`); + } + + // Graph variables (keyed by index, mirroring BJS getVariableName(i)). + const variables: Record = {}; + (json.variables ?? []).forEach((v, i) => { + const t = types[v.type] ?? FgType.Any; + variables[String(i)] = { type: t, value: arrayToFgValue(v.value, t) }; + }); + + // Pass 1: instantiate each block's socket shape from its def + config. + const blocks: FgBlock[] = []; + const pointers: string[] = []; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]!; + const mapping = mappings[i]!; + const config: Record = {}; + + if (mapping.dynamicSequence) { + config.outputSignalCount = Object.keys(node.flows ?? {}).length; + } + if (mapping.variableConfigKey) { + const idx = (node.configuration?.[mapping.variableConfigKey]?.value?.[0] as number) ?? 0; + config.variable = String(idx); + } + if (mapping.nodeConfigKey) { + config[mapping.nodeConfigKey] = node.configuration?.[mapping.nodeConfigKey]?.value?.[0] as number; + } + // ⚠️ SPEC-VOLATILE: configKeys/configArrayKeys — re-sync against BJS PR #18455. + if (mapping.configKeys) { + for (const [gltfKey, liteName] of Object.entries(mapping.configKeys)) { + const raw = node.configuration?.[gltfKey]?.value; + if (raw !== undefined) { + config[liteName] = raw[0]; // scalar: first element only + } + } + } + if (mapping.configArrayKeys) { + for (const [gltfKey, liteName] of Object.entries(mapping.configArrayKeys)) { + const raw = node.configuration?.[gltfKey]?.value; + if (raw !== undefined) { + config[liteName] = raw; // array: full value array + } + } + } + if (mapping.pointer) { + const template = node.configuration?.pointer?.value?.[0] as string | undefined; + const resolved = template ? resolvePointerTemplate(template, node) : null; + if (resolved === null) { + throw new Error(`KHR_interactivity: node ${i} has an unresolvable pointer ${JSON.stringify(template)} (dynamic segments are not yet supported)`); + } + config.accessor = resolved; + if (!pointers.includes(resolved)) { + pointers.push(resolved); + } + } + + const def = await getBlockDef(mapping.block)!(); + const shape = def.build(config); + blocks.push({ + id: `node_${i}`, + type: mapping.block, + config, + dataIn: [...(shape.dataIn ?? [])], + dataOut: shape.dataOut ?? [], + signalIn: shape.signalIn ?? [], + signalOut: (shape.signalOut ?? []).map((s) => ({ name: s.name, targets: [] as { blockId: string; socket: string }[] })), + event: shape.event, + }); + } + + // Pass 2: wire data sources (values) and signal targets (flows). + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]!; + const mapping = mappings[i]!; + const block = blocks[i]!; + + // Data inputs. + for (const [gltfKey, entry] of Object.entries(node.values ?? {})) { + if (mapping.pointer && gltfKey !== "value") { + continue; // pointer segment — consumed by accessor resolution + } + const inputName = mapping.valueInputs?.[gltfKey] ?? gltfKey; + const socket = block.dataIn.find((d) => d.name === inputName) as (FgDataSocket & { source?: unknown; defaultValue?: FgValue }) | undefined; + if (!socket) { + continue; + } + if (entry.node !== undefined) { + const producer = mappings[entry.node]!; + const gltfSocket = entry.socket ?? DEFAULT_VALUE_SOCKET; + socket.source = { blockId: `node_${entry.node}`, socket: producer.outputValues?.[gltfSocket] ?? gltfSocket }; + } else { + const raw = (entry.value ?? []) as number[]; + const transform = mapping.valueTransform?.[gltfKey]; + const arr = transform ? transform(raw) : raw; + socket.defaultValue = arrayToFgValue(arr, types[entry.type ?? -1] ?? socket.type); + } + } + + // Signal outputs (flows). + const flowKeys = Object.keys(node.flows ?? {}); + for (const key of flowKeys) { + const flow = node.flows![key]!; + const outName = flowOutputName(mapping, flowKeys, key); + const out = block.signalOut.find((s) => s.name === outName) as FgSignalSocket | undefined; + if (!out) { + continue; + } + (out.targets as { blockId: string; socket: string }[]).push({ blockId: `node_${flow.node}`, socket: flow.socket ?? DEFAULT_FLOW_SOCKET }); + } + } + + const byId: Record = {}; + blocks.forEach((b, i) => (byId[b.id] = i)); + return { graph: { blocks, byId, variables }, pointers }; +} diff --git a/packages/babylon-lite/src/flow-graph/gltf/object-model-mapping.ts b/packages/babylon-lite/src/flow-graph/gltf/object-model-mapping.ts new file mode 100644 index 0000000000..a6f660733f --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/gltf/object-model-mapping.ts @@ -0,0 +1,32 @@ +// ⚠️ SPEC-VOLATILE — KHR_interactivity is an UNRATIFIED glTF draft. All code in +// flow-graph/gltf/ is quarantined here so the runtime core never changes when +// the spec churns. Mirrored against Babylon.js commit 8f728b23ea (2026-06-24). +// Re-sync against BJS PR #18455 ("KHR_interactivity rework") when it lands. +// See docs/lite/architecture/42-flow-graph.md → glTF KHR_interactivity Loader. +// +// object-model-mapping: maps glTF JSON-pointer object-model paths to the Lite +// scene property they address + the FgType of that property. Phase 2 covers the +// node TRS triplet (the vertical-slice surface). Broaden in Phase 3+ (materials, +// cameras, lights, extensions) — mirror BJS `objectModelMapping.ts`. + +import { FgType } from "../types.js"; + +/** A scene-graph node TRS property addressable by a JSON pointer. */ +export type NodeTrsProp = "translation" | "rotation" | "scale"; + +/** Object-model entry: which node property a pointer tail addresses + its type. */ +export interface ObjectModelEntry { + /** The pointer tail after `/nodes/{index}/` (e.g. "translation"). */ + readonly prop: NodeTrsProp; + readonly type: FgType; +} + +/** Supported `/nodes/{index}/` tails. glTF uses RH coordinates; the Lite + * import root carries the RH→LH conversion, so accessors read/write the node's + * LOCAL TRS with raw glTF values (no per-accessor handedness flip for + * translation/scale). Rotation handedness is revisited in Phase 3. */ +export const NODE_TRS_PROPS: Record = { + translation: { prop: "translation", type: FgType.Vector3 }, + rotation: { prop: "rotation", type: FgType.Quaternion }, + scale: { prop: "scale", type: FgType.Vector3 }, +}; diff --git a/packages/babylon-lite/src/flow-graph/gltf/path-converter.ts b/packages/babylon-lite/src/flow-graph/gltf/path-converter.ts new file mode 100644 index 0000000000..72fe651c15 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/gltf/path-converter.ts @@ -0,0 +1,204 @@ +// ⚠️ SPEC-VOLATILE — see object-model-mapping.ts. Mirrored against BJS commit +// 8f728b23ea. Re-sync against BJS PR #18455 when it lands. +// +// path-converter: resolves a (literal-substituted) JSON pointer string such as +// "/nodes/0/translation" to an FgAccessor (get/set closures) over the addressed +// object. The KHR_interactivity loader feeds the glTF node map + runtime material +// map so accessors never hold a scene reference — they close over the resolved +// node/material only. +// +// DRY: material UV-transform and node-visibility WRITES delegate to the shared +// KHR_animation_pointer resolver (`resolveAnimationPointer`), which already owns +// the tricky per-texture isolation (so two materials sharing one atlas texture +// don't clobber each other) and the UBO/visibility-epoch invalidation. Pointer +// GETs read the runtime fields directly (the animation registry is write-only). +// +// LITE DIVERGENCE: BJS resolves pointers at RUNTIME via JsonPointerParser + +// gltfPathToObjectConverter. Lite pre-resolves them here at LOAD time, collapsing +// the JsonPointerParser/GetProperty/SetProperty trio into a single block reading +// a pre-resolved accessor. See blocks/data/{get,set}-property.ts. + +import type { FgAccessor } from "../context.js"; +import type { FgValue, Vec2 } from "../types.js"; +import { FgType } from "../types.js"; +import type { TransformNode } from "../../scene/transform-node.js"; +import type { Quat, Vec3, Vec4 } from "../../math/types.js"; +import { NODE_TRS_PROPS } from "./object-model-mapping.js"; +import { resolveAnimationPointer, type PointerContext, type PointerMaterial } from "../../loader-gltf/animation-pointer.js"; + +/** Loader-supplied resolution context: the glTF node map plus the runtime + * material map (glTF material index → PBR material) and raw JSON for reuse of + * the KHR_animation_pointer writers. */ +export interface PointerResolveContext { + nodeMap: readonly (TransformNode | undefined)[]; + materials?: readonly (PointerMaterial | undefined)[]; + json?: object; +} + +/** Build the PointerContext the animation-pointer registry expects. */ +function animCtx(ctx: PointerResolveContext): PointerContext { + return { nodes: ctx.nodeMap as PointerContext["nodes"], materials: ctx.materials, _json: ctx.json }; +} + +/** Parse `/nodes/{index}/{prop}` → `{ nodeIndex, prop }`, or `null` when the + * pointer is not a supported node-TRS path. */ +function parseNodeTrsPointer(pointer: string): { nodeIndex: number; prop: string } | null { + const m = /^\/nodes\/(\d+)\/(translation|rotation|scale)$/.exec(pointer); + if (!m) { + return null; + } + return { nodeIndex: Number(m[1]), prop: m[2]! }; +} + +const MAT_UV_RE = + /^\/materials\/(\d+)\/(?:pbrMetallicRoughness\/baseColorTexture|emissiveTexture|normalTexture|occlusionTexture)\/extensions\/KHR_texture_transform\/(offset|scale)$/; +const VISIBILITY_RE = /^\/nodes\/(\d+)\/extensions\/KHR_node_visibility\/visible$/; +const SELECTABILITY_RE = /^\/nodes\/(\d+)\/extensions\/KHR_node_selectability\/selectable$/; + +/** Resolve a literal-substituted JSON pointer to an `FgAccessor`, or `null` when + * it addresses an unsupported path or an unreachable node/material. */ +export function resolvePointerAccessor(pointer: string, ctx: PointerResolveContext): FgAccessor | null { + const trs = parseNodeTrsPointer(pointer); + if (trs) { + return resolveNodeTrs(trs.nodeIndex, trs.prop, ctx); + } + + const uv = MAT_UV_RE.exec(pointer); + if (uv) { + return resolveMaterialUvTransform(pointer, Number(uv[1]), uv[2]!, ctx); + } + + const vis = VISIBILITY_RE.exec(pointer); + if (vis) { + return resolveVisibility(pointer, Number(vis[1]), ctx); + } + + const sel = SELECTABILITY_RE.exec(pointer); + if (sel) { + return resolveSelectability(Number(sel[1]), ctx); + } + + return null; +} + +function resolveNodeTrs(nodeIndex: number, prop: string, ctx: PointerResolveContext): FgAccessor | null { + const node = ctx.nodeMap[nodeIndex]; + if (!node) { + return null; + } + const entry = NODE_TRS_PROPS[prop]!; + + if (prop === "translation") { + return { + type: entry.type, + target: node, + get: () => ({ x: node.position.x, y: node.position.y, z: node.position.z }), + set: (v) => { + const p = toVec3(v); + node.position.set(p.x, p.y, p.z); + }, + }; + } + if (prop === "scale") { + return { + type: entry.type, + target: node, + get: () => ({ x: node.scaling.x, y: node.scaling.y, z: node.scaling.z }), + set: (v) => { + const p = toVec3(v); + node.scaling.set(p.x, p.y, p.z); + }, + }; + } + // rotation (quaternion) + return { + type: entry.type, + target: node, + get: () => ({ x: node.rotationQuaternion.x, y: node.rotationQuaternion.y, z: node.rotationQuaternion.z, w: node.rotationQuaternion.w }), + set: (v) => { + const q = toQuat(v); + node.rotationQuaternion.set(q.x, q.y, q.z, q.w); + }, + }; +} + +/** Material `KHR_texture_transform` offset/scale (Vec2). WRITES reuse the shared + * animation-pointer writer (per-texture isolation + UBO bump); READS sample the + * runtime baseColorTexture's UV fields directly. */ +function resolveMaterialUvTransform(pointer: string, matIndex: number, kind: string, ctx: PointerResolveContext): FgAccessor | null { + const mat = ctx.materials?.[matIndex]; + if (!mat) { + return null; + } + const resolved = resolveAnimationPointer(pointer, animCtx(ctx)); + return { + type: FgType.Vector2, + target: mat, + get: () => { + const tex = mat.baseColorTexture; + if (kind === "scale") { + return { x: tex?.uScale ?? 1, y: tex?.vScale ?? 1 }; + } + return { x: tex?.uOffset ?? 0, y: tex?.vOffset ?? 0 }; + }, + set: resolved + ? (v) => { + const p = toVec2(v); + resolved.writer(Float32Array.of(p.x, p.y), 0); + } + : undefined, + }; +} + +/** `KHR_node_visibility/visible` (boolean). WRITES reuse the shared + * animation-pointer writer (subtree cascade + visibility-epoch bump). */ +function resolveVisibility(pointer: string, nodeIndex: number, ctx: PointerResolveContext): FgAccessor | null { + const node = ctx.nodeMap[nodeIndex]; + if (!node) { + return null; + } + const resolved = resolveAnimationPointer(pointer, animCtx(ctx)); + return { + type: FgType.Boolean, + target: node, + get: () => node.visible !== false, + set: resolved + ? (v) => { + resolved.writer(Float32Array.of(v ? 1 : 0), 0); + } + : undefined, + }; +} + +/** `KHR_node_selectability/selectable` (boolean). Lite has no picking gate, so + * this is a no-op accessor: the value round-trips but has no visual effect. */ +function resolveSelectability(nodeIndex: number, ctx: PointerResolveContext): FgAccessor | null { + const node = ctx.nodeMap[nodeIndex]; + if (!node) { + return null; + } + let selectable = true; + return { + type: FgType.Boolean, + target: node, + get: () => selectable, + set: (v) => { + selectable = !!v; + }, + }; +} + +function toVec2(v: FgValue): Vec2 { + const o = (v ?? {}) as Partial; + return { x: o.x ?? 0, y: o.y ?? 0 }; +} + +function toVec3(v: FgValue): Vec3 { + const o = (v ?? {}) as Partial; + return { x: o.x ?? 0, y: o.y ?? 0, z: (o as Vec3).z ?? 0 }; +} + +function toQuat(v: FgValue): Quat { + const o = (v ?? {}) as Partial; + return { x: o.x ?? 0, y: o.y ?? 0, z: o.z ?? 0, w: o.w ?? 1 }; +} diff --git a/packages/babylon-lite/src/flow-graph/index.ts b/packages/babylon-lite/src/flow-graph/index.ts new file mode 100644 index 0000000000..c1f5b4ac16 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/index.ts @@ -0,0 +1,52 @@ +// Public surface of the flow-graph subsystem. Pure data + standalone functions. +// Re-exported from the package root (../index.ts). + +// Core data types +export type { FgBlock, FgDataSocket, FgGraph, FgSignalSocket, FgValue, Vec2 } from "./types.js"; +export { FgEventType, FgType } from "./types.js"; + +// Behaviour definitions + block-type names +export type { FgBlockDef, FgBlockShape } from "./block-def.js"; +export { FgBlockType } from "./block-type.js"; + +// Execution context & environment +export type { FgAccessor, FgCapabilities, FgContext, FgEnv, FgPendingTask, FgWiring, LoadedFlowGraph } from "./context.js"; + +// Event bus +export type { FgEventBus, FgEventHandler, FgEventPayload } from "./event-bus.js"; +export { clearFgEventBus, createFgEventBus, pumpFgEvent, subscribeFgEvent } from "./event-bus.js"; + +// Rich-type pure functions +export { animationTypeForFgType, coerceValue, defaultForType, FgAnimationValueType } from "./rich-type.js"; + +// Custom types +export type { FgInteger } from "./custom-types/fg-integer.js"; +export { fgInt, isFgInt } from "./custom-types/fg-integer.js"; +export type { FgMatrix2D, FgMatrix3D } from "./custom-types/fg-matrix.js"; +export { fgMatrix2D, fgMatrix3D, isFgMatrix2D, isFgMatrix3D } from "./custom-types/fg-matrix.js"; + +// Block registry +export { getBlockDef } from "./block-registry.js"; + +// Scene attachment +export { attachFlowGraph, detachFlowGraph, runFlowGraphs } from "./scene-flow-graph.js"; + +// Runtime functions + FgRuntime +export type { FgRuntime } from "./runtime.js"; +export { + activateSignal, + addPending, + cancelPendingForBlock, + compactPending, + createFgContext, + createFgEnv, + createFgRuntime, + disposeFlowGraph, + getDataValue, + getExecVar, + setDataValue, + setExecVar, + startFlowGraph, + stillPending, + tickFlowGraph, +} from "./runtime.js"; diff --git a/packages/babylon-lite/src/flow-graph/rich-type.ts b/packages/babylon-lite/src/flow-graph/rich-type.ts new file mode 100644 index 0000000000..36f919856c --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/rich-type.ts @@ -0,0 +1,142 @@ +// Pure replacements for the three things a BJS `RichType` instance carried: +// a default value, a typeTransformer (coercion), and an animationType. +// No classes, no module-level instances — three pure functions, allocating +// only inside the function body. + +import { quatFromRotationMatrix } from "../math/quat-from-rotation-matrix.js"; +import type { Color3, Color4, Mat4, Quat, Vec3, Vec4 } from "../math/types.js"; +import { fgInt, isFgInt } from "./custom-types/fg-integer.js"; +import { fgMatrix2D, fgMatrix3D } from "./custom-types/fg-matrix.js"; +import type { FgValue, Vec2 } from "./types.js"; +import { FgType } from "./types.js"; + +/** Keyframe value categories an interpolation/animation block must distinguish. + * Replaces BJS `RichType.animationType`; quaternion targets must use slerp. */ +export const enum FgAnimationValueType { + Float = 0, + Vector2 = 1, + Vector3 = 2, + Vector4 = 3, + Quaternion = 4, + Color3 = 5, + Color4 = 6, + Matrix = 7, +} + +/** The default value for a type. Constructs fresh values inside the function + * (no shared module-level instances). Replaces `RichType.defaultValue`. */ +export function defaultForType(type: FgType): FgValue { + switch (type) { + case FgType.Number: + return 0; + case FgType.Integer: + return fgInt(0); + case FgType.Boolean: + return false; + case FgType.String: + return ""; + case FgType.Vector2: + return { x: 0, y: 0 } as Vec2; + case FgType.Vector3: + return { x: 0, y: 0, z: 0 } as Vec3; + case FgType.Vector4: + return { x: 0, y: 0, z: 0, w: 0 } as Vec4; + case FgType.Quaternion: + return { x: 0, y: 0, z: 0, w: 1 } as Quat; + case FgType.Matrix: + // Identity 4x4, column-major, branded as Mat4 by convention. + return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) as unknown as Mat4; + case FgType.Matrix2D: + return fgMatrix2D(); + case FgType.Matrix3D: + return fgMatrix3D(); + case FgType.Color3: + return { r: 0, g: 0, b: 0 } as Color3; + case FgType.Color4: + return { r: 0, g: 0, b: 0, a: 1 } as Color4; + case FgType.Any: + default: + return null; + } +} + +/** Coerce `value` to `target`. Home of BJS's `typeTransformer`, including the + * critical `Vector4`/`Matrix` → `Quaternion` and numeric↔integer conversions. + * Invoked by `getDataValue` at the consumer boundary so block bodies stay + * type-clean. Returns `value` unchanged when no conversion is needed/possible. */ +export function coerceValue(value: FgValue, target: FgType): FgValue { + if (value === null || value === undefined) { + return value; + } + switch (target) { + case FgType.Number: + if (isFgInt(value)) { + return value.value; + } + if (typeof value === "boolean") { + return value ? 1 : 0; + } + return value; + case FgType.Integer: + if (typeof value === "number") { + return fgInt(value); + } + if (typeof value === "boolean") { + return fgInt(value ? 1 : 0); + } + return value; + case FgType.Boolean: + if (isFgInt(value)) { + return value.value !== 0; + } + if (typeof value === "number") { + return value !== 0; + } + return value; + case FgType.Quaternion: + return toQuaternion(value); + default: + return value; + } +} + +/** `Vector4` (x,y,z,w) and `Matrix` (rotation) both coerce to `Quaternion`. + * glTF `useSlerp` targets force this so interpolation runs as slerp. */ +function toQuaternion(value: FgValue): FgValue { + // A Vec4 already has x,y,z,w — reinterpret as a quaternion directly. + if (typeof value === "object" && value !== null && "w" in value && "x" in value && "y" in value && "z" in value) { + const v = value as Vec4; + return { x: v.x, y: v.y, z: v.z, w: v.w } as Quat; + } + // A 4x4 matrix → extract its rotation as a quaternion. + if (value instanceof Float32Array && value.length === 16) { + return quatFromRotationMatrix(value as unknown as Mat4); + } + return value; +} + +/** The animation value category for a type. Replaces `RichType.animationType`; + * used by interpolation/animation blocks to pick the keyframe interpolation + * (quaternion ⇒ slerp). */ +export function animationTypeForFgType(type: FgType): FgAnimationValueType { + switch (type) { + case FgType.Vector2: + return FgAnimationValueType.Vector2; + case FgType.Vector3: + return FgAnimationValueType.Vector3; + case FgType.Vector4: + return FgAnimationValueType.Vector4; + case FgType.Quaternion: + return FgAnimationValueType.Quaternion; + case FgType.Color3: + return FgAnimationValueType.Color3; + case FgType.Color4: + return FgAnimationValueType.Color4; + case FgType.Matrix: + case FgType.Matrix2D: + case FgType.Matrix3D: + return FgAnimationValueType.Matrix; + default: + return FgAnimationValueType.Float; + } +} diff --git a/packages/babylon-lite/src/flow-graph/runtime.ts b/packages/babylon-lite/src/flow-graph/runtime.ts new file mode 100644 index 0000000000..0dc6a34b2c --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/runtime.ts @@ -0,0 +1,345 @@ +// Standalone runtime functions — the functional re-expression of BJS's +// FlowGraph execution. Data edges PULL (recompute on every read); signal edges +// PUSH (cascade through `execute`). Async work is tracked as `FgPendingTask` +// records ticked in a cancellation-safe per-frame loop. See +// docs/lite/architecture/42-flow-graph.md → Internal Architecture. + +import type { FgBlockDef } from "./block-def.js"; +import type { FgContext, FgEnv, FgPendingTask, FgWiring } from "./context.js"; +import { createFgEventBus, pumpFgEvent, subscribeFgEvent } from "./event-bus.js"; +import { getBlockDef } from "./block-registry.js"; +import { coerceValue, defaultForType } from "./rich-type.js"; +import type { FgBlock, FgGraph, FgValue } from "./types.js"; +import { FgEventType } from "./types.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Data edges (PULL) +// ───────────────────────────────────────────────────────────────────────────── + +/** PULL a data input: resolve the wired source, run its producer's + * `updateOutputs` (every time — `connectionValues` is a transport slot, not a + * validity cache), read the produced value, then coerce to the consumer type. + * Falls back to the socket's literal default when unwired. */ +export function getDataValue(ctx: FgContext, env: FgEnv, block: FgBlock, socket: string): FgValue { + const input = block.dataIn.find((s) => s.name === socket); + if (!input) { + return undefined; + } + + let raw: FgValue; + if (input.source) { + const producer = blockById(env.graph, input.source.blockId); + if (producer) { + const guardKey = `${producer.id}:resolving`; + // Break accidental data cycles: return the default rather than recurse. + if (ctx.executionVariables[guardKey]) { + raw = input.defaultValue ?? defaultForType(input.type); + } else { + const def = env.defs[producer.type]; + ctx.executionVariables[guardKey] = true; + try { + def?.updateOutputs?.(producer, ctx, env); + } finally { + ctx.executionVariables[guardKey] = false; + } + const slot = `${producer.id}:${input.source.socket}`; + raw = slot in ctx.connectionValues ? ctx.connectionValues[slot] : (input.defaultValue ?? defaultForType(input.type)); + } + } else { + raw = input.defaultValue ?? defaultForType(input.type); + } + } else { + raw = input.defaultValue ?? defaultForType(input.type); + } + + return coerceValue(raw, input.type); +} + +/** Write a data output into the transport slot (called from `updateOutputs`). */ +export function setDataValue(ctx: FgContext, block: FgBlock, socket: string, value: FgValue): void { + ctx.connectionValues[`${block.id}:${socket}`] = value; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Per-block execution variables (mutable runtime state, keyed by block id) +// ───────────────────────────────────────────────────────────────────────────── + +/** Read a block-local execution variable, falling back to `def` when unset. + * Replaces BJS `context._getExecutionVariable(this, key, def)`. */ +export function getExecVar(ctx: FgContext, block: FgBlock, key: string, def: T): T { + const slot = `${block.id}:${key}`; + return slot in ctx.executionVariables ? (ctx.executionVariables[slot] as T) : def; +} + +/** Write a block-local execution variable. + * Replaces BJS `context._setExecutionVariable(this, key, value)`. */ +export function setExecVar(ctx: FgContext, block: FgBlock, key: string, value: unknown): void { + ctx.executionVariables[`${block.id}:${key}`] = value; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Signal edges (PUSH) +// ───────────────────────────────────────────────────────────────────────────── + +/** PUSH a signal: for each wired target of `socket`, dispatch into the target + * block's `def.execute`, passing the target's incoming signal name. */ +export function activateSignal(ctx: FgContext, env: FgEnv, block: FgBlock, socket: string): void { + const output = block.signalOut.find((s) => s.name === socket); + if (!output) { + return; + } + for (const target of output.targets) { + const targetBlock = blockById(env.graph, target.blockId); + if (!targetBlock) { + continue; + } + env.defs[targetBlock.type]?.execute?.(targetBlock, ctx, env, target.socket); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Async pending tasks +// ───────────────────────────────────────────────────────────────────────────── + +/** Register a new async task owned by `block`. Returns the task (with a unique + * token) so the caller can stash handles on `task.state`. A block may own + * several concurrent tasks. */ +export function addPending(ctx: FgContext, block: FgBlock, state: Record = {}): FgPendingTask { + const task: FgPendingTask = { + blockId: block.id, + token: ctx._tokenSeq++, + canceled: false, + done: false, + state, + }; + ctx.pending.push(task); + return task; +} + +/** True while `task` is still live (present, not canceled, not done). */ +export function stillPending(ctx: FgContext, task: FgPendingTask): boolean { + return !task.canceled && !task.done && ctx.pending.indexOf(task) >= 0; +} + +/** Cancel every outstanding task owned by `block` (marks them; compacted later). */ +export function cancelPendingForBlock(ctx: FgContext, block: FgBlock): void { + for (const task of ctx.pending) { + if (task.blockId === block.id) { + task.canceled = true; + } + } +} + +/** Drop canceled/done tasks from `ctx.pending` in place (preserves order). */ +export function compactPending(ctx: FgContext): void { + let write = 0; + for (let read = 0; read < ctx.pending.length; read++) { + const task = ctx.pending[read]; + if (task && !task.canceled && !task.done) { + ctx.pending[write++] = task; + } + } + ctx.pending.length = write; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Context / env / runtime construction +// ───────────────────────────────────────────────────────────────────────────── + +/** Instantiate runtime state for a parsed graph (variables seeded live). */ +export function createFgContext(graph: FgGraph, opts?: { rightHanded?: boolean }): FgContext { + const userVariables: Record = {}; + for (const name of Object.keys(graph.variables)) { + userVariables[name] = graph.variables[name]!.value; + } + return { + connectionValues: {}, + executionVariables: {}, + userVariables, + pending: [], + rightHanded: opts?.rightHanded ?? false, + _tokenSeq: 0, + }; +} + +/** Build the resolved env: await every block def the graph needs (registry or + * wiring override), then attach accessors/animations/caps/bus. + * + * Unknown op policy: the registry returns `null` for an unknown type and this + * loader FAILS LOUDLY (throws with the offending types) so a `KHR_interactivity` + * asset can't silently render a broken interaction. */ +export async function createFgEnv(graph: FgGraph, wiring: FgWiring = {}): Promise { + const defs: Record = {}; + const unsupported: string[] = []; + + for (const block of graph.blocks) { + if (defs[block.type]) { + continue; + } + const override = wiring.defs?.[block.type]; + if (override) { + defs[block.type] = override; + continue; + } + const loader = getBlockDef(block.type); + if (!loader) { + if (!unsupported.includes(block.type)) { + unsupported.push(block.type); + } + continue; + } + defs[block.type] = await loader(); + } + + if (unsupported.length > 0) { + throw new Error(`flow-graph: unsupported block type(s): ${unsupported.join(", ")}`); + } + + return { + graph, + defs, + accessors: wiring.accessors ?? {}, + animations: wiring.animations ?? [], + caps: wiring.caps ?? {}, + events: wiring.events ?? createFgEventBus(), + }; +} + +/** One graph runtime = graph + context + env, owned by the scene. */ +export interface FgRuntime { + readonly graph: FgGraph; + readonly context: FgContext; + readonly env: FgEnv; + started: boolean; + /** @internal Bus unsubscribe fns registered at start, called on dispose. */ + _unsub: (() => void)[]; +} + +/** Assemble a runtime from a graph + wiring (creates context + env). */ +export async function createFgRuntime(graph: FgGraph, wiring: FgWiring = {}, opts?: { rightHanded?: boolean }): Promise { + const env = await createFgEnv(graph, wiring); + const context = createFgContext(graph, opts); + return { graph, context, env, started: false, _unsub: [] }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Lifecycle: start / tick / dispose +// ───────────────────────────────────────────────────────────────────────────── + +/** Init priority for event blocks: lower runs/subscribes first. Custom-event + * receivers must be listening BEFORE the start cascade can send to them. */ +function eventInitPriority(event: FgEventType): number { + switch (event) { + case FgEventType.CustomEvent: + return 0; + case FgEventType.Pointer: + case FgEventType.Key: + return 1; + case FgEventType.Tick: + return 2; + case FgEventType.Start: + return 3; + default: + return 2; + } +} + +/** Subscribe every non-start event block to the bus, in init-priority order, + * then fire the `Start` blocks once. Idempotent: a started runtime is skipped. */ +export function startFlowGraph(rt: FgRuntime): void { + if (rt.started) { + return; + } + const { context: ctx, env } = rt; + + const eventBlocks = rt.graph.blocks.filter((b): b is FgBlock & { event: FgEventType } => b.event !== undefined); + eventBlocks.sort((a, b) => eventInitPriority(a.event) - eventInitPriority(b.event)); + + // 1. Subscribe ALL non-start receivers first (custom-event before start-like). + for (const block of eventBlocks) { + if (block.event === FgEventType.Start) { + continue; + } + const def = env.defs[block.type]; + const unsub = subscribeFgEvent(env.events, block.event, (payload) => { + ctx.executionVariables[`${block.id}:lastEvent`] = payload; + def?.execute?.(block, ctx, env, block.event); + }); + rt._unsub.push(unsub); + } + + rt.started = true; + + // 2. THEN fire onStart blocks once (receivers are now listening). + for (const block of eventBlocks) { + if (block.event !== FgEventType.Start) { + continue; + } + env.defs[block.type]?.execute?.(block, ctx, env, FgEventType.Start); + } +} + +/** Per-frame drive: pump the tick event, then advance pending async tasks in a + * cancellation-safe loop (a task may be canceled / a new one added mid-loop). */ +export function tickFlowGraph(rt: FgRuntime, deltaMs: number): void { + if (!rt.started) { + startFlowGraph(rt); + } + const { context: ctx, env } = rt; + + pumpFgEvent(env.events, FgEventType.Tick, { deltaMs, deltaTime: deltaMs / 1000 }); + + // Snapshot: tasks added during the loop are picked up next frame (not retro-ticked). + const snapshot = ctx.pending.slice(); + for (const task of snapshot) { + if (task.canceled || !stillPending(ctx, task)) { + continue; + } + const block = blockById(env.graph, task.blockId); + if (block) { + env.defs[block.type]?.onTick?.(block, ctx, env, deltaMs, task); + } + } + compactPending(ctx); +} + +/** Tear down: cancel pending tasks (invoking `cancelPending` once per block), + * detach bus listeners, clear caches. Safe to call more than once. */ +export function disposeFlowGraph(rt: FgRuntime): void { + const { context: ctx, env } = rt; + + for (const unsub of rt._unsub) { + unsub(); + } + rt._unsub.length = 0; + + const visited = new Set(); + for (const task of ctx.pending) { + task.canceled = true; + if (!visited.has(task.blockId)) { + visited.add(task.blockId); + const block = blockById(env.graph, task.blockId); + if (block) { + env.defs[block.type]?.cancelPending?.(block, ctx, env); + } + } + } + ctx.pending.length = 0; + + for (const key of Object.keys(ctx.connectionValues)) { + delete ctx.connectionValues[key]; + } + for (const key of Object.keys(ctx.executionVariables)) { + delete ctx.executionVariables[key]; + } + rt.started = false; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function blockById(graph: FgGraph, id: string): FgBlock | undefined { + const index = graph.byId[id]; + return index === undefined ? undefined : graph.blocks[index]; +} diff --git a/packages/babylon-lite/src/flow-graph/scene-flow-graph.ts b/packages/babylon-lite/src/flow-graph/scene-flow-graph.ts new file mode 100644 index 0000000000..a43cdac5a4 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/scene-flow-graph.ts @@ -0,0 +1,81 @@ +// Scene attachment for flow-graph runtimes. Byte-neutral for non-interactivity +// scenes: this module is only pulled into a bundle when something imports it +// (the glTF KHR_interactivity feature, or explicit user code). It drives graphs +// through the scene's generic `onBeforeRender` / `onSceneDispose` seams instead +// of hardcoding a loop in scene-core (GUIDANCE §4c′ — always extensions). + +import { onBeforeRender, onSceneDispose, type SceneContext } from "../scene/scene-core.js"; +import type { FgRuntime } from "./runtime.js"; +import { createFgRuntime, disposeFlowGraph, startFlowGraph, tickFlowGraph } from "./runtime.js"; +import type { AnimationGroup } from "../animation/animation-group.js"; +import { playAnimation as agPlay, stopAnimation as agStop, goToFrame as agGoToFrame } from "../animation/animation-group.js"; +import type { FgCapabilities, LoadedFlowGraph } from "./context.js"; + +/** Attach a flow-graph runtime to a scene. The runtime starts on the first + * frame (after which event listeners are live) and ticks every frame. The + * runtime is auto-disposed when the scene is disposed. */ +export function attachFlowGraph(scene: SceneContext, rt: FgRuntime): void { + let list = scene._flowGraphs; + if (!list) { + list = []; + scene._flowGraphs = list; + } + list.push(rt); + + onBeforeRender(scene, (deltaMs: number) => { + // Early-out if detached (the closure persists until scene dispose). + if (!scene._flowGraphs || scene._flowGraphs.indexOf(rt) < 0) { + return; + } + if (!rt.started) { + startFlowGraph(rt); + } + tickFlowGraph(rt, deltaMs); + }); + + onSceneDispose(scene, () => detachFlowGraph(scene, rt)); +} + +/** Detach and dispose a flow-graph runtime previously attached to `scene`. */ +export function detachFlowGraph(scene: SceneContext, rt: FgRuntime): void { + const list = scene._flowGraphs; + if (list) { + const i = list.indexOf(rt); + if (i >= 0) { + list.splice(i, 1); + } + } + disposeFlowGraph(rt); +} + +/** Scene-owned animation capabilities backing the Play/Stop animation blocks. + * Pure delegation to the animation-group functions so blocks never import the + * scene. `from`/`to` frame-range playback is a Phase 3 refinement. */ +function sceneAnimationCaps(): FgCapabilities { + return { + playAnimation: (group: AnimationGroup, opts) => { + group.speedRatio = opts?.speed ?? 1; + group.loopAnimation = opts?.loop ?? false; + agPlay(group); + }, + stopAnimation: (group: AnimationGroup) => agStop(group), + // Halt at the requested frame (goToFrame sets currentTime + clears + // isPlaying), leaving the target posed at that frame rather than reset. + stopAnimationAt: (group: AnimationGroup, frame: number) => agGoToFrame(group, frame), + }; +} + +/** Build + attach a runtime for every flow graph loaded onto a container. Binds + * the graph's pre-resolved accessors, the container's animation groups (indexed + * by glTF order), and scene-owned animation capabilities, then drives each + * runtime through the scene's frame loop. Returns the attached runtimes. */ +export async function runFlowGraphs(scene: SceneContext, loaded: readonly LoadedFlowGraph[], animations: readonly AnimationGroup[] = []): Promise { + const caps = sceneAnimationCaps(); + const runtimes: FgRuntime[] = []; + for (const lg of loaded) { + const rt = await createFgRuntime(lg.graph, { accessors: lg.accessors, animations, caps }, { rightHanded: true }); + attachFlowGraph(scene, rt); + runtimes.push(rt); + } + return runtimes; +} diff --git a/packages/babylon-lite/src/flow-graph/sockets.ts b/packages/babylon-lite/src/flow-graph/sockets.ts new file mode 100644 index 0000000000..da28ae22b5 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/sockets.ts @@ -0,0 +1,27 @@ +// Tiny socket constructors used by block `build()` shapes. Pure functions, no +// state. The parser/builder later fills `source` (data inputs) and `targets` +// (signal outputs) from the graph edges; these helpers just produce the +// skeleton a def declares. Keeps block files terse and consistent with the +// porting skill (.github/copilot/skills/port-flow-graph-block.md). + +import type { FgDataSocket, FgSignalSocket, FgType, FgValue } from "./types.js"; + +/** Declare a data INPUT socket (optional literal fallback). */ +export function sockIn(name: string, type: FgType, defaultValue?: FgValue): FgDataSocket { + return defaultValue === undefined ? { name, type } : { name, type, defaultValue }; +} + +/** Declare a data OUTPUT socket. */ +export function sockOut(name: string, type: FgType): FgDataSocket { + return { name, type }; +} + +/** Declare a signal INPUT socket. */ +export function sigIn(name: string): FgSignalSocket { + return { name, targets: [] }; +} + +/** Declare a signal OUTPUT socket. */ +export function sigOut(name: string): FgSignalSocket { + return { name, targets: [] }; +} diff --git a/packages/babylon-lite/src/flow-graph/types.ts b/packages/babylon-lite/src/flow-graph/types.ts new file mode 100644 index 0000000000..28f75d5745 --- /dev/null +++ b/packages/babylon-lite/src/flow-graph/types.ts @@ -0,0 +1,92 @@ +// Flow-graph core data types — pure state, no classes, no attached methods. +// A graph is plain data describing topology + config only; behaviour lives in +// FgBlockDef records (block-def.ts) and standalone runtime functions +// (runtime.ts). See docs/lite/architecture/42-flow-graph.md. + +import type { Color3, Color4, Mat4, Quat, Vec3, Vec4 } from "../math/types.js"; +import type { FgInteger } from "./custom-types/fg-integer.js"; +import type { FgMatrix2D, FgMatrix3D } from "./custom-types/fg-matrix.js"; + +/** 2-component vector. Core math/ is Vec3-centric and has no Vec2, so the + * flow-graph subsystem owns its own (glTF `float2` maps to this). */ +export interface Vec2 { + x: number; + y: number; +} + +/** A value that can flow along a data edge. */ +export type FgValue = number | boolean | string | Vec2 | Vec3 | Vec4 | Quat | Mat4 | FgInteger | FgMatrix2D | FgMatrix3D | Color3 | Color4 | null | undefined; + +/** Type tags. `const enum` → fully erased at build, zero runtime cost. + * String values intentionally match the glTF / BJS `flowGraphType` identifiers + * so the declaration mapper can pass them through unchanged. */ +export const enum FgType { + Any = "any", + Number = "number", + Boolean = "boolean", + String = "string", + Integer = "FlowGraphInteger", + Vector2 = "Vector2", + Vector3 = "Vector3", + Vector4 = "Vector4", + Quaternion = "Quaternion", + Matrix = "Matrix", + Matrix2D = "Matrix2D", + Matrix3D = "Matrix3D", + Color3 = "Color3", + Color4 = "Color4", +} + +/** A data input/output port — plain data. */ +export interface FgDataSocket { + readonly name: string; + readonly type: FgType; + /** Wired source (for inputs): producing block id + its output socket name. */ + source?: { blockId: string; socket: string }; + /** Literal fallback used when `source` is undefined. */ + defaultValue?: FgValue; +} + +/** A control-flow (signal) port — plain data. Push model. */ +export interface FgSignalSocket { + readonly name: string; + /** Wired targets (for outputs): consuming block id + its input signal name. */ + readonly targets: { blockId: string; socket: string }[]; +} + +/** A node instance — PURE DATA describing topology + config only. */ +export interface FgBlock { + readonly id: string; + /** `FgBlockType` value or a `"module/Name"` custom identifier. */ + readonly type: string; + readonly config?: Readonly>; + readonly dataIn: readonly FgDataSocket[]; + readonly dataOut: readonly FgDataSocket[]; + readonly signalIn: readonly FgSignalSocket[]; + readonly signalOut: readonly FgSignalSocket[]; + /** Declared by event blocks; which bus event activates them. */ + readonly event?: FgEventType; +} + +/** A parsed graph — pure data. */ +export interface FgGraph { + readonly blocks: readonly FgBlock[]; + /** id → block index, for O(1) edge resolution (built by the parser/builder). */ + readonly byId: Readonly>; + /** Declared graph variables: name → type + initial value. */ + readonly variables: Readonly>; +} + +/** Event channels an event block can subscribe to. `const enum` → erased. */ +export const enum FgEventType { + /** Fired once when the graph starts (scene ready). */ + Start = "start", + /** Fired every frame with `{ deltaMs, deltaTime }`. */ + Tick = "tick", + /** Custom events sent between graphs; payload carries `eventName` + values. */ + CustomEvent = "customEvent", + /** Pointer events forwarded by the picking/input layer. */ + Pointer = "pointer", + /** Keyboard events forwarded by the input layer. */ + Key = "key", +} diff --git a/packages/babylon-lite/src/index.ts b/packages/babylon-lite/src/index.ts index 5614732828..8a5963a150 100644 --- a/packages/babylon-lite/src/index.ts +++ b/packages/babylon-lite/src/index.ts @@ -747,3 +747,33 @@ export { createSoundBufferAsync } from "./audio/sound-buffer.js"; export type { SoundBuffer, SoundSource, SoundBufferOptions } from "./audio/sound-buffer.js"; export type { AudioSignal } from "./audio/audio-signal.js"; export type { AudioRampShape, RampOptions } from "./audio/audio-param.js"; + +// ─── Flow graph (visual scripting / glTF KHR_interactivity runtime) ─── +export type { FgBlock, FgDataSocket, FgGraph, FgSignalSocket, FgValue, Vec2 } from "./flow-graph/index.js"; +export { FgEventType, FgType } from "./flow-graph/index.js"; +export type { FgBlockDef, FgBlockShape } from "./flow-graph/index.js"; +export { FgBlockType } from "./flow-graph/index.js"; +export type { FgAccessor, FgCapabilities, FgContext, FgEnv, FgPendingTask, FgWiring } from "./flow-graph/index.js"; +export type { FgEventBus, FgEventHandler, FgEventPayload } from "./flow-graph/index.js"; +export { clearFgEventBus, createFgEventBus, pumpFgEvent, subscribeFgEvent } from "./flow-graph/index.js"; +export { animationTypeForFgType, coerceValue, defaultForType, FgAnimationValueType } from "./flow-graph/index.js"; +export type { FgInteger, FgMatrix2D, FgMatrix3D } from "./flow-graph/index.js"; +export { fgInt, fgMatrix2D, fgMatrix3D, isFgInt, isFgMatrix2D, isFgMatrix3D } from "./flow-graph/index.js"; +export { getBlockDef } from "./flow-graph/index.js"; +export { attachFlowGraph, detachFlowGraph, runFlowGraphs } from "./flow-graph/index.js"; +export type { FgRuntime } from "./flow-graph/index.js"; +export { + activateSignal, + addPending, + cancelPendingForBlock, + compactPending, + createFgContext, + createFgEnv, + createFgRuntime, + disposeFlowGraph, + getDataValue, + setDataValue, + startFlowGraph, + stillPending, + tickFlowGraph, +} from "./flow-graph/index.js"; diff --git a/packages/babylon-lite/src/loader-gltf/gltf-feature-interactivity.ts b/packages/babylon-lite/src/loader-gltf/gltf-feature-interactivity.ts new file mode 100644 index 0000000000..3f677faf23 --- /dev/null +++ b/packages/babylon-lite/src/loader-gltf/gltf-feature-interactivity.ts @@ -0,0 +1,78 @@ +// ⚠️ SPEC-VOLATILE — KHR_interactivity is an UNRATIFIED glTF draft. This feature +// is the ONLY loader-side entrypoint for it; all spec-dependent parsing lives +// under flow-graph/gltf/. Mirrored against Babylon.js commit 8f728b23ea. Re-sync +// against BJS PR #18455 ("KHR_interactivity rework") when it lands. +// +// gltf-feature-interactivity: a per-asset glTF feature. At applyAsset time the +// node hierarchy (`ctx._nodeMap`) is built but animation groups are NOT yet +// available, so this feature only does the spec-volatile, node-dependent work — +// parse each graph and pre-resolve its JSON pointers to TRS accessors — and +// hands the result to the AssetContainer. Binding animations + scene capabilities +// and driving the runtime happens later, in addToScene → runFlowGraphs. + +import type { GltfFeature, GltfLoadCtx } from "./gltf-feature.js"; +import type { Mesh } from "../mesh/mesh.js"; +import type { TransformNode } from "../scene/transform-node.js"; +import type { AssetContainer } from "../asset-container.js"; +import type { FgAccessor, LoadedFlowGraph } from "../flow-graph/context.js"; +import type { PointerMaterial } from "./animation-pointer.js"; +import { parseInteractivityGraph, type GltfInteractivityGraph } from "../flow-graph/gltf/interactivity-parser.js"; +import { resolvePointerAccessor } from "../flow-graph/gltf/path-converter.js"; + +interface IKHRInteractivity { + graphs?: GltfInteractivityGraph[]; +} + +/** Build a glTF-material-index → runtime-material map by walking the node→mesh→ + * primitive hierarchy in the same order the loader instantiates GPU meshes + * (mirrors KHR_animation_pointer's `materialMap`). Lets a `pointer/{get,set}` on + * a material's UV transform reach the live PBR material. */ +function buildMaterialMap(json: { nodes?: { mesh?: number }[]; meshes?: { primitives?: { material?: number }[] }[] }, meshes: readonly Mesh[]): (PointerMaterial | undefined)[] { + const map: (PointerMaterial | undefined)[] = []; + const nodes = json.nodes ?? []; + let gpuIdx = 0; + for (let ni = 0; ni < nodes.length; ni++) { + const meshRef = nodes[ni]?.mesh; + if (meshRef === undefined) { + continue; + } + const prims = json.meshes?.[meshRef]?.primitives ?? []; + for (let p = 0; p < prims.length; p++) { + const matIdx = prims[p]?.material; + const mesh = meshes[gpuIdx++]; + if (matIdx !== undefined && mesh) { + map[matIdx] = mesh.material as unknown as PointerMaterial; + } + } + } + return map; +} + +const feature: GltfFeature = { + id: "KHR_interactivity", + async applyAsset(_meshes: Mesh[], _root: TransformNode, ctx: GltfLoadCtx): Promise> { + const ext = ctx._json.extensions?.KHR_interactivity as IKHRInteractivity | undefined; + const graphs = ext?.graphs ?? []; + const nodeMap = ctx._nodeMap ?? []; + const materials = buildMaterialMap(ctx._json, _meshes); + const resolveCtx = { nodeMap, materials, json: ctx._json }; + + const flowGraphs: LoadedFlowGraph[] = []; + for (const graphJson of graphs) { + const { graph, pointers } = await parseInteractivityGraph(graphJson); + const accessors: Record = {}; + for (const pointer of pointers) { + const accessor = resolvePointerAccessor(pointer, resolveCtx); + if (!accessor) { + throw new Error(`KHR_interactivity: cannot resolve pointer ${JSON.stringify(pointer)} (unsupported path or unreachable node)`); + } + accessors[pointer] = accessor; + } + flowGraphs.push({ graph, accessors }); + } + + return flowGraphs.length > 0 ? { flowGraphs } : {}; + }, +}; + +export default feature; diff --git a/packages/babylon-lite/src/loader-gltf/gltf-feature-registry.ts b/packages/babylon-lite/src/loader-gltf/gltf-feature-registry.ts index 01a6885dc6..d0164eaf9a 100644 --- a/packages/babylon-lite/src/loader-gltf/gltf-feature-registry.ts +++ b/packages/babylon-lite/src/loader-gltf/gltf-feature-registry.ts @@ -59,6 +59,7 @@ const _features: [Trigger, Loader][] = [ ["KHR_animation_pointer", () => import("./gltf-feature-animation-pointer.js")], ["EXT_mesh_gpu_instancing", () => import("./gltf-feature-gpu-instancing.js")], ["KHR_xmp_json_ld", () => import("./gltf-feature-xmp.js")], + ["KHR_interactivity", () => import("./gltf-feature-interactivity.js")], ]; /** Dynamic-import every feature the asset triggers. */ diff --git a/packages/babylon-lite/src/scene/scene-core.ts b/packages/babylon-lite/src/scene/scene-core.ts index 1d34712a7e..53424e355b 100644 --- a/packages/babylon-lite/src/scene/scene-core.ts +++ b/packages/babylon-lite/src/scene/scene-core.ts @@ -22,6 +22,7 @@ import type { AssetContainer } from "../asset-container.js"; import type { SceneLightGpuState } from "../render/lights-ubo.js"; import type { ClusteredLightContainer } from "../light/clustered.js"; import type { GaussianSplattingMesh } from "../mesh/GaussianSplatting/gaussian-splatting-mesh.js"; +import type { FgRuntime } from "../flow-graph/runtime.js"; /** Image processing configuration. */ export interface ImageProcessingConfig { @@ -134,6 +135,12 @@ export interface SceneContext extends RenderingContext { _clusteredLightContainer?: ClusteredLightContainer; /** @internal Updates clustered light cells for the camera used by the current render pass. */ _clusteredLightUpdater?: (camera: Camera | null | undefined, targetWidth: number, targetHeight: number) => void; + + /** @internal Flow-graph runtimes attached to this scene (visual scripting / + * glTF KHR_interactivity). Lazily created by `attachFlowGraph`; left + * undefined for non-interactivity scenes so core stays byte-identical. + * Driven via the generic `onBeforeRender` seam, not a hardcoded core loop. */ + _flowGraphs?: FgRuntime[]; } /** Options passed to the scene-context factory. */ diff --git a/scripts/bundle-demos-core.ts b/scripts/bundle-demos-core.ts index 1a38b42238..3fe735f3f3 100644 --- a/scripts/bundle-demos-core.ts +++ b/scripts/bundle-demos-core.ts @@ -225,6 +225,13 @@ function copyDemoRuntimeAssets(demos: DemoConfigEntry[]): void { } } + if (demos.some((demo) => demo.slug === "calculator")) { + const glb = resolve(labDir, "public", "models", "Calculator.glb"); + if (existsSync(glb)) { + cpSync(glb, resolve(demosDir, "Calculator.glb")); + } + } + if (demos.some((demo) => demo.slug === "tetris")) { // Tetris geometry/texture assets (consolidated under lab/public/tetris/) // plus its local studio HDR environment, copied flat so the demo resolves diff --git a/tests/lite/unit/flow-graph-blocks.test.ts b/tests/lite/unit/flow-graph-blocks.test.ts new file mode 100644 index 0000000000..f213384f56 --- /dev/null +++ b/tests/lite/unit/flow-graph-blocks.test.ts @@ -0,0 +1,2054 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AnimationGroup } from "../../../packages/babylon-lite/src/animation/animation-group"; +import type { FgAccessor, FgBlock, FgBlockDef, FgGraph, FgValue, FgWiring } from "../../../packages/babylon-lite/src/flow-graph/index"; +import { + createFgRuntime, + fgInt, + fgMatrix2D, + fgMatrix3D, + isFgMatrix2D, + isFgMatrix3D, + FgEventType, + FgType, + getBlockDef, + getDataValue, + pumpFgEvent, + startFlowGraph, + tickFlowGraph, +} from "../../../packages/babylon-lite/src/flow-graph/index"; + +// ─── graph + runtime builder driven by the REAL registry ──────────────────── + +interface Edge { + blockId: string; + socket: string; +} +interface NodeSpec { + id: string; + type: string; + config?: Record; + /** signal output socket name → wired targets */ + signalTargets?: Record; + /** data input socket name → wired source */ + dataSources?: Record; + /** data input socket name → literal fallback (when unwired) */ + dataDefaults?: Record; +} + +/** Instantiate each node's shape from its def (resolved via the production + * `getBlockDef` registry, unless supplied in `defs`), then wire edges. */ +async function buildGraph(specs: NodeSpec[], variables: FgGraph["variables"], defs: Record): Promise { + const blocks: FgBlock[] = []; + for (const spec of specs) { + const def = defs[spec.type] ?? (await getBlockDef(spec.type)!()); + const shape = def.build(spec.config); + blocks.push({ + id: spec.id, + type: spec.type, + config: spec.config, + dataIn: (shape.dataIn ?? []).map((d) => ({ + name: d.name, + type: d.type, + source: spec.dataSources?.[d.name], + defaultValue: spec.dataDefaults?.[d.name] ?? d.defaultValue, + })), + dataOut: shape.dataOut ?? [], + signalIn: shape.signalIn ?? [], + signalOut: (shape.signalOut ?? []).map((s) => ({ name: s.name, targets: spec.signalTargets?.[s.name] ?? [] })), + event: shape.event, + }); + } + const byId: Record = {}; + blocks.forEach((b, i) => (byId[b.id] = i)); + return { blocks, byId, variables }; +} + +/** Build a graph + runtime, threading test-only `defs` (e.g. the recorder) into + * BOTH shape instantiation and the runtime env so blocks under test still + * resolve via the production registry. */ +async function makeRuntime(specs: NodeSpec[], opts: { variables?: FgGraph["variables"]; wiring?: FgWiring; defs?: Record } = {}) { + const defs = opts.defs ?? {}; + const graph = await buildGraph(specs, opts.variables ?? {}, defs); + return createFgRuntime(graph, { ...(opts.wiring ?? {}), defs }); +} + +/** Test-only recorder: logs incoming signal label + the pulled `value` input. + * Passed via `defs` so blocks UNDER TEST still resolve via the registry. */ +const RECORD = "test/record"; +function recorderDef(log: { label: string; value: FgValue }[]): FgBlockDef { + return { + type: RECORD, + build: () => ({ signalIn: [{ name: "in", targets: [] }], dataIn: [{ name: "value", type: FgType.Any }] }), + execute: (block, ctx, env) => log.push({ label: (block.config?.label as string) ?? "", value: getDataValue(ctx, env, block, "value") }), + }; +} + +// ─── tests ─────────────────────────────────────────────────────────────────── + +describe("flow-graph blocks — events", () => { + it("SceneStart fires its out signal once on start", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "rec", type: RECORD, config: { label: "started" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + startFlowGraph(rt); // idempotent + expect(log.map((e) => e.label)).toEqual(["started"]); + }); + + it("SceneTick accumulates timeSinceStart and exposes deltaTime", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "tick", type: "SceneTickEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "tick", socket: "timeSinceStart" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + tickFlowGraph(rt, 1000); // +1s + tickFlowGraph(rt, 500); // +0.5s + expect(log.map((e) => e.value)).toEqual([1, 1.5]); + }); + + it("OnSelect fires only when its configured node is picked", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "sel", type: "OnSelect", config: { nodeIndex: 14 }, signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "sel", socket: "selectedNodeIndex" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + pumpFgEvent(rt.env.events, FgEventType.Pointer, { nodeIndex: 99 }); + expect(log).toHaveLength(0); // wrong node — inert + pumpFgEvent(rt.env.events, FgEventType.Pointer, { nodeIndex: 14 }); + expect(log).toHaveLength(1); + expect(log[0]!.value).toEqual({ value: 14, __fgInt: true }); + }); +}); + +describe("flow-graph blocks — control flow", () => { + it("Branch routes to onTrue / onFalse by condition", async () => { + for (const [cond, expected] of [ + [true, "T"], + [false, "F"], + ] as const) { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "br", socket: "in" }] } }, + { + id: "br", + type: "Branch", + dataDefaults: { condition: cond }, + signalTargets: { onTrue: [{ blockId: "t", socket: "in" }], onFalse: [{ blockId: "f", socket: "in" }] }, + }, + { id: "t", type: RECORD, config: { label: "T" } }, + { id: "f", type: RECORD, config: { label: "F" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log.map((e) => e.label)).toEqual([expected]); + } + }); + + it("Sequence fires out_0..out_N in order", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "seq", socket: "in" }] } }, + { + id: "seq", + type: "Sequence", + config: { outputSignalCount: 3 }, + signalTargets: { + out_0: [{ blockId: "a", socket: "in" }], + out_1: [{ blockId: "b", socket: "in" }], + out_2: [{ blockId: "c", socket: "in" }], + }, + }, + { id: "a", type: RECORD, config: { label: "0" } }, + { id: "b", type: RECORD, config: { label: "1" } }, + { id: "c", type: RECORD, config: { label: "2" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log.map((e) => e.label)).toEqual(["0", "1", "2"]); + }); +}); + +describe("flow-graph blocks — math/data", () => { + it("Add sums numbers (pulled through to a recorder)", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "add", type: "Add", dataDefaults: { a: 1, b: 2 } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "add", socket: "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log[0]!.value).toBe(3); + }); + + it("Add is component-wise for vectors", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "add", type: "Add", dataDefaults: { a: { x: 1, y: 2, z: 3 }, b: { x: 4, y: 5, z: 6 } } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "add", socket: "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log[0]!.value).toEqual({ x: 5, y: 7, z: 9 }); + }); + + it.each([ + ["Subtract", 7, 4, 3], + ["Multiply", 6, 7, 42], + ["Divide", 20, 5, 4], + ["Modulo", 17, 5, 2], + ])("%s computes a∘b on numbers", async (type, a, b, expected) => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "op", type, dataDefaults: { a, b } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "op", socket: "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log[0]!.value).toBe(expected); + }); + + it("Subtract/Multiply are component-wise for vectors", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { + out: [ + { blockId: "rs", socket: "in" }, + { blockId: "rm", socket: "in" }, + ], + }, + }, + { id: "sub", type: "Subtract", dataDefaults: { a: { x: 5, y: 7 }, b: { x: 1, y: 2 } } }, + { id: "mul", type: "Multiply", dataDefaults: { a: { x: 2, y: 3 }, b: { x: 4, y: 5 } } }, + { id: "rs", type: RECORD, config: { label: "sub" }, dataSources: { value: { blockId: "sub", socket: "value" } } }, + { id: "rm", type: RECORD, config: { label: "mul" }, dataSources: { value: { blockId: "mul", socket: "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log.find((l) => l.label === "sub")!.value).toEqual({ x: 4, y: 5 }); + expect(log.find((l) => l.label === "mul")!.value).toEqual({ x: 8, y: 15 }); + }); + + it.each([ + ["Abs", -3.5, 3.5], + ["Floor", 3.9, 3], + ])("%s is unary", async (type, a, expected) => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "op", type, dataDefaults: { a } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "op", socket: "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log[0]!.value).toBe(expected); + }); + + it.each([ + [1, 2, true], + [2, 2, false], + [3, 2, false], + ])("LessThan(%d, %d) → %s", async (a, b, expected) => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "op", type: "LessThan", dataDefaults: { a, b } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "op", socket: "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log[0]!.value).toBe(expected); + }); + + it.each([ + [-5, -3, 7, -3], + [10, -3, 7, 7], + [4, -3, 7, 4], + ])("Clamp(%d, %d, %d) → %d", async (a, b, c, expected) => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "op", type: "Clamp", dataDefaults: { a, b, c } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "op", socket: "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log[0]!.value).toBe(expected); + }); + + it("CombineVector2 builds a Vec2 from two scalars", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "op", type: "CombineVector2", dataDefaults: { a: 0.8, b: 0.1 } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "op", socket: "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log[0]!.value).toEqual({ x: 0.8, y: 0.1 }); + }); + + it("ExtractVector2 splits a Vec2 into x/y outputs", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { + out: [ + { blockId: "rx", socket: "in" }, + { blockId: "ry", socket: "in" }, + ], + }, + }, + { id: "op", type: "ExtractVector2", dataDefaults: { a: { x: 3, y: 4 } } }, + { id: "rx", type: RECORD, config: { label: "x" }, dataSources: { value: { blockId: "op", socket: "x" } } }, + { id: "ry", type: RECORD, config: { label: "y" }, dataSources: { value: { blockId: "op", socket: "y" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log.find((l) => l.label === "x")!.value).toBe(3); + expect(log.find((l) => l.label === "y")!.value).toBe(4); + }); + + it("Get/SetVariable round-trips a live graph variable", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "set", socket: "in" }] } }, + { + id: "set", + type: "SetVariable", + config: { variable: "score" }, + dataDefaults: { value: 42 }, + signalTargets: { out: [{ blockId: "rec", socket: "in" }] }, + }, + { id: "get", type: "GetVariable", config: { variable: "score" } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "get", socket: "value" } } }, + ], + { variables: { score: { type: FgType.Number, value: 0 } }, defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(rt.context.userVariables.score).toBe(42); + expect(log[0]!.value).toBe(42); + }); +}); + +describe("flow-graph blocks — math Phase 3", () => { + /** Build start→op→recorder, fire start, return the value pulled off `outSocket`. */ + async function evalOp(type: string, dataDefaults: Record, opts: { config?: Record; outSocket?: string } = {}): Promise { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "op", type, config: opts.config, dataDefaults }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "op", socket: opts.outSocket ?? "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + return log[0]!.value; + } + + it.each([ + ["Negation", -5, 5], + ["Sign", -2, -1], + ["Sign", 0, 0], + ["Ceil", 3.2, 4], + ["Round", 2.5, 3], + ["Round", -2.5, -2], + ["Trunc", -3.9, -3], + ["Fraction", 3.25, 0.25], + ["Saturate", 1.5, 1], + ["Saturate", -0.5, 0], + ["SquareRoot", 9, 3], + ["CubeRoot", 27, 3], + ["Exponential", 0, 1], + ["Log2", 8, 3], + ["Log10", 1000, 3], + ])("%s(%d) → %d", async (type, a, expected) => { + expect(await evalOp(type, { a })).toBeCloseTo(expected as number, 10); + }); + + it.each([ + ["Sin", 0, 0], + ["Cos", 0, 1], + ["Tan", 0, 0], + ["Asin", 1, Math.PI / 2], + ["Acos", 1, 0], + ["Atan", 0, 0], + ["Sinh", 0, 0], + ["Cosh", 0, 1], + ["Tanh", 0, 0], + ["DegToRad", 180, Math.PI], + ["RadToDeg", Math.PI, 180], + ])("%s(%d) trig/conv", async (type, a, expected) => { + expect(await evalOp(type, { a })).toBeCloseTo(expected as number, 10); + }); + + it.each([ + ["Min", 3, 5, 3], + ["Max", 3, 5, 5], + ["Power", 2, 10, 1024], + ["Atan2", 1, 1, Math.PI / 4], + ])("%s(%d, %d) → %d", async (type, a, b, expected) => { + expect(await evalOp(type, { a, b })).toBeCloseTo(expected as number, 10); + }); + + it.each([ + ["Equality", 2, 2, true], + ["Equality", 2, 3, false], + ["LessThanOrEqual", 2, 2, true], + ["LessThanOrEqual", 3, 2, false], + ["GreaterThan", 3, 2, true], + ["GreaterThan", 2, 2, false], + ["GreaterThanOrEqual", 2, 2, true], + ["GreaterThanOrEqual", 1, 2, false], + ])("%s(%d, %d) → %s", async (type, a, b, expected) => { + expect(await evalOp(type, { a, b })).toBe(expected); + }); + + it.each([ + ["IsNaN", NaN, true], + ["IsNaN", 3, false], + ["IsInfinity", Infinity, true], + ["IsInfinity", 3, false], + ])("%s(%d) → %s", async (type, a, expected) => { + expect(await evalOp(type, { a })).toBe(expected); + }); + + it.each([ + ["BitwiseAnd", 6, 3, 2], + ["BitwiseOr", 6, 1, 7], + ["BitwiseXor", 6, 3, 5], + ["BitwiseLeftShift", 1, 4, 16], + ["BitwiseRightShift", 16, 2, 4], + ])("%s(%d, %d) on numbers → %d", async (type, a, b, expected) => { + const r = await evalOp(type, { a, b }); + expect(isFgIntResult(r) ? (r as { value: number }).value : r).toBe(expected); + }); + + it.each([ + ["LeadingZeros", 1, 31], + ["TrailingZeros", 8, 3], + ["OneBitsCounter", 7, 3], + ["BitwiseNot", 0, -1], + ])("%s(%d) on numbers → %d", async (type, a, expected) => { + const r = await evalOp(type, { a }); + expect(isFgIntResult(r) ? (r as { value: number }).value : r).toBe(expected); + }); + + it("bitwise and/or/not dispatch booleans logically", async () => { + expect(await evalOp("BitwiseAnd", { a: true, b: false })).toBe(false); + expect(await evalOp("BitwiseOr", { a: true, b: false })).toBe(true); + expect(await evalOp("BitwiseNot", { a: true })).toBe(false); + }); + + it("bitwise ops round-trip FlowGraphInteger", async () => { + const r = await evalOp("BitwiseAnd", { a: fgInt(6), b: fgInt(3) }); + expect(isFgIntResult(r)).toBe(true); + expect((r as { value: number }).value).toBe(2); + }); + + it.each(["Length", "Dot"] as const)("vector scalar op %s", async (type) => { + if (type === "Length") { + expect(await evalOp("Length", { a: { x: 3, y: 4, z: 0 } })).toBeCloseTo(5, 10); + } else { + expect(await evalOp("Dot", { a: { x: 1, y: 2, z: 3 }, b: { x: 4, y: 5, z: 6 } })).toBeCloseTo(32, 10); + } + }); + + it("Normalize returns a unit vector", async () => { + const r = (await evalOp("Normalize", { a: { x: 3, y: 4, z: 0 } })) as { x: number; y: number; z: number }; + expect(r.x).toBeCloseTo(0.6, 10); + expect(r.y).toBeCloseTo(0.8, 10); + expect(r.z).toBeCloseTo(0, 10); + }); + + it("Cross computes the right-handed cross product", async () => { + expect(await evalOp("Cross", { a: { x: 1, y: 0, z: 0 }, b: { x: 0, y: 1, z: 0 } })).toEqual({ x: 0, y: 0, z: 1 }); + }); + + it("Rotate2D rotates a Vec2 CCW by angle (b)", async () => { + const r = (await evalOp("Rotate2D", { a: { x: 1, y: 0 }, b: Math.PI / 2 })) as { x: number; y: number }; + expect(r.x).toBeCloseTo(0, 10); + expect(r.y).toBeCloseTo(1, 10); + }); + + it("Rotate3D by identity quaternion is a no-op", async () => { + const r = (await evalOp("Rotate3D", { a: { x: 1, y: 2, z: 3 }, b: { x: 0, y: 0, z: 0, w: 1 } })) as { x: number; y: number; z: number }; + expect(r.x).toBeCloseTo(1, 10); + expect(r.y).toBeCloseTo(2, 10); + expect(r.z).toBeCloseTo(3, 10); + }); + + it("CombineVector3/4 build vectors from scalars", async () => { + expect(await evalOp("CombineVector3", { a: 1, b: 2, c: 3 })).toEqual({ x: 1, y: 2, z: 3 }); + expect(await evalOp("CombineVector4", { a: 1, b: 2, c: 3, d: 4 })).toEqual({ x: 1, y: 2, z: 3, w: 4 }); + }); + + it("ExtractVector3/4 split vectors into component sockets", async () => { + expect(await evalOp("ExtractVector3", { a: { x: 7, y: 8, z: 9 } }, { outSocket: "z" })).toBe(9); + expect(await evalOp("ExtractVector4", { a: { x: 7, y: 8, z: 9, w: 10 } }, { outSocket: "w" })).toBe(10); + }); + + it.each([ + ["E", Math.E], + ["PI", Math.PI], + ])("constant %s emits its value", async (type, expected) => { + expect(await evalOp(type, {})).toBeCloseTo(expected as number, 12); + }); + + it("Inf and NaN constants", async () => { + expect(await evalOp("Inf", {})).toBe(Infinity); + expect(Number.isNaN(await evalOp("NaN", {}))).toBe(true); + }); + + it("MathInterpolation mixes a→b by t = (1-t)a + t·b", async () => { + expect(await evalOp("MathInterpolation", { a: 0, b: 10, c: 0.25 })).toBeCloseTo(2.5, 10); + }); + + it("Conditional (select) picks onTrue/onFalse from condition", async () => { + expect(await evalOp("Conditional", { condition: true, onTrue: 11, onFalse: 22 })).toBe(11); + expect(await evalOp("Conditional", { condition: false, onTrue: 11, onFalse: 22 })).toBe(22); + }); + + it("DataSwitch selects the matching case, else default", async () => { + const config = { cases: [0, 1, 2] }; + expect(await evalOp("DataSwitch", { case: 1, default: -1, in_0: 10, in_1: 20, in_2: 30 }, { config })).toBe(20); + expect(await evalOp("DataSwitch", { case: 9, default: -1, in_0: 10, in_1: 20, in_2: 30 }, { config })).toBe(-1); + }); + + it.each([ + ["BooleanToFloat", true, 1], + ["BooleanToInt", false, 0], + ["FloatToBoolean", 0, false], + ["FloatToBoolean", 2.5, true], + ["IntToFloat", 5, 5], + ])("conversion %s", async (type, a, expected) => { + const r = await evalOp(type, { a }); + const v = isFgIntResult(r) ? (r as { value: number }).value : r; + expect(v).toBe(expected); + }); +}); + +/** True when a value is a FlowGraphInteger box (re-wrapped by bitwise/int ops). */ +function isFgIntResult(v: FgValue): boolean { + return ( + typeof v === "object" && + v !== null && + "value" in (v as unknown as Record) && + typeof (v as { value: unknown }).value === "number" && + !("x" in (v as unknown as Record)) + ); +} + +describe("flow-graph blocks — property accessors", () => { + function vec3Accessor(initial: { x: number; y: number; z: number }): { acc: FgAccessor; box: { v: FgValue } } { + const box = { v: initial as FgValue }; + return { + box, + acc: { + type: FgType.Vector3, + get: () => box.v, + set: (value) => { + box.v = value; + }, + }, + }; + } + + it("SetProperty writes through a resolved accessor and fires out", async () => { + const log: { label: string; value: FgValue }[] = []; + const { acc, box } = vec3Accessor({ x: 0, y: 0, z: 0 }); + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "set", socket: "in" }] } }, + { + id: "set", + type: "SetProperty", + config: { accessor: "/nodes/0/translation" }, + dataDefaults: { value: { x: 1, y: 1, z: 1 } }, + signalTargets: { out: [{ blockId: "rec", socket: "in" }] }, + }, + { id: "rec", type: RECORD, config: { label: "ok" } }, + ], + { wiring: { accessors: { "/nodes/0/translation": acc } }, defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(box.v).toEqual({ x: 1, y: 1, z: 1 }); + expect(log.map((e) => e.label)).toEqual(["ok"]); + }); + + it("SetProperty fires error when the accessor is missing", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "set", socket: "in" }] } }, + { + id: "set", + type: "SetProperty", + config: { accessor: "/missing" }, + dataDefaults: { value: 1 }, + signalTargets: { error: [{ blockId: "rec", socket: "in" }] }, + }, + { id: "rec", type: RECORD, config: { label: "err" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log.map((e) => e.label)).toEqual(["err"]); + }); + + it("GetProperty reads through a resolved accessor", async () => { + const log: { label: string; value: FgValue }[] = []; + const { acc } = vec3Accessor({ x: 7, y: 8, z: 9 }); + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "get", type: "GetProperty", config: { accessor: "p" } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "get", socket: "value" } } }, + ], + { wiring: { accessors: { p: acc } }, defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log[0]!.value).toEqual({ x: 7, y: 8, z: 9 }); + }); +}); + +describe("flow-graph blocks — animation", () => { + function mockGroup(): AnimationGroup { + return { + name: "anim", + duration: 1, + isPlaying: false, + currentTime: 0, + targetedAnimations: [], + speedRatio: 1, + loopAnimation: false, + weight: 1, + _stopped: false, + } as AnimationGroup; + } + + it("PlayAnimation invokes the play capability and fires out, then done on end", async () => { + const log: { label: string; value: FgValue }[] = []; + const group = mockGroup(); + let endCb: (() => void) | undefined; + const wiring: FgWiring = { + animations: [group], + caps: { + playAnimation: vi.fn((g, opts) => { + g.isPlaying = true; + if (opts?.speed !== undefined) { + g.speedRatio = opts.speed; + } + }), + onAnimationEnd: (_g, cb) => { + endCb = cb; + return () => { + endCb = undefined; + }; + }, + }, + }; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "play", socket: "in" }] } }, + { + id: "play", + type: "PlayAnimation", + dataDefaults: { animation: 0, speed: 2 }, + signalTargets: { out: [{ blockId: "o", socket: "in" }], done: [{ blockId: "d", socket: "in" }] }, + }, + { id: "o", type: RECORD, config: { label: "out" } }, + { id: "d", type: RECORD, config: { label: "done" } }, + ], + { wiring, defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(wiring.caps!.playAnimation).toHaveBeenCalledTimes(1); + expect(group.speedRatio).toBe(2); + expect(log.map((e) => e.label)).toEqual(["out"]); + endCb?.(); // simulate the animation ending + expect(log.map((e) => e.label)).toEqual(["out", "done"]); + }); + + it("StopAnimation invokes the stop capability", async () => { + const group = mockGroup(); + const stop = vi.fn(); + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "stop", socket: "in" }] } }, + { id: "stop", type: "StopAnimation", dataDefaults: { animation: 0 } }, + ], + { wiring: { animations: [group], caps: { stopAnimation: stop } } } + ); + startFlowGraph(rt); + expect(stop).toHaveBeenCalledWith(group); + }); + + it("StopAnimation with stopAtFrame defers the stop until the frame is reached", async () => { + const group = mockGroup(); + (group as { frameRate: number }).frameRate = 60; + group.isPlaying = true; + const stop = vi.fn(); + const stopAt = vi.fn(); + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "stop", socket: "in" }] } }, + { + id: "stop", + type: "StopAnimation", + dataDefaults: { animation: 0, stopAtFrame: 30 }, + signalTargets: { out: [{ blockId: "o", socket: "in" }] }, + }, + { id: "o", type: RECORD, config: { label: "out" } }, + ], + { wiring: { animations: [group], caps: { stopAnimation: stop, stopAnimationAt: stopAt } }, defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + // out fires immediately; no immediate stop, deferred monitor armed. + expect(log.map((e) => e.label)).toEqual(["out"]); + expect(stop).not.toHaveBeenCalled(); + expect(stopAt).not.toHaveBeenCalled(); + + // Not yet at the target frame. + group.currentTime = 0.25; // frame 15 + tickFlowGraph(rt, 16); + expect(stopAt).not.toHaveBeenCalled(); + + // Reached the target frame (30 / 60 = 0.5s). + group.currentTime = 0.5; + tickFlowGraph(rt, 16); + expect(stopAt).toHaveBeenCalledWith(group, 30); + }); +}); + +describe("flow-graph blocks — matrix/quaternion", () => { + /** Reuse the evalOp helper from Phase-3 tests. Same graph shape: + * start → op → recorder; returns the value at `outSocket`. */ + async function evalOp(type: string, dataDefaults: Record, opts: { config?: Record; outSocket?: string } = {}): Promise { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "op", type, config: opts.config, dataDefaults }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "op", socket: opts.outSocket ?? "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + return log[0]!.value; + } + + // ─── TransformVector ────────────────────────────────────────────────────── + + it("TransformVector: Vec2 × Matrix2D (M·v)", async () => { + // M (col-major) = [1,2,3,4] → [[1,3],[2,4]] + // v = (5,6); M·v = (1*5+3*6, 2*5+4*6) = (23, 34) + const m = fgMatrix2D([1, 2, 3, 4]); + const r = (await evalOp("TransformVector", { a: { x: 5, y: 6 }, b: m })) as { x: number; y: number }; + expect(r.x).toBeCloseTo(23, 8); + expect(r.y).toBeCloseTo(34, 8); + }); + + it("TransformVector: Vec3 × Matrix3D (M·v)", async () => { + // Identity 3x3 → result = input + const m = fgMatrix3D(); + const r = (await evalOp("TransformVector", { a: { x: 1, y: 2, z: 3 }, b: m })) as { x: number; y: number; z: number }; + expect(r.x).toBeCloseTo(1, 8); + expect(r.y).toBeCloseTo(2, 8); + expect(r.z).toBeCloseTo(3, 8); + }); + + // ─── MatrixMultiplication ───────────────────────────────────────────────── + + it("MatrixMultiplication: 2x2 col-major A×B", async () => { + // A=[1,2,3,4] = [[1,3],[2,4]], B=[5,6,7,8] = [[5,7],[6,8]] + // A×B[0][0] = 1*5+3*6=23, [1][0]=2*5+4*6=34, [0][1]=1*7+3*8=31, [1][1]=2*7+4*8=46 + const a = fgMatrix2D([1, 2, 3, 4]); + const b = fgMatrix2D([5, 6, 7, 8]); + const r = await evalOp("MatrixMultiplication", { a, b }); + expect(isFgMatrix2D(r)).toBe(true); + if (isFgMatrix2D(r)) { + expect(r.m[0]).toBeCloseTo(23, 8); // col0, row0 + expect(r.m[1]).toBeCloseTo(34, 8); // col0, row1 + expect(r.m[2]).toBeCloseTo(31, 8); // col1, row0 + expect(r.m[3]).toBeCloseTo(46, 8); // col1, row1 + } + }); + + it("MatrixMultiplication: identity × M = M (3x3)", async () => { + const identity = fgMatrix3D(); + const m = fgMatrix3D([1, 2, 3, 4, 5, 6, 7, 8, 9]); + const r = await evalOp("MatrixMultiplication", { a: identity, b: m }); + expect(isFgMatrix3D(r)).toBe(true); + if (isFgMatrix3D(r)) { + for (let i = 0; i < 9; i++) expect(r.m[i]).toBeCloseTo(m.m[i]!, 6); + } + }); + + // ─── Determinant ────────────────────────────────────────────────────────── + + it("Determinant: 2x2 [1,2,3,4] = 1*4 - 2*3 = -2", async () => { + const m = fgMatrix2D([1, 2, 3, 4]); + expect(await evalOp("Determinant", { a: m })).toBeCloseTo(-2, 8); + }); + + it("Determinant: 3x3 identity = 1", async () => { + expect(await evalOp("Determinant", { a: fgMatrix3D() })).toBeCloseTo(1, 8); + }); + + // ─── InvertMatrix ───────────────────────────────────────────────────────── + + it("InvertMatrix: M × inv(M) ≈ identity (2x2)", async () => { + const m = fgMatrix2D([1, 2, 3, 4]); + const inv = await evalOp("InvertMatrix", { a: m }); + expect(isFgMatrix2D(inv)).toBe(true); + if (isFgMatrix2D(inv)) { + // product M × inv should be identity + const am = m.m, + bm = inv.m; + const c00 = am[0]! * bm[0]! + am[2]! * bm[1]!; + const c11 = am[1]! * bm[2]! + am[3]! * bm[3]!; + expect(c00).toBeCloseTo(1, 6); + expect(c11).toBeCloseTo(1, 6); + } + }); + + it("InvertMatrix: identity inverse is identity (3x3)", async () => { + const inv = await evalOp("InvertMatrix", { a: fgMatrix3D() }); + expect(isFgMatrix3D(inv)).toBe(true); + if (isFgMatrix3D(inv)) { + expect(inv.m[0]).toBeCloseTo(1, 6); + expect(inv.m[4]).toBeCloseTo(1, 6); + expect(inv.m[8]).toBeCloseTo(1, 6); + } + }); + + // ─── Transpose ─────────────────────────────────────────────────────────── + + it("Transpose: 2x2 swaps off-diagonal", async () => { + const m = fgMatrix2D([1, 2, 3, 4]); // col-major: [[1,3],[2,4]] + const t = await evalOp("Transpose", { a: m }); + expect(isFgMatrix2D(t)).toBe(true); + if (isFgMatrix2D(t)) { + // transposed col-major = [1,3,2,4] + expect(t.m[0]).toBeCloseTo(1, 8); + expect(t.m[1]).toBeCloseTo(3, 8); + expect(t.m[2]).toBeCloseTo(2, 8); + expect(t.m[3]).toBeCloseTo(4, 8); + } + }); + + // ─── CombineMatrix / ExtractMatrix round-trips ──────────────────────────── + + it("CombineMatrix2D → ExtractMatrix2D round-trips 4 inputs", async () => { + const inputs = [1, 2, 3, 4]; + const dataDefaults: Record = {}; + inputs.forEach((v, i) => (dataDefaults[`input_${i}`] = v)); + const combined = await evalOp("CombineMatrix2D", dataDefaults); + expect(isFgMatrix2D(combined)).toBe(true); + if (isFgMatrix2D(combined)) { + for (let i = 0; i < 4; i++) { + const e = await evalOp("ExtractMatrix2D", { input: combined }, { outSocket: `output_${i}` }); + expect(e).toBeCloseTo(inputs[i]!, 8); + } + } + }); + + it("CombineMatrix3D → ExtractMatrix3D round-trips 9 inputs", async () => { + const inputs = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + const dataDefaults: Record = {}; + inputs.forEach((v, i) => (dataDefaults[`input_${i}`] = v)); + const combined = await evalOp("CombineMatrix3D", dataDefaults); + expect(isFgMatrix3D(combined)).toBe(true); + if (isFgMatrix3D(combined)) { + for (let i = 0; i < 9; i++) { + const e = await evalOp("ExtractMatrix3D", { input: combined }, { outSocket: `output_${i}` }); + expect(e).toBeCloseTo(inputs[i]!, 8); + } + } + }); + + it("CombineMatrix (4x4) → ExtractMatrix round-trips 16 inputs", async () => { + const inputs = Array.from({ length: 16 }, (_, i) => i + 1); + const dataDefaults: Record = {}; + inputs.forEach((v, i) => (dataDefaults[`input_${i}`] = v)); + const combined = await evalOp("CombineMatrix", dataDefaults); + expect(combined instanceof Float32Array).toBe(true); + for (let i = 0; i < 16; i++) { + const e = await evalOp("ExtractMatrix", { input: combined }, { outSocket: `output_${i}` }); + expect(e).toBeCloseTo(inputs[i]!, 8); + } + }); + + // ─── MatrixCompose / MatrixDecompose ────────────────────────────────────── + + it("MatrixCompose then MatrixDecompose round-trips TRS", async () => { + const pos = { x: 1, y: 2, z: 3 }; + const rot = { x: 0, y: 0, z: 0, w: 1 }; // identity quat + const scl = { x: 2, y: 3, z: 4 }; + const mat = await evalOp("MatrixCompose", { + position: pos, + rotationQuaternion: rot, + scaling: scl, + }); + + const posOut = (await evalOp("MatrixDecompose", { input: mat }, { outSocket: "position" })) as { x: number; y: number; z: number }; + const sclOut = (await evalOp("MatrixDecompose", { input: mat }, { outSocket: "scaling" })) as { x: number; y: number; z: number }; + const validOut = await evalOp("MatrixDecompose", { input: mat }, { outSocket: "isValid" }); + + expect(posOut.x).toBeCloseTo(1, 5); + expect(posOut.y).toBeCloseTo(2, 5); + expect(posOut.z).toBeCloseTo(3, 5); + expect(sclOut.x).toBeCloseTo(2, 5); + expect(sclOut.y).toBeCloseTo(3, 5); + expect(sclOut.z).toBeCloseTo(4, 5); + expect(validOut).toBe(true); + }); + + it("MatrixDecompose returns isValid=false for non-TRS matrix", async () => { + // A matrix with a bad bottom row ([1,0,0,1] instead of [0,0,0,1]). + // In column-major 4x4, bottom row is at indices 3,7,11,15. + const bad = new Float32Array(16); + bad[0] = 1; + bad[5] = 1; + bad[10] = 1; + bad[15] = 1; // identity base + bad[3] = 1; // break bottom row: m[3] should be 0 + const validOut = await evalOp("MatrixDecompose", { input: bad as unknown as FgValue }, { outSocket: "isValid" }); + expect(validOut).toBe(false); + }); + + // ─── Quaternion ops ─────────────────────────────────────────────────────── + + it("Conjugate: (-x,-y,-z,w)", async () => { + const r = (await evalOp("Conjugate", { a: { x: 1, y: 2, z: 3, w: 4 } })) as { x: number; y: number; z: number; w: number }; + expect(r.x).toBeCloseTo(-1, 8); + expect(r.y).toBeCloseTo(-2, 8); + expect(r.z).toBeCloseTo(-3, 8); + expect(r.w).toBeCloseTo(4, 8); + }); + + it("QuaternionFromAxisAngle: Y-axis 90° → known components", async () => { + // axis=(0,1,0), angle=PI/2 → quat=(0, sin(PI/4), 0, cos(PI/4)) + const r = (await evalOp("QuaternionFromAxisAngle", { + a: { x: 0, y: 1, z: 0 }, + b: Math.PI / 2, + })) as { x: number; y: number; z: number; w: number }; + expect(r.x).toBeCloseTo(0, 8); + expect(r.y).toBeCloseTo(Math.sin(Math.PI / 4), 8); + expect(r.z).toBeCloseTo(0, 8); + expect(r.w).toBeCloseTo(Math.cos(Math.PI / 4), 8); + }); + + it("AxisAngleFromQuaternion round-trips a known quaternion", async () => { + // quat from Y-axis 90° + const q = { x: 0, y: Math.sin(Math.PI / 4), z: 0, w: Math.cos(Math.PI / 4) }; + const axis = (await evalOp("AxisAngleFromQuaternion", { a: q }, { outSocket: "axis" })) as { x: number; y: number; z: number }; + const angle = (await evalOp("AxisAngleFromQuaternion", { a: q }, { outSocket: "angle" })) as number; + const valid = await evalOp("AxisAngleFromQuaternion", { a: q }, { outSocket: "isValid" }); + expect(axis.x).toBeCloseTo(0, 5); + expect(axis.y).toBeCloseTo(1, 5); + expect(axis.z).toBeCloseTo(0, 5); + expect(angle).toBeCloseTo(Math.PI / 2, 5); + expect(valid).toBe(true); + }); + + it("AngleBetween: same quaternion → 0", async () => { + const q = { x: 0, y: 0, z: 0, w: 1 }; + expect(await evalOp("AngleBetween", { a: q, b: q })).toBeCloseTo(0, 8); + }); + + it("AngleBetween: identity vs 180° rotation → PI", async () => { + // 180° rotation around any axis: quat = (0,1,0,0) (Y-axis 180°) + const q0 = { x: 0, y: 0, z: 0, w: 1 }; + const q180 = { x: 0, y: 1, z: 0, w: 0 }; + const result = await evalOp("AngleBetween", { a: q0, b: q180 }); + expect(result as number).toBeCloseTo(Math.PI, 5); + }); + + it("QuaternionFromDirections: (1,0,0) → (0,1,0) rotates 90° around Z", async () => { + const a = { x: 1, y: 0, z: 0 }; + const b = { x: 0, y: 1, z: 0 }; + const r = (await evalOp("QuaternionFromDirections", { a, b })) as { x: number; y: number; z: number; w: number }; + // cross(a,b) = (0,0,1); angle = PI/2 → quat = (0,0,sin(PI/4),cos(PI/4)) + expect(r.x).toBeCloseTo(0, 5); + expect(r.y).toBeCloseTo(0, 5); + expect(r.z).toBeCloseTo(Math.sin(Math.PI / 4), 5); + expect(r.w).toBeCloseTo(Math.cos(Math.PI / 4), 5); + }); + + it("QuaternionMultiplication: Hamilton product i⊗j = k", async () => { + const i = { x: 1, y: 0, z: 0, w: 0 }; + const j = { x: 0, y: 1, z: 0, w: 0 }; + const r = (await evalOp("QuaternionMultiplication", { a: i, b: j })) as { x: number; y: number; z: number; w: number }; + expect(r.x).toBeCloseTo(0, 8); + expect(r.y).toBeCloseTo(0, 8); + expect(r.z).toBeCloseTo(1, 8); + expect(r.w).toBeCloseTo(0, 8); + }); + + it("QuaternionMultiplication: two Y-axis 90° rotations compose to Y-axis 180°", async () => { + const s = Math.sin(Math.PI / 4); + const c = Math.cos(Math.PI / 4); + const q = { x: 0, y: s, z: 0, w: c }; + const r = (await evalOp("QuaternionMultiplication", { a: q, b: q })) as { x: number; y: number; z: number; w: number }; + expect(r.x).toBeCloseTo(0, 8); + expect(r.y).toBeCloseTo(1, 8); + expect(r.z).toBeCloseTo(0, 8); + expect(r.w).toBeCloseTo(0, 8); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Phase 3h — control-flow blocks +// ───────────────────────────────────────────────────────────────────────────── + +describe("flow-graph blocks — control flow Phase 3h", () => { + // ── Switch ──────────────────────────────────────────────────────────────── + + it("Switch routes to matching out_ when case is known", async () => { + for (const [sel, expected] of [ + [0, "zero"], + [1, "one"], + [99, "default"], + ] as const) { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "sw", socket: "in" }] } }, + { + id: "sw", + type: "Switch", + config: { cases: [0, 1] }, + dataDefaults: { case: sel }, + signalTargets: { + out_0: [{ blockId: "r0", socket: "in" }], + out_1: [{ blockId: "r1", socket: "in" }], + default: [{ blockId: "rd", socket: "in" }], + }, + }, + { id: "r0", type: RECORD, config: { label: "zero" } }, + { id: "r1", type: RECORD, config: { label: "one" } }, + { id: "rd", type: RECORD, config: { label: "default" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log.map((e) => e.label)).toEqual([expected]); + } + }); + + it("Switch routes to default when no case matches", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "sw", socket: "in" }] } }, + { + id: "sw", + type: "Switch", + config: { cases: [5, 10] }, + dataDefaults: { case: 42 }, + signalTargets: { default: [{ blockId: "rec", socket: "in" }] }, + }, + { id: "rec", type: RECORD, config: { label: "hit" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log.map((e) => e.label)).toEqual(["hit"]); + }); + + // ── ForLoop ─────────────────────────────────────────────────────────────── + + it("ForLoop fires executionFlow for indices [start, end) exclusively", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "fl", socket: "in" }] } }, + { + id: "fl", + type: "ForLoop", + dataDefaults: { startIndex: 2, endIndex: 5, step: 1 }, + signalTargets: { + executionFlow: [{ blockId: "rec", socket: "in" }], + completed: [{ blockId: "done", socket: "in" }], + }, + }, + { id: "rec", type: RECORD, config: { label: "body" }, dataSources: { value: { blockId: "fl", socket: "index" } } }, + { id: "done", type: RECORD, config: { label: "done" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + // Indices 2, 3, 4 — end is EXCLUSIVE (BJS: i < endIndex); then "done" fires. + expect(log.filter((e) => e.label === "body").map((e) => e.value)).toEqual([fgInt(2), fgInt(3), fgInt(4)]); + expect(log.filter((e) => e.label === "done")).toHaveLength(1); + }); + + it("ForLoop fires completed immediately when startIndex >= endIndex", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "fl", socket: "in" }] } }, + { + id: "fl", + type: "ForLoop", + dataDefaults: { startIndex: 3, endIndex: 3 }, + signalTargets: { + executionFlow: [{ blockId: "body", socket: "in" }], + completed: [{ blockId: "done", socket: "in" }], + }, + }, + { id: "body", type: RECORD, config: { label: "body" } }, + { id: "done", type: RECORD, config: { label: "done" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log.map((e) => e.label)).toEqual(["done"]); + }); + + // ── WhileLoop ───────────────────────────────────────────────────────────── + + it("WhileLoop fires body N times then completed when condition goes false", async () => { + const log: { label: string; value: FgValue }[] = []; + let condVal = true; + let callCount = 0; + + // Stateful recorder that flips the condition after 3 calls. + const flipDef: FgBlockDef = { + type: "test/flip", + build: () => ({ signalIn: [{ name: "in", targets: [] }], dataIn: [{ name: "value", type: FgType.Any }] }), + execute: (_block, _ctx, _env) => { + callCount++; + log.push({ label: "body", value: callCount as unknown as FgValue }); + if (callCount >= 3) condVal = false; + }, + }; + const condDef: FgBlockDef = { + type: "test/cond", + build: () => ({ dataOut: [{ name: "value", type: FgType.Boolean }] }), + updateOutputs: (_block, ctx, _env) => { + ctx.connectionValues["cond:value"] = condVal; + }, + }; + + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "wl", socket: "in" }] } }, + { + id: "wl", + type: "WhileLoop", + dataSources: { condition: { blockId: "cond", socket: "value" } }, + signalTargets: { + executionFlow: [{ blockId: "flip", socket: "in" }], + completed: [{ blockId: "done", socket: "in" }], + }, + }, + { id: "cond", type: "test/cond" }, + { id: "flip", type: "test/flip" }, + { id: "done", type: RECORD, config: { label: "done" } }, + ], + { defs: { [RECORD]: recorderDef(log), "test/flip": flipDef, "test/cond": condDef } } + ); + startFlowGraph(rt); + expect(log.filter((e) => e.label === "body")).toHaveLength(3); + expect(log[log.length - 1]!.label).toBe("done"); + }); + + // ── DoN ─────────────────────────────────────────────────────────────────── + + it("DoN fires out only for the first N activations", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { + out: [ + { blockId: "don", socket: "in" }, + { blockId: "don", socket: "in" }, + { blockId: "don", socket: "in" }, + { blockId: "don", socket: "in" }, + ], + }, + }, + { + id: "don", + type: "DoN", + dataDefaults: { maxExecutions: fgInt(3) }, + signalTargets: { out: [{ blockId: "rec", socket: "in" }] }, + }, + { id: "rec", type: RECORD, config: { label: "fired" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + // 4 activations but max is 3 → only 3 should fire. + expect(log.filter((e) => e.label === "fired")).toHaveLength(3); + }); + + it("DoN reset re-arms the block", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { + out: [ + { blockId: "don", socket: "in" }, + { blockId: "don", socket: "in" }, + { blockId: "don", socket: "in" }, // exhausts N=2 + { blockId: "don", socket: "reset" }, // resets + { blockId: "don", socket: "in" }, // fires again + ], + }, + }, + { + id: "don", + type: "DoN", + dataDefaults: { maxExecutions: fgInt(2) }, + signalTargets: { out: [{ blockId: "rec", socket: "in" }] }, + }, + { id: "rec", type: RECORD, config: { label: "fired" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + // 3 activate before exhaustion (N=2 → 2 pass), then reset, then 1 more. + expect(log.filter((e) => e.label === "fired")).toHaveLength(3); + }); + + // ── MultiGate ───────────────────────────────────────────────────────────── + + it("MultiGate cycles through outputs sequentially", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { + out: [ + { blockId: "mg", socket: "in" }, + { blockId: "mg", socket: "in" }, + { blockId: "mg", socket: "in" }, + ], + }, + }, + { + id: "mg", + type: "MultiGate", + config: { outputSignalCount: 3, isRandom: false, isLoop: false }, + signalTargets: { + out_0: [{ blockId: "r0", socket: "in" }], + out_1: [{ blockId: "r1", socket: "in" }], + out_2: [{ blockId: "r2", socket: "in" }], + }, + }, + { id: "r0", type: RECORD, config: { label: "0" } }, + { id: "r1", type: RECORD, config: { label: "1" } }, + { id: "r2", type: RECORD, config: { label: "2" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log.map((e) => e.label)).toEqual(["0", "1", "2"]); + }); + + it("MultiGate with isLoop wraps around", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { + out: [ + { blockId: "mg", socket: "in" }, + { blockId: "mg", socket: "in" }, + { blockId: "mg", socket: "in" }, + { blockId: "mg", socket: "in" }, // wraps + ], + }, + }, + { + id: "mg", + type: "MultiGate", + config: { outputSignalCount: 2, isRandom: false, isLoop: true }, + signalTargets: { + out_0: [{ blockId: "r0", socket: "in" }], + out_1: [{ blockId: "r1", socket: "in" }], + }, + }, + { id: "r0", type: RECORD, config: { label: "A" } }, + { id: "r1", type: RECORD, config: { label: "B" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log.map((e) => e.label)).toEqual(["A", "B", "A", "B"]); + }); + + it("MultiGate reset clears state", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { + out: [ + { blockId: "mg", socket: "in" }, + { blockId: "mg", socket: "in" }, // exhausts 2 + { blockId: "mg", socket: "reset" }, + { blockId: "mg", socket: "in" }, // starts over + ], + }, + }, + { + id: "mg", + type: "MultiGate", + config: { outputSignalCount: 2, isRandom: false, isLoop: false }, + signalTargets: { + out_0: [{ blockId: "r0", socket: "in" }], + out_1: [{ blockId: "r1", socket: "in" }], + }, + }, + { id: "r0", type: RECORD, config: { label: "A" } }, + { id: "r1", type: RECORD, config: { label: "B" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + // First 2 activations → A, B (exhausted). After reset → A again. + expect(log.map((e) => e.label)).toEqual(["A", "B", "A"]); + }); + + // ── WaitAll ─────────────────────────────────────────────────────────────── + + it("WaitAll fires completed only after all inputs received", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { + out: [ + { blockId: "wa", socket: "in_0" }, + { blockId: "wa", socket: "in_1" }, // completes + ], + }, + }, + { + id: "wa", + type: "WaitAll", + config: { inputSignalCount: 2 }, + signalTargets: { + out: [{ blockId: "partial", socket: "in" }], + completed: [{ blockId: "done", socket: "in" }], + }, + }, + { id: "partial", type: RECORD, config: { label: "partial" } }, + { id: "done", type: RECORD, config: { label: "done" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + // in_0 → "partial"; in_1 completes → "done" + expect(log.map((e) => e.label)).toEqual(["partial", "done"]); + }); + + it("WaitAll reset clears received flags", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { + out: [ + { blockId: "wa", socket: "in_0" }, // partial + { blockId: "wa", socket: "reset" }, // resets + { blockId: "wa", socket: "in_0" }, // partial again + { blockId: "wa", socket: "in_1" }, // now completes + ], + }, + }, + { + id: "wa", + type: "WaitAll", + config: { inputSignalCount: 2 }, + signalTargets: { + out: [{ blockId: "partial", socket: "in" }], + completed: [{ blockId: "done", socket: "in" }], + }, + }, + { id: "partial", type: RECORD, config: { label: "partial" } }, + { id: "done", type: RECORD, config: { label: "done" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + // in_0 → partial; reset; in_0 → partial; in_1 → done + expect(log.map((e) => e.label)).toEqual(["partial", "partial", "done"]); + }); + + // ── Throttle ────────────────────────────────────────────────────────────── + + it("Throttle passes first activation then suppresses until duration elapses", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "th", socket: "in" }] } }, + { + id: "th", + type: "Throttle", + dataDefaults: { duration: 1 }, // 1 second + signalTargets: { out: [{ blockId: "rec", socket: "in" }] }, + }, + { id: "rec", type: RECORD, config: { label: "pass" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + // First activation should pass through. + expect(log).toHaveLength(1); + expect(log[0]!.label).toBe("pass"); + }); + + it("Throttle suppresses re-activations during cooldown", async () => { + const log: { label: string; value: FgValue }[] = []; + + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "th", socket: "in" }] } }, + { + id: "th", + type: "Throttle", + dataDefaults: { duration: 1 }, // 1 second + signalTargets: { out: [{ blockId: "rec", socket: "in" }] }, + }, + { id: "rec", type: RECORD, config: { label: "pass" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + // First activation passes. + expect(log).toHaveLength(1); + + // Re-fire during cooldown (500 ms elapsed, need 1000 ms). + const thBlock = rt.env.graph.blocks.find((b) => b.type === "Throttle")!; + rt.env.defs[thBlock.type]!.execute!(thBlock, rt.context, rt.env, "in"); + expect(log).toHaveLength(1); // still suppressed + + // Tick past the duration. + tickFlowGraph(rt, 600); + tickFlowGraph(rt, 600); // total 1200 ms > 1000 ms → cooldown done + + // Activate again — should pass. + rt.env.defs[thBlock.type]!.execute!(thBlock, rt.context, rt.env, "in"); + expect(log).toHaveLength(2); + }); + + // ── SetDelay / CancelDelay ──────────────────────────────────────────────── + + it("SetDelay fires done after duration via tickFlowGraph", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "sd", socket: "in" }] } }, + { + id: "sd", + type: "SetDelay", + dataDefaults: { duration: 1 }, // 1 second + signalTargets: { + out: [{ blockId: "out", socket: "in" }], + done: [{ blockId: "done", socket: "in" }], + }, + }, + { id: "out", type: RECORD, config: { label: "out" } }, + { id: "done", type: RECORD, config: { label: "done" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log.map((e) => e.label)).toEqual(["out"]); // immediate + + tickFlowGraph(rt, 500); // 500 ms — not done yet + expect(log.map((e) => e.label)).toEqual(["out"]); + + tickFlowGraph(rt, 600); // total 1100 ms > 1000 ms → done fires + expect(log.map((e) => e.label)).toEqual(["out", "done"]); + }); + + it("CancelDelay prevents done from firing", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { + out: [ + { blockId: "sd", socket: "in" }, // schedule delay + { blockId: "cd", socket: "in" }, // immediately cancel it + ], + }, + }, + { + id: "sd", + type: "SetDelay", + dataDefaults: { duration: 1 }, + signalTargets: { done: [{ blockId: "done", socket: "in" }] }, + }, + { + id: "cd", + type: "CancelDelay", + // Wire the lastDelayIndex output of sd into delayIndex input of cd. + dataSources: { delayIndex: { blockId: "sd", socket: "lastDelayIndex" } }, + signalTargets: { out: [{ blockId: "cancelled", socket: "in" }] }, + }, + { id: "done", type: RECORD, config: { label: "done" } }, + { id: "cancelled", type: RECORD, config: { label: "cancelled" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + tickFlowGraph(rt, 2000); // well past duration + // done must NOT fire; cancelled must fire once. + expect(log.filter((e) => e.label === "done")).toHaveLength(0); + expect(log.filter((e) => e.label === "cancelled")).toHaveLength(1); + }); + + // ── Constant ────────────────────────────────────────────────────────────── + + it("Constant emits its configured value", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "const", type: "Constant", config: { value: 42 } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "const", socket: "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log[0]!.value).toBe(42); + }); + + it("Constant emits a vector value", async () => { + const log: { label: string; value: FgValue }[] = []; + const v = { x: 1, y: 2, z: 3 }; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { id: "const", type: "Constant", config: { value: v } }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "const", socket: "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log[0]!.value).toEqual(v); + }); + + // ── DataSwitch (math/switch cases config) ──────────────────────────────── + + it("DataSwitch selects in_ by selector value", async () => { + for (const [sel, expected] of [ + [0, 100], + [1, 200], + [2, 999], // default + ] as const) { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "rec", socket: "in" }] } }, + { + id: "ds", + type: "DataSwitch", + config: { cases: [0, 1] }, + dataDefaults: { case: sel, default: 999, in_0: 100, in_1: 200 }, + }, + { id: "rec", type: RECORD, dataSources: { value: { blockId: "ds", socket: "value" } } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + startFlowGraph(rt); + expect(log[0]!.value).toBe(expected); + } + }); +}); + +// ─── Phase 3i: custom events + value interpolation ──────────────────────────── + +describe("flow-graph blocks — events + interpolation Phase 3i", () => { + // ── SendCustomEvent / ReceiveCustomEvent ───────────────────────────────── + + it("Send→Receive round-trip: matching eventId delivers named values", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + // SceneStart fires SendCustomEvent with {score: 42} + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { out: [{ blockId: "send", socket: "in" }] }, + }, + { + id: "send", + type: "SendCustomEvent", + config: { eventId: "myEvent", valueNames: ["score"] }, + dataDefaults: { score: 42 }, + signalTargets: { out: [{ blockId: "recSent", socket: "in" }] }, + }, + // ReceiveCustomEvent (same eventId) wires to recorder + { + id: "recv", + type: "ReceiveCustomEvent", + config: { eventId: "myEvent", valueNames: ["score"] }, + signalTargets: { out: [{ blockId: "recRecv", socket: "in" }] }, + }, + { id: "recSent", type: RECORD, config: { label: "sent" } }, + { + id: "recRecv", + type: RECORD, + config: { label: "recv" }, + dataSources: { value: { blockId: "recv", socket: "score" } }, + }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + + startFlowGraph(rt); + + // send fired "sent" + receive triggered "recv" + expect(log.map((e) => e.label)).toContain("sent"); + expect(log.map((e) => e.label)).toContain("recv"); + + const recvEntry = log.find((e) => e.label === "recv"); + expect(recvEntry?.value).toBe(42); + }); + + it("Send→Receive: non-matching eventId does NOT trigger receiver", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { out: [{ blockId: "send", socket: "in" }] }, + }, + { + id: "send", + type: "SendCustomEvent", + config: { eventId: "eventA", valueNames: ["x"] }, + dataDefaults: { x: 7 }, + }, + // Receiver listens on a DIFFERENT eventId — must NOT fire. + { + id: "recv", + type: "ReceiveCustomEvent", + config: { eventId: "eventB", valueNames: ["x"] }, + signalTargets: { out: [{ blockId: "rec", socket: "in" }] }, + }, + { id: "rec", type: RECORD, config: { label: "fired" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + + startFlowGraph(rt); + // No record should appear — mismatched eventId is filtered out. + expect(log.filter((e) => e.label === "fired")).toHaveLength(0); + }); + + it("Send→Receive: matching receiver fires, mismatched receiver is silent", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { out: [{ blockId: "send", socket: "in" }] }, + }, + { + id: "send", + type: "SendCustomEvent", + config: { eventId: "ping", valueNames: [] }, + }, + { + id: "recvOk", + type: "ReceiveCustomEvent", + config: { eventId: "ping" }, + signalTargets: { out: [{ blockId: "recOk", socket: "in" }] }, + }, + { + id: "recvNo", + type: "ReceiveCustomEvent", + config: { eventId: "pong" }, + signalTargets: { out: [{ blockId: "recNo", socket: "in" }] }, + }, + { id: "recOk", type: RECORD, config: { label: "ok" } }, + { id: "recNo", type: RECORD, config: { label: "no" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + + startFlowGraph(rt); + expect(log.filter((e) => e.label === "ok")).toHaveLength(1); + expect(log.filter((e) => e.label === "no")).toHaveLength(0); + }); + + it("ReceiveCustomEvent fires both out and done signals", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { out: [{ blockId: "send", socket: "in" }] }, + }, + { id: "send", type: "SendCustomEvent", config: { eventId: "ev" } }, + { + id: "recv", + type: "ReceiveCustomEvent", + config: { eventId: "ev" }, + signalTargets: { + out: [{ blockId: "recOut", socket: "in" }], + done: [{ blockId: "recDone", socket: "in" }], + }, + }, + { id: "recOut", type: RECORD, config: { label: "out" } }, + { id: "recDone", type: RECORD, config: { label: "done" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + + startFlowGraph(rt); + expect(log.map((e) => e.label)).toContain("out"); + expect(log.map((e) => e.label)).toContain("done"); + }); + + // ── ValueInterpolation ──────────────────────────────────────────────────── + + it("ValueInterpolation: numeric lerp moves value toward target over ticks", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { out: [{ blockId: "interp", socket: "in" }] }, + }, + { + id: "interp", + type: "ValueInterpolation", + config: { type: "number" }, + dataDefaults: { startValue: 0, endValue: 10, duration: 1 }, // 1 second + signalTargets: { + out: [{ blockId: "recOut", socket: "in" }], + done: [{ blockId: "recDone", socket: "in" }], + }, + }, + { + id: "recOut", + type: RECORD, + config: { label: "out" }, + dataSources: { value: { blockId: "interp", socket: "value" } }, + }, + { + id: "recDone", + type: RECORD, + config: { label: "done" }, + dataSources: { value: { blockId: "interp", socket: "value" } }, + }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + + startFlowGraph(rt); + // out fires immediately — value should be at startValue (0) + const outEntry = log.find((e) => e.label === "out"); + expect(outEntry).toBeDefined(); + expect(outEntry!.value).toBe(0); + expect(log.find((e) => e.label === "done")).toBeUndefined(); + + // Tick to 50% progress (500 ms of 1000 ms) → value ≈ 5 + tickFlowGraph(rt, 500); + const interpBlock = rt.env.graph.blocks.find((b) => b.type === "ValueInterpolation")!; + const midValue = rt.context.connectionValues[`${interpBlock.id}:value`] as number; + expect(midValue).toBeGreaterThan(0); + expect(midValue).toBeLessThan(10); + expect(midValue).toBeCloseTo(5, 5); + + // Tick past duration → done fires, value snaps to 10 + tickFlowGraph(rt, 600); // total ~1100 ms exceeds the 1 s duration + expect(log.find((e) => e.label === "done")).toBeDefined(); + expect(log.find((e) => e.label === "done")!.value).toBe(10); + }); + + it("ValueInterpolation: done fires exactly once even with extra ticks", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { out: [{ blockId: "interp", socket: "in" }] }, + }, + { + id: "interp", + type: "ValueInterpolation", + config: { type: "number" }, + dataDefaults: { startValue: 0, endValue: 1, duration: 0.5 }, + signalTargets: { done: [{ blockId: "recDone", socket: "in" }] }, + }, + { id: "recDone", type: RECORD, config: { label: "done" } }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + + startFlowGraph(rt); + tickFlowGraph(rt, 1000); // well past 500 ms + tickFlowGraph(rt, 1000); + expect(log.filter((e) => e.label === "done")).toHaveLength(1); + }); + + it("ValueInterpolation: Vec3 lerp produces correct intermediate and final components", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { out: [{ blockId: "interp", socket: "in" }] }, + }, + { + id: "interp", + type: "ValueInterpolation", + config: { type: "Vector3" }, + dataDefaults: { + startValue: { x: 0, y: 0, z: 0 }, + endValue: { x: 10, y: 20, z: 30 }, + duration: 1, + }, + signalTargets: { done: [{ blockId: "recDone", socket: "in" }] }, + }, + { + id: "recDone", + type: RECORD, + config: { label: "done" }, + dataSources: { value: { blockId: "interp", socket: "value" } }, + }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + + startFlowGraph(rt); + // Advance to halfway point (500 ms of 1 s). + tickFlowGraph(rt, 500); + const interpBlock = rt.env.graph.blocks.find((b) => b.type === "ValueInterpolation")!; + const mid = rt.context.connectionValues[`${interpBlock.id}:value`] as { x: number; y: number; z: number }; + expect(mid.x).toBeCloseTo(5, 5); + expect(mid.y).toBeCloseTo(10, 5); + expect(mid.z).toBeCloseTo(15, 5); + + // Tick past the end. + tickFlowGraph(rt, 600); + const done = log.find((e) => e.label === "done"); + expect(done?.value).toEqual({ x: 10, y: 20, z: 30 }); + }); + + it("ValueInterpolation: zero duration snaps to endValue synchronously", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { out: [{ blockId: "interp", socket: "in" }] }, + }, + { + id: "interp", + type: "ValueInterpolation", + config: { type: "number" }, + dataDefaults: { startValue: 0, endValue: 99, duration: 0 }, + signalTargets: { + out: [{ blockId: "recOut", socket: "in" }], + done: [{ blockId: "recDone", socket: "in" }], + }, + }, + { + id: "recOut", + type: RECORD, + config: { label: "out" }, + dataSources: { value: { blockId: "interp", socket: "value" } }, + }, + { + id: "recDone", + type: RECORD, + config: { label: "done" }, + dataSources: { value: { blockId: "interp", socket: "value" } }, + }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + + startFlowGraph(rt); + // Both out and done fire synchronously; value is already endValue. + expect(log.map((e) => e.label)).toContain("out"); + expect(log.map((e) => e.label)).toContain("done"); + expect(log.find((e) => e.label === "done")?.value).toBe(99); + }); + + it("ValueInterpolation: re-triggering in cancels the previous run", async () => { + const log: { label: string; value: FgValue }[] = []; + const rt = await makeRuntime( + [ + { + id: "start", + type: "SceneReadyEvent", + signalTargets: { out: [{ blockId: "interp", socket: "in" }] }, + }, + { + id: "interp", + type: "ValueInterpolation", + config: { type: "number" }, + dataDefaults: { startValue: 0, endValue: 100, duration: 2 }, + signalTargets: { done: [{ blockId: "recDone", socket: "in" }] }, + }, + { + id: "recDone", + type: RECORD, + config: { label: "done" }, + dataSources: { value: { blockId: "interp", socket: "value" } }, + }, + ], + { defs: { [RECORD]: recorderDef(log) } } + ); + + startFlowGraph(rt); + tickFlowGraph(rt, 500); // half-way through first run + + // Re-trigger with different endValue / duration via direct mutation of the + // socket defaults (the same technique used in the SetDelay cancel test). + const interpBlock = rt.env.graph.blocks.find((b) => b.type === "ValueInterpolation")!; + const endSock = interpBlock.dataIn.find((s) => s.name === "endValue")!; + (endSock as { defaultValue?: FgValue }).defaultValue = 200; + const durSock = interpBlock.dataIn.find((s) => s.name === "duration")!; + (durSock as { defaultValue?: FgValue }).defaultValue = 0.5; + rt.env.defs[interpBlock.type]!.execute!(interpBlock, rt.context, rt.env, "in"); + + tickFlowGraph(rt, 600); // past the new 500 ms duration + // done fires once for the second interpolation (first was canceled) + expect(log.filter((e) => e.label === "done")).toHaveLength(1); + expect(log.find((e) => e.label === "done")?.value).toBe(200); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Phase 4 — ConsoleLog (flow/log + debug/log messageTemplate) +// ───────────────────────────────────────────────────────────────────────────── + +describe("flow-graph blocks — ConsoleLog", () => { + it("flow/log: logs the raw `message` input", async () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const rt = await makeRuntime([ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "log", socket: "in" }] } }, + { id: "log", type: "ConsoleLog", dataDefaults: { message: "hello" } }, + ]); + startFlowGraph(rt); + expect(spy).toHaveBeenCalledWith("hello"); + } finally { + spy.mockRestore(); + } + }); + + it("debug/log: expands `{name}` placeholders from named data inputs", async () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const rt = await makeRuntime([ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "log", socket: "in" }] } }, + { + id: "log", + type: "ConsoleLog", + config: { messageTemplate: "x={x} y={y}" }, + dataDefaults: { x: 1, y: 2 }, + }, + ]); + startFlowGraph(rt); + expect(spy).toHaveBeenCalledWith("x=1 y=2"); + } finally { + spy.mockRestore(); + } + }); + + it("debug/log: pulls placeholders from a `message` object's keys", async () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const rt = await makeRuntime([ + { id: "start", type: "SceneReadyEvent", signalTargets: { out: [{ blockId: "log", socket: "in" }] } }, + { + id: "log", + type: "ConsoleLog", + config: { messageTemplate: "pos={x},{y},{z}" }, + dataDefaults: { message: { x: 1, y: 2, z: 3 } as unknown as FgValue }, + }, + ]); + startFlowGraph(rt); + expect(spy).toHaveBeenCalledWith("pos=1,2,3"); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/tests/lite/unit/flow-graph-gltf-feature.test.ts b/tests/lite/unit/flow-graph-gltf-feature.test.ts new file mode 100644 index 0000000000..2b48bd34d6 --- /dev/null +++ b/tests/lite/unit/flow-graph-gltf-feature.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createTransformNode } from "../../../packages/babylon-lite/src/scene/transform-node"; +import type { TransformNode } from "../../../packages/babylon-lite/src/scene/transform-node"; +import { resolvePointerAccessor } from "../../../packages/babylon-lite/src/flow-graph/gltf/path-converter"; +import interactivityFeature from "../../../packages/babylon-lite/src/loader-gltf/gltf-feature-interactivity"; +import { createFgRuntime, startFlowGraph } from "../../../packages/babylon-lite/src/flow-graph/index"; +import type { GltfLoadCtx } from "../../../packages/babylon-lite/src/loader-gltf/gltf-feature"; + +const worldPointerExtension = { + graphs: [ + { + declarations: [{ op: "event/onStart" }, { op: "pointer/set" }], + nodes: [ + { declaration: 0, flows: { out: { node: 1, socket: "in" } } }, + { + declaration: 1, + configuration: { pointer: { value: ["/nodes/0/translation"] } }, + values: { value: { value: [1, 2, 3], type: 0 } }, + }, + ], + types: [{ signature: "float3" }], + }, + ], +}; + +describe("path-converter resolvePointerAccessor", () => { + it("reads + writes a node's translation through the TRS accessor", () => { + const node = createTransformNode("n0"); + const accessor = resolvePointerAccessor("/nodes/0/translation", { nodeMap: [node] }); + expect(accessor).not.toBeNull(); + expect(accessor!.get()).toEqual({ x: 0, y: 0, z: 0 }); + accessor!.set!({ x: 4, y: 5, z: 6 }); + expect(node.position.x).toBe(4); + expect(node.position.y).toBe(5); + expect(node.position.z).toBe(6); + }); + + it("reads + writes a node's scale", () => { + const node = createTransformNode("n0"); + const accessor = resolvePointerAccessor("/nodes/0/scale", { nodeMap: [node] })!; + accessor.set!({ x: 2, y: 2, z: 2 }); + expect(node.scaling.x).toBe(2); + }); + + it("returns null for unsupported paths and unreachable nodes", () => { + expect(resolvePointerAccessor("/materials/0/baseColor", { nodeMap: [createTransformNode("n0")] })).toBeNull(); + expect(resolvePointerAccessor("/nodes/3/translation", { nodeMap: [createTransformNode("n0")] })).toBeNull(); + }); + + it("reads a material's baseColorTexture UV scale (pointer/get)", () => { + const mat = { _uboVersion: 0, baseColorTexture: { uScale: 0.1, vScale: 1, uOffset: 0.8, vOffset: 0 } }; + const ptr = "/materials/4/pbrMetallicRoughness/baseColorTexture/extensions/KHR_texture_transform/scale"; + const accessor = resolvePointerAccessor(ptr, { nodeMap: [], materials: [undefined, undefined, undefined, undefined, mat] })!; + expect(accessor.get()).toEqual({ x: 0.1, y: 1 }); + }); + + it("writes a material's UV offset and isolates the shared texture (pointer/set)", () => { + const shared = { uScale: 0.1, vScale: 1, uOffset: 0.8, vOffset: 0 }; + const matA = { _uboVersion: 0, baseColorTexture: shared }; + const matB = { _uboVersion: 0, baseColorTexture: shared }; + const ptr = "/materials/0/pbrMetallicRoughness/baseColorTexture/extensions/KHR_texture_transform/offset"; + const accessor = resolvePointerAccessor(ptr, { nodeMap: [], materials: [matA, matB] })!; + accessor.set!({ x: 0, y: 0 }); + expect(matA.baseColorTexture.uOffset).toBe(0); + expect(matA._uboVersion).toBe(1); + // matB still references the original shared wrapper — untouched (per-texture isolation). + expect(matB.baseColorTexture).toBe(shared); + expect(shared.uOffset).toBe(0.8); + }); + + it("toggles node visibility through the KHR_node_visibility accessor", () => { + const node = createTransformNode("n0"); + const accessor = resolvePointerAccessor("/nodes/0/extensions/KHR_node_visibility/visible", { nodeMap: [node] })!; + expect(accessor.get()).toBe(true); + accessor.set!(false); + expect(node.visible).toBe(false); + accessor.set!(true); + expect(node.visible).toBe(true); + }); + + it("treats node selectability as a no-op value round-trip", () => { + const node = createTransformNode("n0"); + const accessor = resolvePointerAccessor("/nodes/0/extensions/KHR_node_selectability/selectable", { nodeMap: [node] })!; + expect(accessor.get()).toBe(true); + accessor.set!(false); + expect(accessor.get()).toBe(false); + expect(node.visible).toBeUndefined(); // selectability never touches visibility + }); +}); + +describe("gltf-feature-interactivity applyAsset", () => { + it("parses graphs and resolves pointers into the container", async () => { + const node = createTransformNode("n0"); + const ctx = { _json: { extensions: { KHR_interactivity: worldPointerExtension } }, _nodeMap: [node] as (TransformNode | undefined)[] } as unknown as GltfLoadCtx; + + const result = await interactivityFeature.applyAsset!([], node, ctx); + expect(result.flowGraphs).toHaveLength(1); + const lg = result.flowGraphs![0]!; + expect(Object.keys(lg.accessors)).toEqual(["/nodes/0/translation"]); + + // Run it: onStart → pointer/set writes (1,2,3) into the real node. + const rt = await createFgRuntime(lg.graph, { accessors: lg.accessors }, { rightHanded: true }); + startFlowGraph(rt); + expect(node.position.x).toBe(1); + expect(node.position.y).toBe(2); + expect(node.position.z).toBe(3); + }); + + it("throws when a pointer cannot be resolved against the node map", async () => { + const ctx = { _json: { extensions: { KHR_interactivity: worldPointerExtension } }, _nodeMap: [] as (TransformNode | undefined)[] } as unknown as GltfLoadCtx; + await expect(interactivityFeature.applyAsset!([], createTransformNode("x"), ctx)).rejects.toThrow(/cannot resolve pointer/); + }); + + it("returns an empty fragment when the asset has no interactivity extension", async () => { + const ctx = { _json: { extensions: {} }, _nodeMap: [] as (TransformNode | undefined)[] } as unknown as GltfLoadCtx; + const result = await interactivityFeature.applyAsset!([], createTransformNode("x"), ctx); + expect(result.flowGraphs).toBeUndefined(); + }); +}); + +vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/tests/lite/unit/flow-graph-gltf-parser.test.ts b/tests/lite/unit/flow-graph-gltf-parser.test.ts new file mode 100644 index 0000000000..701480f658 --- /dev/null +++ b/tests/lite/unit/flow-graph-gltf-parser.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { FgAccessor, FgValue } from "../../../packages/babylon-lite/src/flow-graph/index"; +import { createFgRuntime, startFlowGraph } from "../../../packages/babylon-lite/src/flow-graph/index"; +import { parseInteractivityGraph, type GltfInteractivityGraph } from "../../../packages/babylon-lite/src/flow-graph/gltf/interactivity-parser"; + +// Fixtures mirror Babylon.js loaders/test/unit/Interactivity/testData.ts verbatim +// (commit 8f728b23ea) so the Lite parser is validated against the real wire format. + +const worldPointerExample: GltfInteractivityGraph = { + declarations: [{ op: "event/onStart" }, { op: "pointer/set" }, { op: "pointer/get" }, { op: "flow/log", extension: "BABYLON" }], + nodes: [ + { declaration: 0, flows: { out: { node: 1, socket: "in" } } }, + { + declaration: 1, + configuration: { pointer: { value: ["/nodes/0/translation"] } }, + values: { value: { value: [1, 1, 1], type: 4 } }, + flows: { out: { node: 3, socket: "in" } }, + }, + { + declaration: 2, + configuration: { pointer: { value: ["/nodes/{nodeIndex}/translation"] } }, + values: { nodeIndex: { value: [0], type: 1 } }, + }, + { declaration: 3, values: { message: { node: 2, socket: "value" } } }, + ], + variables: [], + events: [], + types: [{ signature: "bool" }, { signature: "int" }, { signature: "float" }, { signature: "float2" }, { signature: "float3" }], +}; + +const loggerExample: GltfInteractivityGraph = { + declarations: [{ op: "event/onStart" }, { op: "flow/log", extension: "BABYLON" }, { op: "math/add" }], + nodes: [ + { declaration: 0, flows: { out: { node: 2, socket: "in" } } }, + { + declaration: 2, + values: { + a: { value: [1, 2, 3, 4], type: 0 }, + b: { value: [1, 2, 3, 4], type: 0 }, + }, + }, + { declaration: 1, values: { message: { node: 1, socket: "value" } } }, + ], + types: [{ signature: "float4" }], +}; + +describe("KHR_interactivity parser", () => { + it("parses the worldPointer graph: topology, config, pointers", async () => { + const { graph, pointers } = await parseInteractivityGraph(worldPointerExample); + + expect(graph.blocks.map((b) => b.type)).toEqual(["SceneReadyEvent", "SetProperty", "GetProperty", "ConsoleLog"]); + expect(pointers).toEqual(["/nodes/0/translation"]); + + // onStart `out` flow maps to the SceneReadyEvent `done` signal → node_1.in + const start = graph.blocks[0]!; + const done = start.signalOut.find((s) => s.name === "done")!; + expect(done.targets).toEqual([{ blockId: "node_1", socket: "in" }]); + + // pointer/set resolves config.accessor + literal Vector3 value, and wires out→node_3 + const set = graph.blocks[1]!; + expect(set.config?.accessor).toBe("/nodes/0/translation"); + expect(set.dataIn.find((d) => d.name === "value")!.defaultValue).toEqual({ x: 1, y: 1, z: 1 }); + expect(set.signalOut.find((s) => s.name === "out")!.targets).toEqual([{ blockId: "node_3", socket: "in" }]); + + // pointer/get template `{nodeIndex}` substituted from the literal value socket + expect(graph.blocks[2]!.config?.accessor).toBe("/nodes/0/translation"); + + // ConsoleLog pulls `message` from GetProperty.value + const log = graph.blocks[3]!; + expect(log.dataIn.find((d) => d.name === "message")!.source).toEqual({ blockId: "node_2", socket: "value" }); + }); + + it("coerces a float4 literal into a Vector4 and wires a data reference", async () => { + const { graph } = await parseInteractivityGraph(loggerExample); + const add = graph.blocks[1]!; + expect(add.type).toBe("Add"); + expect(add.dataIn.find((d) => d.name === "a")!.defaultValue).toEqual({ x: 1, y: 2, z: 3, w: 4 }); + // ConsoleLog message references the add node's default `value` output + expect(graph.blocks[2]!.dataIn.find((d) => d.name === "message")!.source).toEqual({ blockId: "node_1", socket: "value" }); + }); + + it("runs end-to-end: onStart → pointer/set writes through the resolved accessor", async () => { + const { graph, pointers } = await parseInteractivityGraph(worldPointerExample); + const box = { v: { x: 0, y: 0, z: 0 } as FgValue }; + const accessor: FgAccessor = { + type: graph.blocks[1]!.dataIn[0]!.type, + get: () => box.v, + set: (value) => { + box.v = value; + }, + }; + const accessors = Object.fromEntries(pointers.map((p) => [p, accessor])); + const rt = await createFgRuntime(graph, { accessors }); + startFlowGraph(rt); + expect(box.v).toEqual({ x: 1, y: 1, z: 1 }); + }); + + it("fails loudly on an unknown op", async () => { + const bad: GltfInteractivityGraph = { + declarations: [{ op: "event/onStart" }, { op: "math/teleport" }], + nodes: [{ declaration: 0, flows: { out: { node: 1, socket: "in" } } }, { declaration: 1 }], + types: [], + }; + await expect(parseInteractivityGraph(bad)).rejects.toThrow(/unsupported op\(s\): math\/teleport/); + }); + + it("rejects a pointer with an unresolved dynamic segment", async () => { + const dyn: GltfInteractivityGraph = { + declarations: [{ op: "pointer/set" }], + nodes: [ + { + declaration: 0, + configuration: { pointer: { value: ["/nodes/{nodeIndex}/translation"] } }, + // nodeIndex is a node reference (runtime-dynamic), not a literal + values: { nodeIndex: { node: 5 }, value: { value: [1, 1, 1], type: 0 } }, + }, + ], + types: [{ signature: "float3" }], + }; + await expect(parseInteractivityGraph(dyn)).rejects.toThrow(/unresolvable pointer/); + }); +}); + +describe("KHR_interactivity parser — pointer templating & new ops", () => { + it("extracts the trailing index from a `ref` placeholder value (/materials/4/ → 4)", async () => { + const g: GltfInteractivityGraph = { + declarations: [{ op: "pointer/get" }], + nodes: [ + { + declaration: 0, + configuration: { pointer: { value: ["/materials/{materialRef}/pbrMetallicRoughness/baseColorTexture/extensions/KHR_texture_transform/scale"] } }, + values: { materialRef: { type: 5, value: ["/materials/4/"] } }, + }, + ], + types: [{ signature: "bool" }, { signature: "int" }, { signature: "float" }, { signature: "float2" }, { signature: "float3" }, { signature: "ref" }], + }; + const { graph, pointers } = await parseInteractivityGraph(g); + expect(pointers).toEqual(["/materials/4/pbrMetallicRoughness/baseColorTexture/extensions/KHR_texture_transform/scale"]); + expect(graph.blocks[0]!.config?.accessor).toBe("/materials/4/pbrMetallicRoughness/baseColorTexture/extensions/KHR_texture_transform/scale"); + }); + + it("anchors a relative pointer on an unused `nodeRef` ref value", async () => { + const g: GltfInteractivityGraph = { + declarations: [{ op: "pointer/set" }], + nodes: [ + { + declaration: 0, + configuration: { pointer: { value: ["extensions/KHR_node_visibility/visible"] } }, + values: { value: { value: [1], type: 0 }, nodeRef: { type: 5, value: ["/nodes/22/"] } }, + }, + ], + types: [{ signature: "bool" }, { signature: "int" }, { signature: "float" }, { signature: "float2" }, { signature: "float3" }, { signature: "ref" }], + }; + const { pointers } = await parseInteractivityGraph(g); + expect(pointers).toEqual(["/nodes/22/extensions/KHR_node_visibility/visible"]); + }); + + it("maps the new math ops and renames extract2 outputs to x/y", async () => { + const g: GltfInteractivityGraph = { + declarations: [ + { op: "math/extract2" }, // 0 + { op: "math/combine2" }, // 1 + { op: "math/clamp" }, // 2 + { op: "math/sub" }, // 3 + { op: "flow/log", extension: "BABYLON" }, // 4 + ], + nodes: [ + { declaration: 0, values: { a: { value: [3, 4], type: 3 } } }, + { declaration: 1, values: { a: { value: [1], type: 2 }, b: { value: [2], type: 2 } } }, + { declaration: 2, values: { a: { value: [5], type: 2 }, b: { value: [0], type: 2 }, c: { value: [9], type: 2 } } }, + { declaration: 3, values: { a: { value: [7], type: 2 }, b: { value: [2], type: 2 } } }, + // ConsoleLog pulls extract2's "1" output → must resolve to the Lite `y` socket + { declaration: 4, values: { message: { node: 0, socket: "1" } } }, + ], + types: [{ signature: "bool" }, { signature: "int" }, { signature: "float" }, { signature: "float2" }], + }; + const { graph } = await parseInteractivityGraph(g); + expect(graph.blocks.map((b) => b.type)).toEqual(["ExtractVector2", "CombineVector2", "Clamp", "Subtract", "ConsoleLog"]); + expect(graph.blocks[4]!.dataIn.find((d) => d.name === "message")!.source).toEqual({ blockId: "node_0", socket: "y" }); + }); + + it("maps event/onSelect (KHR_node_selectability) and copies nodeIndex into config", async () => { + const g: GltfInteractivityGraph = { + declarations: [{ op: "event/onSelect", extension: "KHR_node_selectability" }], + nodes: [{ declaration: 0, configuration: { nodeIndex: { value: [14] } } }], + types: [], + }; + const { graph } = await parseInteractivityGraph(g); + expect(graph.blocks[0]!.type).toBe("OnSelect"); + expect(graph.blocks[0]!.config?.nodeIndex).toBe(14); + }); +}); + +// Silence the ConsoleLog block's console.log in the end-to-end run. +vi.spyOn(console, "log").mockImplementation(() => {}); + +// ───────────────────────────────────────────────────────────────────────────── +// Coverage guard: every mapped glTF op must resolve to a registered block def. +// Prevents adding an op mapping without wiring its block into the registry. +// ───────────────────────────────────────────────────────────────────────────── + +describe("declaration-mapper — block coverage", () => { + it("every mapped block type resolves to a registered, type-matching def", async () => { + const { allMappedBlockTypes } = await import("../../../packages/babylon-lite/src/flow-graph/gltf/declaration-mapper"); + const { getBlockDef } = await import("../../../packages/babylon-lite/src/flow-graph/index"); + for (const type of allMappedBlockTypes()) { + const factory = getBlockDef(type); + expect(factory, `no registry entry for mapped block type "${type}"`).toBeTruthy(); + const def = await factory!(); + expect(def.type).toBe(type); + } + }); +}); diff --git a/tests/lite/unit/flow-graph-rich-type.test.ts b/tests/lite/unit/flow-graph-rich-type.test.ts new file mode 100644 index 0000000000..702662c551 --- /dev/null +++ b/tests/lite/unit/flow-graph-rich-type.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import type { Mat4, Quat, Vec4 } from "../../../packages/babylon-lite/src/math/types"; +import { + animationTypeForFgType, + coerceValue, + defaultForType, + FgAnimationValueType, + FgType, + isFgInt, + isFgMatrix2D, + isFgMatrix3D, +} from "../../../packages/babylon-lite/src/flow-graph/index"; +import type { FgInteger } from "../../../packages/babylon-lite/src/flow-graph/index"; + +describe("rich-type defaultForType", () => { + it("returns the correct defaults per type", () => { + expect(defaultForType(FgType.Number)).toBe(0); + expect(defaultForType(FgType.Boolean)).toBe(false); + expect(defaultForType(FgType.String)).toBe(""); + expect(defaultForType(FgType.Vector3)).toEqual({ x: 0, y: 0, z: 0 }); + expect(defaultForType(FgType.Quaternion)).toEqual({ x: 0, y: 0, z: 0, w: 1 }); + expect(defaultForType(FgType.Color4)).toEqual({ r: 0, g: 0, b: 0, a: 1 }); + expect(isFgInt(defaultForType(FgType.Integer))).toBe(true); + expect(isFgMatrix2D(defaultForType(FgType.Matrix2D))).toBe(true); + expect(isFgMatrix3D(defaultForType(FgType.Matrix3D))).toBe(true); + expect(defaultForType(FgType.Any)).toBe(null); + }); + + it("identity matrix default has 1s on the diagonal", () => { + const m = defaultForType(FgType.Matrix) as unknown as Mat4; + expect([m[0], m[5], m[10], m[15]]).toEqual([1, 1, 1, 1]); + expect(m[1]).toBe(0); + }); + + it("constructs fresh values each call (no shared instances)", () => { + const a = defaultForType(FgType.Vector3); + const b = defaultForType(FgType.Vector3); + expect(a).not.toBe(b); + }); +}); + +describe("rich-type coerceValue", () => { + it("Vector4 → Quaternion preserves components", () => { + const v: Vec4 = { x: 0.1, y: 0.2, z: 0.3, w: 0.9 }; + const q = coerceValue(v, FgType.Quaternion) as Quat; + expect(q).toEqual({ x: 0.1, y: 0.2, z: 0.3, w: 0.9 }); + }); + + it("identity Matrix → identity Quaternion", () => { + const identity = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) as unknown as Mat4; + const q = coerceValue(identity, FgType.Quaternion) as Quat; + expect(q.x).toBeCloseTo(0, 6); + expect(q.y).toBeCloseTo(0, 6); + expect(q.z).toBeCloseTo(0, 6); + expect(q.w).toBeCloseTo(1, 6); + }); + + it("number → integer and back", () => { + const i = coerceValue(3.9, FgType.Integer) as FgInteger; + expect(isFgInt(i)).toBe(true); + expect(i.value).toBe(3); // truncated to 32-bit int + expect(coerceValue(i, FgType.Number)).toBe(3); + }); + + it("boolean ↔ number/integer coercions", () => { + expect(coerceValue(true, FgType.Number)).toBe(1); + expect(coerceValue(false, FgType.Number)).toBe(0); + expect(coerceValue(0, FgType.Boolean)).toBe(false); + expect(coerceValue(5, FgType.Boolean)).toBe(true); + }); + + it("passes null/undefined through untouched", () => { + expect(coerceValue(null, FgType.Quaternion)).toBe(null); + expect(coerceValue(undefined, FgType.Number)).toBe(undefined); + }); + + it("returns the value unchanged when no conversion applies", () => { + const v3 = { x: 1, y: 2, z: 3 }; + expect(coerceValue(v3, FgType.Vector3)).toBe(v3); + }); +}); + +describe("rich-type animationTypeForFgType", () => { + it("maps each type to its animation value category (quaternion ⇒ slerp)", () => { + expect(animationTypeForFgType(FgType.Number)).toBe(FgAnimationValueType.Float); + expect(animationTypeForFgType(FgType.Vector2)).toBe(FgAnimationValueType.Vector2); + expect(animationTypeForFgType(FgType.Vector3)).toBe(FgAnimationValueType.Vector3); + expect(animationTypeForFgType(FgType.Quaternion)).toBe(FgAnimationValueType.Quaternion); + expect(animationTypeForFgType(FgType.Color3)).toBe(FgAnimationValueType.Color3); + expect(animationTypeForFgType(FgType.Matrix)).toBe(FgAnimationValueType.Matrix); + }); +}); diff --git a/tests/lite/unit/flow-graph-runtime.test.ts b/tests/lite/unit/flow-graph-runtime.test.ts new file mode 100644 index 0000000000..66d1e6f870 --- /dev/null +++ b/tests/lite/unit/flow-graph-runtime.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, it } from "vitest"; + +import type { FgBlock, FgBlockDef, FgGraph, FgPendingTask, FgValue } from "../../../packages/babylon-lite/src/flow-graph/index"; +import { + activateSignal, + addPending, + cancelPendingForBlock, + createFgRuntime, + disposeFlowGraph, + FgEventType, + FgType, + getDataValue, + pumpFgEvent, + setDataValue, + startFlowGraph, + tickFlowGraph, +} from "../../../packages/babylon-lite/src/flow-graph/index"; + +// ─── tiny builders ────────────────────────────────────────────────────────── + +function mkBlock(partial: Partial & { id: string; type: string }): FgBlock { + return { dataIn: [], dataOut: [], signalIn: [], signalOut: [], ...partial }; +} + +function mkGraph(blocks: FgBlock[], variables: FgGraph["variables"] = {}): FgGraph { + const byId: Record = {}; + blocks.forEach((b, i) => (byId[b.id] = i)); + return { blocks, byId, variables }; +} + +// ─── reusable hand-built defs ─────────────────────────────────────────────── + +/** Data producer: emits `value` = the named user variable (recomputes each pull). */ +const getVarDef: FgBlockDef = { + type: "getVar", + build: () => ({ dataOut: [{ name: "value", type: FgType.Number }] }), + updateOutputs: (block, ctx) => { + const name = block.config?.name as string; + setDataValue(ctx, block, "value", ctx.userVariables[name] ?? 0); + }, +}; + +/** Data passthrough: `value` = pulled `in`. Used for cycle-guard test. */ +const passthroughDef: FgBlockDef = { + type: "passthrough", + build: () => ({ dataIn: [{ name: "in", type: FgType.Number }], dataOut: [{ name: "value", type: FgType.Number }] }), + updateOutputs: (block, ctx, env) => { + setDataValue(ctx, block, "value", getDataValue(ctx, env, block, "in")); + }, +}; + +const startDef: FgBlockDef = { + type: "start", + build: () => ({ event: FgEventType.Start, signalOut: [{ name: "out", targets: [] }] }), + execute: (block, ctx, env) => activateSignal(ctx, env, block, "out"), +}; + +const sequenceDef: FgBlockDef = { + type: "sequence", + build: () => ({ + signalIn: [{ name: "in", targets: [] }], + signalOut: [ + { name: "out1", targets: [] }, + { name: "out2", targets: [] }, + ], + }), + execute: (block, ctx, env) => { + activateSignal(ctx, env, block, "out1"); + activateSignal(ctx, env, block, "out2"); + }, +}; + +function recordDef(log: string[]): FgBlockDef { + return { + type: "record", + build: () => ({ signalIn: [{ name: "in", targets: [] }] }), + execute: (block) => { + log.push(block.config?.label as string); + }, + }; +} + +const branchDef: FgBlockDef = { + type: "branch", + build: () => ({ + dataIn: [{ name: "condition", type: FgType.Boolean }], + signalIn: [{ name: "in", targets: [] }], + signalOut: [ + { name: "true", targets: [] }, + { name: "false", targets: [] }, + ], + }), + execute: (block, ctx, env) => { + const cond = getDataValue(ctx, env, block, "condition"); + activateSignal(ctx, env, block, cond ? "true" : "false"); + }, +}; + +// ───────────────────────────────────────────────────────────────────────────── + +describe("flow-graph data edges (PULL)", () => { + it("recomputes on every pull — no memoization of producer output", async () => { + const producer = mkBlock({ id: "g", type: "getVar", config: { name: "x" }, dataOut: [{ name: "value", type: FgType.Number }] }); + const consumer = mkBlock({ + id: "c", + type: "passthrough", + dataIn: [{ name: "in", type: FgType.Number, source: { blockId: "g", socket: "value" } }], + }); + const graph = mkGraph([producer, consumer], { x: { type: FgType.Number, value: 0 } }); + const rt = await createFgRuntime(graph, { defs: { getVar: getVarDef, passthrough: passthroughDef } }); + + rt.context.userVariables.x = 5; + expect(getDataValue(rt.context, rt.env, consumer, "in")).toBe(5); + + rt.context.userVariables.x = 9; + expect(getDataValue(rt.context, rt.env, consumer, "in")).toBe(9); + }); + + it("falls back to the socket default when unwired", async () => { + const consumer = mkBlock({ id: "c", type: "passthrough", dataIn: [{ name: "in", type: FgType.Number, defaultValue: 42 }] }); + const graph = mkGraph([consumer]); + const rt = await createFgRuntime(graph, { defs: { passthrough: passthroughDef } }); + expect(getDataValue(rt.context, rt.env, consumer, "in")).toBe(42); + }); + + it("breaks accidental data cycles via the resolving guard (returns default, no hang)", async () => { + const a = mkBlock({ + id: "A", + type: "passthrough", + dataIn: [{ name: "in", type: FgType.Number, source: { blockId: "B", socket: "value" } }], + dataOut: [{ name: "value", type: FgType.Number }], + }); + const b = mkBlock({ + id: "B", + type: "passthrough", + dataIn: [{ name: "in", type: FgType.Number, source: { blockId: "A", socket: "value" } }], + dataOut: [{ name: "value", type: FgType.Number }], + }); + const graph = mkGraph([a, b]); + const rt = await createFgRuntime(graph, { defs: { passthrough: passthroughDef } }); + expect(getDataValue(rt.context, rt.env, a, "in")).toBe(0); + }); +}); + +describe("flow-graph signal edges (PUSH)", () => { + it("fires sequence outputs in declared order", async () => { + const log: string[] = []; + const start = mkBlock({ id: "s", type: "start", event: FgEventType.Start, signalOut: [{ name: "out", targets: [{ blockId: "seq", socket: "in" }] }] }); + const seq = mkBlock({ + id: "seq", + type: "sequence", + signalIn: [{ name: "in", targets: [] }], + signalOut: [ + { name: "out1", targets: [{ blockId: "a", socket: "in" }] }, + { name: "out2", targets: [{ blockId: "b", socket: "in" }] }, + ], + }); + const a = mkBlock({ id: "a", type: "record", config: { label: "A" }, signalIn: [{ name: "in", targets: [] }] }); + const b = mkBlock({ id: "b", type: "record", config: { label: "B" }, signalIn: [{ name: "in", targets: [] }] }); + const graph = mkGraph([start, seq, a, b]); + const rt = await createFgRuntime(graph, { defs: { start: startDef, sequence: sequenceDef, record: recordDef(log) } }); + + startFlowGraph(rt); + expect(log).toEqual(["A", "B"]); + }); + + it("branch routes to the active output only", async () => { + const log: string[] = []; + const start = mkBlock({ id: "s", type: "start", event: FgEventType.Start, signalOut: [{ name: "out", targets: [{ blockId: "br", socket: "in" }] }] }); + const br = mkBlock({ + id: "br", + type: "branch", + dataIn: [{ name: "condition", type: FgType.Boolean, defaultValue: true }], + signalIn: [{ name: "in", targets: [] }], + signalOut: [ + { name: "true", targets: [{ blockId: "a", socket: "in" }] }, + { name: "false", targets: [{ blockId: "b", socket: "in" }] }, + ], + }); + const a = mkBlock({ id: "a", type: "record", config: { label: "TRUE" }, signalIn: [{ name: "in", targets: [] }] }); + const b = mkBlock({ id: "b", type: "record", config: { label: "FALSE" }, signalIn: [{ name: "in", targets: [] }] }); + const graph = mkGraph([start, br, a, b]); + const rt = await createFgRuntime(graph, { defs: { start: startDef, branch: branchDef, record: recordDef(log) } }); + + startFlowGraph(rt); + expect(log).toEqual(["TRUE"]); + }); +}); + +describe("flow-graph async pending tasks", () => { + /** A delay that counts down `durationMs` then fires `done`. Dedupes re-entry. */ + function delayDef(): FgBlockDef { + return { + type: "delay", + build: () => ({ + dataIn: [{ name: "duration", type: FgType.Number }], + signalIn: [{ name: "in", targets: [] }], + signalOut: [{ name: "done", targets: [] }], + }), + execute: (block, ctx, env) => { + // dedupe: ignore re-entry while a task is already live + if (ctx.pending.some((t) => t.blockId === block.id && !t.canceled && !t.done)) { + return; + } + const duration = (getDataValue(ctx, env, block, "duration") as number) ?? 0; + addPending(ctx, block, { remainingMs: duration }); + }, + onTick: (block, ctx, env, deltaMs, task) => { + task.state.remainingMs = (task.state.remainingMs as number) - deltaMs; + if ((task.state.remainingMs as number) <= 0) { + task.done = true; + activateSignal(ctx, env, block, "done"); + } + }, + cancelPending: (block, ctx) => cancelPendingForBlock(ctx, block), + }; + } + + it("counts down across frames and fires done exactly once", async () => { + const log: string[] = []; + const start = mkBlock({ id: "s", type: "start", event: FgEventType.Start, signalOut: [{ name: "out", targets: [{ blockId: "d", socket: "in" }] }] }); + const d = mkBlock({ + id: "d", + type: "delay", + dataIn: [{ name: "duration", type: FgType.Number, defaultValue: 100 }], + signalIn: [{ name: "in", targets: [] }], + signalOut: [{ name: "done", targets: [{ blockId: "r", socket: "in" }] }], + }); + const r = mkBlock({ id: "r", type: "record", config: { label: "DONE" }, signalIn: [{ name: "in", targets: [] }] }); + const graph = mkGraph([start, d, r]); + const rt = await createFgRuntime(graph, { defs: { start: startDef, delay: delayDef(), record: recordDef(log) } }); + + startFlowGraph(rt); + expect(rt.context.pending.length).toBe(1); + expect(log).toEqual([]); + + tickFlowGraph(rt, 60); + expect(log).toEqual([]); // 40ms left + + tickFlowGraph(rt, 60); + expect(log).toEqual(["DONE"]); // fired + expect(rt.context.pending.length).toBe(0); // compacted out + }); + + it("dedupes re-entry — a second trigger does not add a second task", async () => { + const d = mkBlock({ + id: "d", + type: "delay", + dataIn: [{ name: "duration", type: FgType.Number, defaultValue: 100 }], + signalIn: [{ name: "in", targets: [] }], + signalOut: [{ name: "done", targets: [] }], + }); + const graph = mkGraph([d]); + const rt = await createFgRuntime(graph, { defs: { delay: delayDef() } }); + + const def = rt.env.defs.delay!; + def.execute!(d, rt.context, rt.env, "in"); + def.execute!(d, rt.context, rt.env, "in"); + expect(rt.context.pending.length).toBe(1); + }); + + it("is cancellation-safe: a task canceled mid-loop never fires", async () => { + const log: string[] = []; + // canceller's onTick cancels the target block once it elapses. + const cancellerDef: FgBlockDef = { + type: "canceller", + build: () => ({ signalIn: [{ name: "in", targets: [] }] }), + execute: (block, ctx) => addPending(ctx, block, { remainingMs: 60 }), + onTick: (block, ctx, env, deltaMs, task) => { + task.state.remainingMs = (task.state.remainingMs as number) - deltaMs; + if ((task.state.remainingMs as number) <= 0) { + task.done = true; + const targetId = block.config?.cancelId as string; + const target = env.graph.blocks[env.graph.byId[targetId]!]!; + cancelPendingForBlock(ctx, target); + } + }, + }; + const victimDef: FgBlockDef = { + type: "victim", + build: () => ({ signalIn: [{ name: "in", targets: [] }], signalOut: [{ name: "done", targets: [] }] }), + execute: (block, ctx) => addPending(ctx, block, { remainingMs: 60 }), + onTick: (_block, _ctx, _env, deltaMs, task) => { + task.state.remainingMs = (task.state.remainingMs as number) - deltaMs; + if ((task.state.remainingMs as number) <= 0) { + task.done = true; + log.push("VICTIM_FIRED"); + } + }, + }; + const canceller = mkBlock({ id: "C", type: "canceller", config: { cancelId: "V" }, signalIn: [{ name: "in", targets: [] }] }); + const victim = mkBlock({ id: "V", type: "victim", signalIn: [{ name: "in", targets: [] }], signalOut: [{ name: "done", targets: [] }] }); + const graph = mkGraph([canceller, victim]); + const rt = await createFgRuntime(graph, { defs: { canceller: cancellerDef, victim: victimDef } }); + + // Seed both tasks (C is first in pending so it ticks first and cancels V). + cancellerDef.execute!(canceller, rt.context, rt.env, "in"); + victimDef.execute!(victim, rt.context, rt.env, "in"); + expect(rt.context.pending.length).toBe(2); + + tickFlowGraph(rt, 60); + expect(log).toEqual([]); // victim was canceled before its turn + expect(rt.context.pending.length).toBe(0); + }); + + it("does not retro-tick a task added during the same frame's loop", async () => { + let addedTask: FgPendingTask | null = null; + const adderDef: FgBlockDef = { + type: "adder", + build: () => ({}), + execute: (block, ctx) => addPending(ctx, block, { ticks: 0 }), + onTick: (block, ctx, _env, _deltaMs, task) => { + task.state.ticks = (task.state.ticks as number) + 1; + task.done = true; + if (!addedTask) { + addedTask = addPending(ctx, block, { ticks: 0 }); + } + }, + }; + const adder = mkBlock({ id: "A", type: "adder" }); + const rt = await createFgRuntime(mkGraph([adder]), { defs: { adder: adderDef } }); + adderDef.execute!(adder, rt.context, rt.env, "in"); + + tickFlowGraph(rt, 16); + // The task added mid-loop must NOT have ticked this frame. + expect((addedTask! as FgPendingTask).state.ticks).toBe(0); + }); +}); + +describe("flow-graph events", () => { + it("delivers a custom event sent during the onStart cascade (receiver subscribed first)", async () => { + const log: string[] = []; + const sendDef: FgBlockDef = { + type: "send", + build: () => ({ signalIn: [{ name: "in", targets: [] }] }), + execute: (block, _ctx, env) => pumpFgEvent(env.events, FgEventType.CustomEvent, { eventName: block.config?.eventName }), + }; + const receiveDef: FgBlockDef = { + type: "receive", + build: () => ({ event: FgEventType.CustomEvent, signalOut: [{ name: "out", targets: [] }] }), + execute: (block, ctx, env) => { + const payload = ctx.executionVariables[`${block.id}:lastEvent`] as { eventName?: string } | undefined; + if (payload?.eventName === block.config?.eventName) { + activateSignal(ctx, env, block, "out"); + } + }, + }; + const start = mkBlock({ id: "s", type: "start", event: FgEventType.Start, signalOut: [{ name: "out", targets: [{ blockId: "snd", socket: "in" }] }] }); + const snd = mkBlock({ id: "snd", type: "send", config: { eventName: "ping" }, signalIn: [{ name: "in", targets: [] }] }); + const rcv = mkBlock({ + id: "rcv", + type: "receive", + event: FgEventType.CustomEvent, + config: { eventName: "ping" }, + signalOut: [{ name: "out", targets: [{ blockId: "r", socket: "in" }] }], + }); + const r = mkBlock({ id: "r", type: "record", config: { label: "GOT_PING" }, signalIn: [{ name: "in", targets: [] }] }); + const graph = mkGraph([start, snd, rcv, r]); + const rt = await createFgRuntime(graph, { defs: { start: startDef, send: sendDef, receive: receiveDef, record: recordDef(log) } }); + + startFlowGraph(rt); + expect(log).toEqual(["GOT_PING"]); + }); + + it("pumps a tick event each frame", async () => { + const log: string[] = []; + const onTickEventDef: FgBlockDef = { + type: "onTick", + build: () => ({ event: FgEventType.Tick, signalOut: [{ name: "out", targets: [] }] }), + execute: (block, ctx) => { + const payload = ctx.executionVariables[`${block.id}:lastEvent`] as { deltaMs?: number }; + log.push(`tick:${payload.deltaMs}`); + }, + }; + const t = mkBlock({ id: "t", type: "onTick", event: FgEventType.Tick, signalOut: [{ name: "out", targets: [] }] }); + const rt = await createFgRuntime(mkGraph([t]), { defs: { onTick: onTickEventDef } }); + + startFlowGraph(rt); + tickFlowGraph(rt, 16); + tickFlowGraph(rt, 32); + expect(log).toEqual(["tick:16", "tick:32"]); + }); + + it("disposeFlowGraph detaches listeners and clears state", async () => { + const log: string[] = []; + const onTickEventDef: FgBlockDef = { + type: "onTick", + build: () => ({ event: FgEventType.Tick }), + execute: (block) => log.push(block.id), + }; + const t = mkBlock({ id: "t", type: "onTick", event: FgEventType.Tick }); + const rt = await createFgRuntime(mkGraph([t]), { defs: { onTick: onTickEventDef } }); + + startFlowGraph(rt); + tickFlowGraph(rt, 16); + expect(log.length).toBe(1); + + disposeFlowGraph(rt); + // Pumping the bus directly after dispose must not reach the detached listener. + pumpFgEvent(rt.env.events, FgEventType.Tick, { deltaMs: 16 }); + expect(log.length).toBe(1); + expect(rt.started).toBe(false); + }); + + it("seeds userVariables from graph variables", async () => { + const graph = mkGraph([], { score: { type: FgType.Number, value: 7 }, name: { type: FgType.String, value: "hi" as FgValue } }); + const rt = await createFgRuntime(graph, {}); + expect(rt.context.userVariables.score).toBe(7); + expect(rt.context.userVariables.name).toBe("hi"); + }); +}); + +describe("flow-graph env resolution", () => { + it("fails loudly on an unsupported block type", async () => { + const graph = mkGraph([mkBlock({ id: "x", type: "DoesNotExist" })]); + await expect(createFgRuntime(graph, {})).rejects.toThrow(/unsupported block type/); + }); +});