diff --git a/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphMathBlocks.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphMathBlocks.pure.ts index 97597756da11..2e4c81e0b208 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphMathBlocks.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphMathBlocks.pure.ts @@ -1,6 +1,6 @@ /** This file must only contain pure code and pure imports */ -import { type IFlowGraphBlockConfiguration } from "../../../flowGraphBlock"; +import { FlowGraphBlock, type IFlowGraphBlockConfiguration } from "../../../flowGraphBlock"; import { FlowGraphTypes, getRichTypeByFlowGraphType, RichTypeAny, RichTypeBoolean, RichTypeFlowGraphInteger, RichTypeNumber } from "../../../flowGraphRichTypes.pure"; import { FlowGraphBinaryOperationBlock } from "../flowGraphBinaryOperationBlock"; import { FlowGraphConstantOperationBlock } from "../flowGraphConstantOperationBlock"; @@ -335,6 +335,15 @@ export class FlowGraphPiBlock extends FlowGraphConstantOperationBlock { } } +/** + * Tau constant (2 * Pi), the ratio of a circle's circumference to its radius. + */ +export class FlowGraphTauBlock extends FlowGraphConstantOperationBlock { + constructor(config?: IFlowGraphBlockConfiguration) { + super(RichTypeNumber, () => 2 * Math.PI, FlowGraphBlockNames.Tau, config); + } +} + /** * Positive inf constant. */ @@ -668,6 +677,21 @@ function Interpolate(a: number, b: number, c: number) { return (1 - c) * a + c * b; } +/** + * Smooth step (Hermite interpolation) per KHR_interactivity `math/smoothStep`. + * Given the edges `a`/`b` and the value `c`, computes the smooth interpolation + * coefficient `t * t * (3 - 2 * t)` where `t = saturate((c - min(a, b)) / |b - a|)`. + * Note that this returns the coefficient in [0, 1]; it does not interpolate between `a` and `b`. + * @param a first edge + * @param b second edge + * @param c value to interpolate + * @returns the smooth-step interpolation coefficient + */ +function SmoothStep(a: number, b: number, c: number) { + const t = Saturate((c - Math.min(a, b)) / Math.abs(b - a)); + return t * t * (3 - 2 * t); +} + /** * Interpolate block. */ @@ -686,6 +710,42 @@ export class FlowGraphMathInterpolationBlock extends FlowGraphTernaryOperationBl } } +/** + * Spherical linear interpolation between two unit quaternions, matching the + * KHR_interactivity `math/quatSlerp` operation. The two inputs are treated as + * Babylon `Quaternion` values regardless of their concrete class (the spec + * defines them as `float4`), and the output is a `Quaternion`. + * + * The underlying implementation is `Quaternion.Slerp`, whose algorithm matches + * the spec step-for-step (dot product, conditional sign-flip on `b`, sin-based + * weighting, and a near-identity short-circuit to a linear blend). + */ +export class FlowGraphMathSlerpBlock extends FlowGraphTernaryOperationBlock { + constructor(config?: IFlowGraphBlockConfiguration) { + super(RichTypeAny, RichTypeAny, RichTypeNumber, RichTypeAny, (a, b, c) => Quaternion.Slerp(a, b, c), FlowGraphBlockNames.MathSlerp, config); + } +} + +/** + * Smooth-step block, backing the KHR_interactivity `math/smoothStep` operation. + * Operates component-wise over floatN inputs (the two edges `a`/`b` and the + * value `c`). + */ +export class FlowGraphMathSmoothStepBlock extends FlowGraphTernaryOperationBlock< + FlowGraphMathOperationType, + FlowGraphMathOperationType, + FlowGraphMathOperationType, + FlowGraphMathOperationType +> { + constructor(config?: IFlowGraphBlockConfiguration) { + super(RichTypeAny, RichTypeAny, RichTypeAny, RichTypeAny, (a, b, c) => this._polymorphicSmoothStep(a, b, c), FlowGraphBlockNames.SmoothStep, config); + } + + private _polymorphicSmoothStep(a: FlowGraphMathOperationType, b: FlowGraphMathOperationType, c: FlowGraphMathOperationType) { + return ComponentWiseTernaryOperation(a, b, c, SmoothStep); + } +} + /** * Equals block. */ @@ -697,14 +757,38 @@ export class FlowGraphEqualityBlock extends FlowGraphBinaryOperationBlock className === FlowGraphTypes.Vector4 || className === FlowGraphTypes.Quaternion; + if (isFourComponent(aClassName) && isFourComponent(bClassName)) { + return (a as Vector4).equals(b as Vector4); } if (_AreSameVectorOrQuaternionClass(aClassName, bClassName) || _AreSameMatrixClass(aClassName, bClassName) || _AreSameIntegerClass(aClassName, bClassName)) { return (a as Vector3).equals(b as Vector3); - } else { - return a === b; } + // Handle mixed number/FlowGraphInteger comparison + if (isNumeric(a) && isNumeric(b)) { + return getNumericValue(a as FlowGraphNumber) === getNumericValue(b as FlowGraphNumber); + } + if (typeof a !== typeof b) { + return false; + } + // Both sides are JSON-Pointer-like ref strings ("/foo/0", "/foo/0/", ...). + // KHR_interactivity ref/eq is defined as "refers to the same object", + // so normalise trailing slashes before comparing — different asset + // authors emit refs with and without the trailing slash for the same + // object (e.g. /nodes/420 vs /nodes/420/). + if (typeof a === "string" && typeof b === "string") { + const aStr: string = a; + const bStr: string = b; + if (aStr.length > 0 && aStr[0] === "/" && bStr.length > 0 && bStr[0] === "/") { + const ar = aStr.endsWith("/") ? aStr.slice(0, -1) : aStr; + const br = bStr.endsWith("/") ? bStr.slice(0, -1) : bStr; + return ar === br; + } + } + return a === b; } } @@ -1295,6 +1379,159 @@ export class FlowGraphOneBitsCounterBlock extends FlowGraphUnaryOperationBlock LMS cone responses. + const long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + const medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + const short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + // Non-linearity (cube root) then LMS' -> Oklab. + const lRoot = Math.cbrt(long); + const mRoot = Math.cbrt(medium); + const sRoot = Math.cbrt(short); + const okL = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot; + const okA = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot; + const okB = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot; + // Oklab -> OkLCh (polar form). + return { l: okL, c: Math.hypot(okA, okB), h: Math.atan2(okB, okA) }; +} + +/** + * Converts an OkLCh color to linear sRGB. Inverse of {@link _RgbToOkLch}; hue is in radians. + * @param l OkLCh lightness + * @param c OkLCh chroma + * @param h OkLCh hue in radians + * @returns the linear sRGB red (r), green (g) and blue (b) components + */ +function _OkLchToRgb(l: number, c: number, h: number): { r: number; g: number; b: number } { + // OkLCh -> Oklab. + const okA = c * Math.cos(h); + const okB = c * Math.sin(h); + // Oklab -> LMS' then cube to LMS. + const lPrime = l + 0.3963377774 * okA + 0.2158037573 * okB; + const mPrime = l - 0.1055613458 * okA - 0.0638541728 * okB; + const sPrime = l - 0.0894841775 * okA - 1.291485548 * okB; + const long = lPrime * lPrime * lPrime; + const medium = mPrime * mPrime * mPrime; + const short = sPrime * sPrime * sPrime; + // LMS -> linear sRGB. + return { + r: 4.0767416621 * long - 3.3077115913 * medium + 0.2309699292 * short, + g: -1.2684380046 * long + 2.6097574011 * medium - 0.3413193965 * short, + b: -0.0041960863 * long - 0.7034186147 * medium + 1.707614701 * short, + }; +} + +/** + * Block that converts a linear sRGB color (r, g, b) to OkLCh (l, c, h). Hue is in radians. + */ +export class FlowGraphRGBToOkLChBlock extends FlowGraphBlock { + /** + * Input connection: the linear red component. + */ + public readonly r: FlowGraphDataConnection; + /** + * Input connection: the linear green component. + */ + public readonly g: FlowGraphDataConnection; + /** + * Input connection: the linear blue component. + */ + public readonly b: FlowGraphDataConnection; + /** + * Output connection: the OkLCh lightness. + */ + public readonly l: FlowGraphDataConnection; + /** + * Output connection: the OkLCh chroma. + */ + public readonly c: FlowGraphDataConnection; + /** + * Output connection: the OkLCh hue, in radians. + */ + public readonly h: FlowGraphDataConnection; + + constructor(config?: IFlowGraphBlockConfiguration) { + super(config); + this.r = this.registerDataInput("r", RichTypeNumber, 0); + this.g = this.registerDataInput("g", RichTypeNumber, 0); + this.b = this.registerDataInput("b", RichTypeNumber, 0); + this.l = this.registerDataOutput("l", RichTypeNumber, 0); + this.c = this.registerDataOutput("c", RichTypeNumber, 0); + this.h = this.registerDataOutput("h", RichTypeNumber, 0); + } + + public override _updateOutputs(context: FlowGraphContext): void { + const { l, c, h } = _RgbToOkLch(this.r.getValue(context), this.g.getValue(context), this.b.getValue(context)); + this.l.setValue(l, context); + this.c.setValue(c, context); + this.h.setValue(h, context); + } + + public override getClassName(): string { + return FlowGraphBlockNames.RGBToOkLCh; + } +} + +/** + * Block that converts an OkLCh color (l, c, h) to linear sRGB (r, g, b). Hue is in radians. + */ +export class FlowGraphRGBFromOkLChBlock extends FlowGraphBlock { + /** + * Input connection: the OkLCh lightness. + */ + public readonly l: FlowGraphDataConnection; + /** + * Input connection: the OkLCh chroma. + */ + public readonly c: FlowGraphDataConnection; + /** + * Input connection: the OkLCh hue, in radians. + */ + public readonly h: FlowGraphDataConnection; + /** + * Output connection: the linear red component. + */ + public readonly r: FlowGraphDataConnection; + /** + * Output connection: the linear green component. + */ + public readonly g: FlowGraphDataConnection; + /** + * Output connection: the linear blue component. + */ + public readonly b: FlowGraphDataConnection; + + constructor(config?: IFlowGraphBlockConfiguration) { + super(config); + this.l = this.registerDataInput("l", RichTypeNumber, 0); + this.c = this.registerDataInput("c", RichTypeNumber, 0); + this.h = this.registerDataInput("h", RichTypeNumber, 0); + this.r = this.registerDataOutput("r", RichTypeNumber, 0); + this.g = this.registerDataOutput("g", RichTypeNumber, 0); + this.b = this.registerDataOutput("b", RichTypeNumber, 0); + } + + public override _updateOutputs(context: FlowGraphContext): void { + const { r, g, b } = _OkLchToRgb(this.l.getValue(context), this.c.getValue(context), this.h.getValue(context)); + this.r.setValue(r, context); + this.g.setValue(g, context); + this.b.setValue(b, context); + } + + public override getClassName(): string { + return FlowGraphBlockNames.RGBFromOkLCh; + } +} + let _Registered = false; /** * Register side effects for flowGraphMathBlocks. @@ -1313,6 +1550,7 @@ export function RegisterFlowGraphMathBlocks(): void { RegisterClass(FlowGraphBlockNames.Random, FlowGraphRandomBlock); RegisterClass(FlowGraphBlockNames.E, FlowGraphEBlock); RegisterClass(FlowGraphBlockNames.PI, FlowGraphPiBlock); + RegisterClass(FlowGraphBlockNames.Tau, FlowGraphTauBlock); RegisterClass(FlowGraphBlockNames.Inf, FlowGraphInfBlock); RegisterClass(FlowGraphBlockNames.NaN, FlowGraphNaNBlock); RegisterClass(FlowGraphBlockNames.Abs, FlowGraphAbsBlock); @@ -1329,6 +1567,10 @@ export function RegisterFlowGraphMathBlocks(): void { RegisterClass(FlowGraphBlockNames.Clamp, FlowGraphClampBlock); RegisterClass(FlowGraphBlockNames.Saturate, FlowGraphSaturateBlock); RegisterClass(FlowGraphBlockNames.MathInterpolation, FlowGraphMathInterpolationBlock); + RegisterClass(FlowGraphBlockNames.MathSlerp, FlowGraphMathSlerpBlock); + RegisterClass(FlowGraphBlockNames.SmoothStep, FlowGraphMathSmoothStepBlock); + RegisterClass(FlowGraphBlockNames.RGBToOkLCh, FlowGraphRGBToOkLChBlock); + RegisterClass(FlowGraphBlockNames.RGBFromOkLCh, FlowGraphRGBFromOkLChBlock); RegisterClass(FlowGraphBlockNames.Equality, FlowGraphEqualityBlock); RegisterClass(FlowGraphBlockNames.LessThan, FlowGraphLessThanBlock); RegisterClass(FlowGraphBlockNames.LessThanOrEqual, FlowGraphLessThanOrEqualBlock); diff --git a/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphMathCombineExtractBlocks.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphMathCombineExtractBlocks.pure.ts index 974de5ad05ea..3b944dbe28d7 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphMathCombineExtractBlocks.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphMathCombineExtractBlocks.pure.ts @@ -143,7 +143,9 @@ export interface IFlowGraphCombineMatrixBlockConfiguration extends IFlowGraphBlo /** * Combines 16 floats into a new Matrix * - * Note that glTF interactivity's combine4x4 uses column-major order, while Babylon.js uses row-major order. + * Note that glTF interactivity's combine4x4 provides its inputs in column-major order, and Babylon.js's + * {@link Matrix} also stores its elements column-major, so column-major inputs map directly onto the matrix + * storage with no reordering. */ export class FlowGraphCombineMatrixBlock extends FlowGraphMathCombineBlock { constructor(config?: IFlowGraphCombineMatrixBlockConfiguration) { @@ -156,41 +158,43 @@ export class FlowGraphCombineMatrixBlock extends FlowGraphMathCombineBlock>(this, "cachedMatrix", null) as Matrix; if (this.config?.inputIsColumnMajor) { + // Column-major inputs map directly onto Babylon's column-major matrix storage. matrix.set( this.getDataInput("input_0")!.getValue(context), - this.getDataInput("input_4")!.getValue(context), - this.getDataInput("input_8")!.getValue(context), - this.getDataInput("input_12")!.getValue(context), this.getDataInput("input_1")!.getValue(context), - this.getDataInput("input_5")!.getValue(context), - this.getDataInput("input_9")!.getValue(context), - this.getDataInput("input_13")!.getValue(context), this.getDataInput("input_2")!.getValue(context), - this.getDataInput("input_6")!.getValue(context), - this.getDataInput("input_10")!.getValue(context), - this.getDataInput("input_14")!.getValue(context), this.getDataInput("input_3")!.getValue(context), + this.getDataInput("input_4")!.getValue(context), + this.getDataInput("input_5")!.getValue(context), + this.getDataInput("input_6")!.getValue(context), this.getDataInput("input_7")!.getValue(context), + this.getDataInput("input_8")!.getValue(context), + this.getDataInput("input_9")!.getValue(context), + this.getDataInput("input_10")!.getValue(context), this.getDataInput("input_11")!.getValue(context), + this.getDataInput("input_12")!.getValue(context), + this.getDataInput("input_13")!.getValue(context), + this.getDataInput("input_14")!.getValue(context), this.getDataInput("input_15")!.getValue(context) ); } else { + // Row-major inputs are transposed into Babylon's column-major storage. matrix.set( this.getDataInput("input_0")!.getValue(context), - this.getDataInput("input_1")!.getValue(context), - this.getDataInput("input_2")!.getValue(context), - this.getDataInput("input_3")!.getValue(context), this.getDataInput("input_4")!.getValue(context), - this.getDataInput("input_5")!.getValue(context), - this.getDataInput("input_6")!.getValue(context), - this.getDataInput("input_7")!.getValue(context), this.getDataInput("input_8")!.getValue(context), - this.getDataInput("input_9")!.getValue(context), - this.getDataInput("input_10")!.getValue(context), - this.getDataInput("input_11")!.getValue(context), this.getDataInput("input_12")!.getValue(context), + this.getDataInput("input_1")!.getValue(context), + this.getDataInput("input_5")!.getValue(context), + this.getDataInput("input_9")!.getValue(context), this.getDataInput("input_13")!.getValue(context), + this.getDataInput("input_2")!.getValue(context), + this.getDataInput("input_6")!.getValue(context), + this.getDataInput("input_10")!.getValue(context), this.getDataInput("input_14")!.getValue(context), + this.getDataInput("input_3")!.getValue(context), + this.getDataInput("input_7")!.getValue(context), + this.getDataInput("input_11")!.getValue(context), this.getDataInput("input_15")!.getValue(context) ); } @@ -217,16 +221,18 @@ export class FlowGraphCombineMatrix2DBlock extends FlowGraphMathCombineBlock>(this, "cachedMatrix", null) as FlowGraphMatrix2D; const array = this.config?.inputIsColumnMajor ? [ - // column to row-major + // Column-major inputs are stored as-is: FlowGraphMatrix2D keeps the glTF column-major array, which is + // also how matrix literals are parsed and how math/extract2x2 reads the elements back. this.getDataInput("input_0")!.getValue(context), - this.getDataInput("input_2")!.getValue(context), this.getDataInput("input_1")!.getValue(context), + this.getDataInput("input_2")!.getValue(context), this.getDataInput("input_3")!.getValue(context), ] : [ + // Row-major inputs are transposed into the column-major storage. this.getDataInput("input_0")!.getValue(context), - this.getDataInput("input_1")!.getValue(context), this.getDataInput("input_2")!.getValue(context), + this.getDataInput("input_1")!.getValue(context), this.getDataInput("input_3")!.getValue(context), ]; matrix.fromArray(array); @@ -253,26 +259,28 @@ export class FlowGraphCombineMatrix3DBlock extends FlowGraphMathCombineBlock>(this, "cachedMatrix", null) as FlowGraphMatrix3D; const array = this.config?.inputIsColumnMajor ? [ - // column to row major + // Column-major inputs are stored as-is: FlowGraphMatrix3D keeps the glTF column-major array, which is + // also how matrix literals are parsed and how math/extract3x3 reads the elements back. this.getDataInput("input_0")!.getValue(context), - this.getDataInput("input_3")!.getValue(context), - this.getDataInput("input_6")!.getValue(context), this.getDataInput("input_1")!.getValue(context), - this.getDataInput("input_4")!.getValue(context), - this.getDataInput("input_7")!.getValue(context), this.getDataInput("input_2")!.getValue(context), + this.getDataInput("input_3")!.getValue(context), + this.getDataInput("input_4")!.getValue(context), this.getDataInput("input_5")!.getValue(context), + this.getDataInput("input_6")!.getValue(context), + this.getDataInput("input_7")!.getValue(context), this.getDataInput("input_8")!.getValue(context), ] : [ + // Row-major inputs are transposed into the column-major storage. this.getDataInput("input_0")!.getValue(context), - this.getDataInput("input_1")!.getValue(context), - this.getDataInput("input_2")!.getValue(context), this.getDataInput("input_3")!.getValue(context), - this.getDataInput("input_4")!.getValue(context), - this.getDataInput("input_5")!.getValue(context), this.getDataInput("input_6")!.getValue(context), + this.getDataInput("input_1")!.getValue(context), + this.getDataInput("input_4")!.getValue(context), this.getDataInput("input_7")!.getValue(context), + this.getDataInput("input_2")!.getValue(context), + this.getDataInput("input_5")!.getValue(context), this.getDataInput("input_8")!.getValue(context), ]; matrix.fromArray(array); diff --git a/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphMatrixMathBlocks.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphMatrixMathBlocks.pure.ts index 496bc1c0937e..6caba5e398d6 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphMatrixMathBlocks.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphMatrixMathBlocks.pure.ts @@ -15,11 +15,18 @@ import { import { Matrix, Quaternion, Vector3 } from "core/Maths/math.vector.pure"; import { FlowGraphBlockNames } from "../../flowGraphBlockNames"; import { FlowGraphUnaryOperationBlock } from "../flowGraphUnaryOperationBlock"; +import { FlowGraphCachedOperationBlock } from "../flowGraphCachedOperationBlock"; import { type FlowGraphMatrix2D } from "core/FlowGraph/CustomTypes/flowGraphMatrix"; import { FlowGraphBinaryOperationBlock } from "../flowGraphBinaryOperationBlock"; import { type FlowGraphMatrix } from "core/FlowGraph/utils"; import { RegisterClass } from "core/Misc/typeStore"; +/** + * Threshold below which the determinant of the normalized 3x3 of a matrix is treated as zero, indicating a + * degenerate (non-decomposable) matrix whose columns are linearly dependent. + */ +const MatrixDecomposeDegenerateEpsilon = 1e-6; + /** * Configuration for the matrix blocks. */ @@ -64,19 +71,35 @@ export class FlowGraphDeterminantBlock extends FlowGraphUnaryOperationBlock { +export class FlowGraphInvertMatrixBlock extends FlowGraphCachedOperationBlock { + /** + * The matrix to invert. + */ + public readonly a: FlowGraphDataConnection; + /** * Creates a new instance of the inverse block. * @param config the configuration of the block */ constructor(config?: IFlowGraphMatrixBlockConfiguration) { - super( - getRichTypeByFlowGraphType(config?.matrixType || FlowGraphTypes.Matrix), - getRichTypeByFlowGraphType(config?.matrixType || FlowGraphTypes.Matrix), - (a) => ((a as FlowGraphMatrix2D).inverse ? (a as FlowGraphMatrix2D).inverse() : Matrix.Invert(a as Matrix)), - FlowGraphBlockNames.InvertMatrix, - config - ); + super(getRichTypeByFlowGraphType(config?.matrixType || FlowGraphTypes.Matrix), config); + this.a = this.registerDataInput("a", getRichTypeByFlowGraphType(config?.matrixType || FlowGraphTypes.Matrix)); + } + + public override _doOperation(context: FlowGraphContext): FlowGraphMatrix | undefined { + const a = this.a.getValue(context); + // Per the KHR_interactivity spec, math/inverse is only valid when the determinant is a finite, non-zero + // number. For a zero, NaN, or infinite determinant the matrix is not invertible: returning undefined makes + // the cached base report isValid = false. + const determinant = a.determinant(); + if (determinant === 0 || !Number.isFinite(determinant)) { + return undefined; + } + return (a as FlowGraphMatrix2D).inverse ? (a as FlowGraphMatrix2D).inverse() : Matrix.Invert(a as Matrix); + } + + public override getClassName(): string { + return FlowGraphBlockNames.InvertMatrix; } } @@ -138,43 +161,90 @@ export class FlowGraphMatrixDecomposeBlock extends FlowGraphBlock { } public override _updateOutputs(context: FlowGraphContext) { - const cachedExecutionId = context._getExecutionVariable(this, "executionId", -1); - const cachedPosition = context._getExecutionVariable(this, "cachedPosition", null); - const cachedRotation = context._getExecutionVariable(this, "cachedRotation", null); - const cachedScaling = context._getExecutionVariable(this, "cachedScaling", null); - if (cachedExecutionId === context.executionId && cachedPosition && cachedRotation && cachedScaling) { - this.position.setValue(cachedPosition, context); - this.rotationQuaternion.setValue(cachedRotation, context); - this.scaling.setValue(cachedScaling, context); - } else { - const matrix = this.input.getValue(context); - const position = cachedPosition || new Vector3(); - const rotation = cachedRotation || new Quaternion(); - const scaling = cachedScaling || new Vector3(); - // check matrix last column components should be 0,0,0,1 - // round them to 4 decimal places - const m3 = Math.round(matrix.m[3] * 10000) / 10000; - const m7 = Math.round(matrix.m[7] * 10000) / 10000; - const m11 = Math.round(matrix.m[11] * 10000) / 10000; - const m15 = Math.round(matrix.m[15] * 10000) / 10000; - if (m3 !== 0 || m7 !== 0 || m11 !== 0 || m15 !== 1) { - this.isValid.setValue(false, context); - this.position.setValue(Vector3.Zero(), context); - this.rotationQuaternion.setValue(Quaternion.Identity(), context); - this.scaling.setValue(Vector3.One(), context); - return; - } - // make the checks for validity - const valid = matrix.decompose(scaling, rotation, position); - this.isValid.setValue(valid, context); - this.position.setValue(position, context); - this.rotationQuaternion.setValue(rotation, context); - this.scaling.setValue(scaling, context); - context._setExecutionVariable(this, "cachedPosition", position); - context._setExecutionVariable(this, "cachedRotation", rotation); - context._setExecutionVariable(this, "cachedScaling", scaling); - context._setExecutionVariable(this, "executionId", context.executionId); + const matrix = this.input.getValue(context); + const m = matrix.m; + + // Per the KHR_interactivity matDecompose algorithm the fourth row of the matrix is ignored: the translation + // comes from the first three elements of the fourth column, the scale from the lengths of the first three + // columns of the upper-left 3x3, and the rotation from that 3x3 once normalized. + const translationX = m[12]; + const translationY = m[13]; + const translationZ = m[14]; + let scaleX = Math.sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]); + const scaleY = Math.sqrt(m[4] * m[4] + m[5] * m[5] + m[6] * m[6]); + const scaleZ = Math.sqrt(m[8] * m[8] + m[9] * m[9] + m[10] * m[10]); + + const allFinite = + Number.isFinite(translationX) && + Number.isFinite(translationY) && + Number.isFinite(translationZ) && + Number.isFinite(scaleX) && + Number.isFinite(scaleY) && + Number.isFinite(scaleZ); + + // A non-finite matrix (NaN/Infinity propagated from the input) is not decomposable: emit the type-default TRS. + if (!allFinite) { + this.isValid.setValue(false, context); + this.position.setValue(Vector3.Zero(), context); + this.rotationQuaternion.setValue(Quaternion.Identity(), context); + this.scaling.setValue(Vector3.One(), context); + return; } + + if (scaleX === 0 || scaleY === 0 || scaleZ === 0) { + // A zero scale component leaves the rotation undefined and the matrix non-decomposable, but the + // translation and (degenerate) scale are still well-defined, so they are reported as-is. + this.isValid.setValue(false, context); + this.position.setValue(new Vector3(translationX, translationY, translationZ), context); + this.rotationQuaternion.setValue(Quaternion.Identity(), context); + this.scaling.setValue(new Vector3(scaleX, scaleY, scaleZ), context); + return; + } + + // The determinant of the upper-left 3x3 (the fourth row is ignored) gives the handedness; dividing by the + // product of the scales yields the determinant of the normalized 3x3, which is (close to) zero only when the + // columns are linearly dependent — a degenerate matrix that cannot represent a rotation. + const determinant = m[0] * (m[5] * m[10] - m[6] * m[9]) - m[4] * (m[1] * m[10] - m[2] * m[9]) + m[8] * (m[1] * m[6] - m[2] * m[5]); + const normalizedDeterminant = determinant / (scaleX * scaleY * scaleZ); + if (Math.abs(normalizedDeterminant) < MatrixDecomposeDegenerateEpsilon) { + this.isValid.setValue(false, context); + this.position.setValue(Vector3.Zero(), context); + this.rotationQuaternion.setValue(Quaternion.Identity(), context); + this.scaling.setValue(Vector3.One(), context); + return; + } + + // Negate the first scale component for a left-handed matrix so the rotation stays right-handed, mirroring + // the normalized first column. + if (determinant < 0) { + scaleX = -scaleX; + } + const invScaleX = 1 / scaleX; + const invScaleY = 1 / scaleY; + const invScaleZ = 1 / scaleZ; + const rotationMatrix = Matrix.FromValues( + m[0] * invScaleX, + m[1] * invScaleX, + m[2] * invScaleX, + 0, + m[4] * invScaleY, + m[5] * invScaleY, + m[6] * invScaleY, + 0, + m[8] * invScaleZ, + m[9] * invScaleZ, + m[10] * invScaleZ, + 0, + 0, + 0, + 0, + 1 + ); + + this.isValid.setValue(true, context); + this.position.setValue(new Vector3(translationX, translationY, translationZ), context); + this.rotationQuaternion.setValue(Quaternion.FromRotationMatrix(rotationMatrix), context); + this.scaling.setValue(new Vector3(scaleX, scaleY, scaleZ), context); } public override getClassName(): string { diff --git a/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphVectorMathBlocks.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphVectorMathBlocks.pure.ts index b3e1c9f2aacb..1585c817cc0f 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphVectorMathBlocks.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Data/Math/flowGraphVectorMathBlocks.pure.ts @@ -15,13 +15,15 @@ import { import { FlowGraphBlockNames } from "../../flowGraphBlockNames"; import { FlowGraphBinaryOperationBlock } from "../flowGraphBinaryOperationBlock"; import { FlowGraphUnaryOperationBlock } from "../flowGraphUnaryOperationBlock"; +import { FlowGraphTernaryOperationBlock } from "../flowGraphTernaryOperationBlock"; +import { FlowGraphCachedOperationBlock } from "../flowGraphCachedOperationBlock"; import { Quaternion, Vector3, Vector4, type Matrix, type Vector2 } from "core/Maths/math.vector.pure"; import { type FlowGraphMatrix2D, type FlowGraphMatrix3D } from "core/FlowGraph/CustomTypes"; import { type FlowGraphMatrix, type FlowGraphVector, _GetClassNameOf } from "core/FlowGraph/utils"; import { type FlowGraphDataConnection } from "../../../flowGraphDataConnection.pure"; import { type FlowGraphContext } from "../../../flowGraphContext"; import { type Nullable } from "../../../../types"; -import { GetAngleBetweenQuaternions, GetQuaternionFromDirections } from "core/FlowGraph/flowGraphMath"; +import { GetAngleBetweenQuaternions, GetQuaternionFromDirections, GetQuaternionFromUpForward, GetVector2Slerp, GetVector3Slerp } from "core/FlowGraph/flowGraphMath"; import { RegisterClass } from "core/Misc/typeStore"; const AxisCacheName = "cachedOperationAxis"; @@ -64,31 +66,51 @@ export interface IFlowGraphNormalizeBlockConfiguration extends IFlowGraphBlockCo /** * Vector normalize block. */ -export class FlowGraphNormalizeBlock extends FlowGraphUnaryOperationBlock { +export class FlowGraphNormalizeBlock extends FlowGraphCachedOperationBlock { + /** + * The vector to normalize. + */ + public readonly a: FlowGraphDataConnection; + constructor(config?: IFlowGraphNormalizeBlockConfiguration) { - super(RichTypeAny, RichTypeAny, (a) => this._polymorphicNormalize(a), FlowGraphBlockNames.Normalize, config); + super(RichTypeAny, config); + this.a = this.registerDataInput("a", RichTypeAny); + } + + public override _doOperation(context: FlowGraphContext): FlowGraphVector | undefined { + return this._polymorphicNormalize(this.a.getValue(context)); } - private _polymorphicNormalize(a: FlowGraphVector) { + private _polymorphicNormalize(a: FlowGraphVector): FlowGraphVector | undefined { const aClassName = _GetClassNameOf(a); - let normalized: FlowGraphVector; switch (aClassName) { case FlowGraphTypes.Vector2: case FlowGraphTypes.Vector3: case FlowGraphTypes.Vector4: - case FlowGraphTypes.Quaternion: - normalized = a.normalizeToNew(); - if (this.config?.nanOnZeroLength) { - const length = a.length(); - if (length === 0) { - normalized.setAll(NaN); + case FlowGraphTypes.Quaternion: { + // Per the KHR_interactivity spec, normalization is only valid when the length is a positive finite + // number. For zero, NaN, or +Infinity length the operation is invalid: returning undefined makes the + // cached base report isValid = false (the `value` output keeps the type default). + const length = (a as Vector3).length(); + if (length === 0 || !Number.isFinite(length)) { + if (this.config?.nanOnZeroLength) { + // Legacy behavior preserved for non-glTF consumers that opt into NaN output. + const nanVector = a.normalizeToNew(); + nanVector.setAll(NaN); + return nanVector; } + return undefined; } - return normalized; + return a.normalizeToNew(); + } default: throw new Error(`Cannot normalize value ${a}`); } } + + public override getClassName(): string { + return FlowGraphBlockNames.Normalize; + } } /** @@ -150,12 +172,15 @@ function TransformVector(a: FlowGraphVector, b: FlowGraphMatrix): FlowGraphVecto return (b as FlowGraphMatrix3D).transformVector(a as Vector3); case FlowGraphTypes.Vector4: a = a as Vector4; - // transform the vector 4 with the matrix here. Vector4.TransformCoordinates transforms a 3D coordinate, not Vector4 + // transform the vector 4 with the matrix here. Vector4.TransformCoordinates transforms a 3D coordinate, not Vector4. + // Babylon's Matrix stores its elements column-major (m[0..3] is the first column), and glTF/KHR_interactivity + // float4x4 values are column-major as well, so M * a reads down the columns: value[i] = sum_j M[i][j] * a[j] + // with M[i][j] = m[j * 4 + i]. return new Vector4( - a.x * b.m[0] + a.y * b.m[1] + a.z * b.m[2] + a.w * b.m[3], - a.x * b.m[4] + a.y * b.m[5] + a.z * b.m[6] + a.w * b.m[7], - a.x * b.m[8] + a.y * b.m[9] + a.z * b.m[10] + a.w * b.m[11], - a.x * b.m[12] + a.y * b.m[13] + a.z * b.m[14] + a.w * b.m[15] + a.x * b.m[0] + a.y * b.m[4] + a.z * b.m[8] + a.w * b.m[12], + a.x * b.m[1] + a.y * b.m[5] + a.z * b.m[9] + a.w * b.m[13], + a.x * b.m[2] + a.y * b.m[6] + a.z * b.m[10] + a.w * b.m[14], + a.x * b.m[3] + a.y * b.m[7] + a.z * b.m[11] + a.w * b.m[15] ); default: throw new Error(`Cannot transform value ${a}`); @@ -304,6 +329,37 @@ export class FlowGraphQuaternionFromDirectionsBlock extends FlowGraphBinaryOpera } } +/** + * Get a rotation quaternion from the specified up and forward directions (KHR_interactivity `math/quatFromUpForward`). + */ +export class FlowGraphQuaternionFromUpForwardBlock extends FlowGraphBinaryOperationBlock { + constructor(config?: IFlowGraphBlockConfiguration) { + super(RichTypeVector3, RichTypeVector3, RichTypeQuaternion, (up, forward) => GetQuaternionFromUpForward(up, forward), FlowGraphBlockNames.QuaternionFromUpForward, config); + } +} + +/** + * Spherical linear interpolation between two vectors (KHR_interactivity `math/slerp`). + * Supports float2 and float3 vectors; the interpolation coefficient is a number. + */ +export class FlowGraphVectorSlerpBlock extends FlowGraphTernaryOperationBlock { + constructor(config?: IFlowGraphBlockConfiguration) { + super(RichTypeAny, RichTypeAny, RichTypeNumber, RichTypeAny, (a, b, c) => this._polymorphicSlerp(a, b, c), FlowGraphBlockNames.VectorSlerp, config); + } + + private _polymorphicSlerp(a: FlowGraphVector, b: FlowGraphVector, c: number): FlowGraphVector { + const className = _GetClassNameOf(a); + switch (className) { + case FlowGraphTypes.Vector2: + return GetVector2Slerp(a as Vector2, b as Vector2, c); + case FlowGraphTypes.Vector3: + return GetVector3Slerp(a as Vector3, b as Vector3, c); + default: + throw new Error(`Cannot slerp value ${a}`); + } + } +} + let _Registered = false; /** * Register side effects for flowGraphVectorMathBlocks. @@ -327,4 +383,7 @@ export function RegisterFlowGraphVectorMathBlocks(): void { RegisterClass(FlowGraphBlockNames.AngleBetween, FlowGraphAngleBetweenBlock); RegisterClass(FlowGraphBlockNames.QuaternionFromAxisAngle, FlowGraphQuaternionFromAxisAngleBlock); RegisterClass(FlowGraphBlockNames.AxisAngleFromQuaternion, FlowGraphAxisAngleFromQuaternionBlock); + RegisterClass(FlowGraphBlockNames.QuaternionFromDirections, FlowGraphQuaternionFromDirectionsBlock); + RegisterClass(FlowGraphBlockNames.QuaternionFromUpForward, FlowGraphQuaternionFromUpForwardBlock); + RegisterClass(FlowGraphBlockNames.VectorSlerp, FlowGraphVectorSlerpBlock); } diff --git a/packages/dev/core/src/FlowGraph/Blocks/Data/Transformers/flowGraphJsonPointerParserBlock.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Data/Transformers/flowGraphJsonPointerParserBlock.pure.ts index 984cf96ff623..b915bd575be1 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Data/Transformers/flowGraphJsonPointerParserBlock.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Data/Transformers/flowGraphJsonPointerParserBlock.pure.ts @@ -88,7 +88,10 @@ export class FlowGraphJsonPointerParserBlock

{ +export class FlowGraphIntToFloat extends FlowGraphUnaryOperationBlock { constructor(config?: IFlowGraphBlockConfiguration) { - super(RichTypeFlowGraphInteger, RichTypeNumber, (a) => a.value, FlowGraphBlockNames.IntToFloat, config); + super(RichTypeAny, RichTypeNumber, (a) => (typeof a === "number" ? a : a?.value), FlowGraphBlockNames.IntToFloat, config); } } diff --git a/packages/dev/core/src/FlowGraph/Blocks/Data/Utils/flowGraphArrayIndexBlock.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Data/Utils/flowGraphArrayIndexBlock.pure.ts index 3543b5272cb8..724d85171d0e 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Data/Utils/flowGraphArrayIndexBlock.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Data/Utils/flowGraphArrayIndexBlock.pure.ts @@ -46,7 +46,27 @@ export class FlowGraphArrayIndexBlock extends FlowGraphBlock { */ public override _updateOutputs(context: FlowGraphContext): void { const array = this.array.getValue(context); - const index = getNumericValue(this.index.getValue(context)); + const rawIndex = this.index.getValue(context); + // KHR_interactivity opaque-reference values feed in here as JSON-Pointer + // ref strings (e.g. "/animations/0/") instead of plain integers. Extract + // the trailing numeric segment as the index in that case. Undefined / + // unconnected inputs short-circuit to a null output instead of crashing + // ``getNumericValue`` on a missing ``.value`` property. + let index: number; + if (rawIndex === undefined || rawIndex === null) { + this.value.setValue(null, context); + return; + } + if (typeof rawIndex === "string") { + const parsed = _ParseRefIndex(rawIndex); + if (parsed === undefined) { + this.value.setValue(null, context); + return; + } + index = parsed; + } else { + index = getNumericValue(rawIndex); + } if (array && index >= 0 && index < array.length) { this.value.setValue(array[index], context); } else { @@ -80,3 +100,28 @@ export function RegisterFlowGraphArrayIndexBlock(): void { RegisterClass(FlowGraphBlockNames.ArrayIndex, FlowGraphArrayIndexBlock); } + +/** + * Extract the trailing numeric segment from a JSON-Pointer-shaped ref string, + * e.g. ``/animations/0/`` → 0, ``/nodes/12`` → 12. Returns ``undefined`` if the + * string does not match the expected ``//(/?)`` shape. + * @param ref the ref string to parse. + * @returns the trailing integer segment, or ``undefined`` when the input is + * not a JSON-Pointer-shaped ref ending in an integer. + */ +function _ParseRefIndex(ref: string): number | undefined { + if (ref.length === 0 || ref[0] !== "/") { + return undefined; + } + const trimmed = ref.endsWith("/") ? ref.slice(0, -1) : ref; + const lastSlash = trimmed.lastIndexOf("/"); + if (lastSlash < 0) { + return undefined; + } + const tail = trimmed.substring(lastSlash + 1); + if (tail.length === 0) { + return undefined; + } + const parsed = Number(tail); + return Number.isFinite(parsed) && Number.isInteger(parsed) ? parsed : undefined; +} diff --git a/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphReceiveCustomEventBlock.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphReceiveCustomEventBlock.pure.ts index 4956545854a6..421283452c9a 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphReceiveCustomEventBlock.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphReceiveCustomEventBlock.pure.ts @@ -5,10 +5,12 @@ import { type FlowGraphContext } from "../../flowGraphContext"; import { FlowGraphEventBlock } from "../../flowGraphEventBlock"; import { type Nullable } from "../../../types"; import { Logger } from "../../../Misc/logger"; -import { type RichType, getRichTypeByFlowGraphType } from "../../flowGraphRichTypes.pure"; +import { type RichType, getRichTypeByFlowGraphType, RichTypeString } from "../../flowGraphRichTypes.pure"; +import { type FlowGraphDataConnection } from "../../flowGraphDataConnection.pure"; import { type IFlowGraphBlockConfiguration } from "../../flowGraphBlock"; import { FlowGraphBlockNames } from "../flowGraphBlockNames"; import { FlowGraphCoordinator } from "core/FlowGraph/flowGraphCoordinator"; +import { GetEventReference } from "core/FlowGraph/flowGraphEventReference"; import { RegisterClass } from "../../../Misc/typeStore"; /** * Parameters used to create a FlowGraphReceiveCustomEventBlock. @@ -34,6 +36,13 @@ export interface IFlowGraphReceiveCustomEventBlockConfiguration extends IFlowGra export class FlowGraphReceiveCustomEventBlock extends FlowGraphEventBlock { public override initPriority: number = 1; + /** + * Output: the KHR_interactivity event reference for the received custom event. + * Per spec (event/receive) receivers of the same event index return the same, + * non-null event reference. We key the reference by the configured event id. + */ + public readonly eventRef: FlowGraphDataConnection; + constructor( /** * the configuration of the block @@ -49,8 +58,15 @@ export class FlowGraphReceiveCustomEventBlock extends FlowGraphEventBlock { const typeKey = typeof entry.type === "string" ? entry.type : entry.type?.typeName; const richType = typeof entry.type?.serialize === "function" ? entry.type : getRichTypeByFlowGraphType(typeKey); entry.type = richType; - this.registerDataOutput(key, richType); + // Pass default value from event data schema so outputs have the correct initial value + this.registerDataOutput(key, richType, (entry as any).value); } + // Reserved `event` output exposing the event reference. Guard against a + // (pathological) custom event value socket literally named "event". + this.eventRef = + this.config.eventData && Object.prototype.hasOwnProperty.call(this.config.eventData, "event") + ? this.getDataOutput("event")! + : this.registerDataOutput("event", RichTypeString, GetEventReference(this.config.eventId)); } public override _preparePendingTasks(context: FlowGraphContext): void { @@ -61,12 +77,21 @@ export class FlowGraphReceiveCustomEventBlock extends FlowGraphEventBlock { return; } - const eventObserver = observable.add((eventData: { [key: string]: any }) => { - const keys = Object.keys(eventData); - for (const key of keys) { - this.getDataOutput(key)?.setValue(eventData[key], context); + const eventObserver = observable.add((eventData: { [key: string]: any }, eventState) => { + // Make this dispatch's EventState reachable by event/stopPropagation + // for the duration of the synchronous receiver flow. + context.configuration.coordinator._beginEventDispatch(this.config.eventId, eventState); + try { + const keys = Object.keys(eventData); + for (const key of keys) { + this.getDataOutput(key)?.setValue(eventData[key], context); + } + // Expose the event reference before activating downstream flow. + this.eventRef.setValue(GetEventReference(this.config.eventId), context); + this._execute(context); + } finally { + context.configuration.coordinator._endEventDispatch(); } - this._execute(context); }); context._setExecutionVariable(this, "_eventObserver", eventObserver); } diff --git a/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphSceneReadyEventBlock.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphSceneReadyEventBlock.pure.ts index 9b0589a4f6c7..48cd239b59ae 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphSceneReadyEventBlock.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphSceneReadyEventBlock.pure.ts @@ -2,8 +2,11 @@ import { FlowGraphEventBlock } from "../../flowGraphEventBlock"; import { type FlowGraphContext } from "core/FlowGraph/flowGraphContext"; +import { type FlowGraphDataConnection } from "core/FlowGraph/flowGraphDataConnection.pure"; +import { RichTypeString } from "core/FlowGraph/flowGraphRichTypes.pure"; import { FlowGraphBlockNames } from "../flowGraphBlockNames"; import { FlowGraphEventType } from "core/FlowGraph/flowGraphEventType"; +import { GetEventReference } from "core/FlowGraph/flowGraphEventReference"; import { RegisterClass } from "../../../Misc/typeStore"; /** * Block that triggers when a scene is ready. @@ -13,7 +16,21 @@ export class FlowGraphSceneReadyEventBlock extends FlowGraphEventBlock { public override readonly type: FlowGraphEventType = FlowGraphEventType.SceneReady; + /** + * Output: the KHR_interactivity event reference for this lifecycle event. + * Per spec (event/onStart) all instances of this operation return the same, + * non-null event reference. We use a stable string ref so `ref/eq` of two + * onStart `event` outputs compares equal. + */ + public readonly eventRef: FlowGraphDataConnection; + + constructor() { + super(); + this.eventRef = this.registerDataOutput("event", RichTypeString, GetEventReference("onStart")); + } + public override _executeEvent(context: FlowGraphContext, _payload: any): boolean { + this.eventRef.setValue(GetEventReference("onStart"), context); this._execute(context); return true; } diff --git a/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphSceneTickEventBlock.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphSceneTickEventBlock.pure.ts index bae78bc0c2ec..e8c62b73f8d3 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphSceneTickEventBlock.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphSceneTickEventBlock.pure.ts @@ -2,10 +2,11 @@ import { FlowGraphEventBlock } from "../../flowGraphEventBlock"; import { type FlowGraphContext } from "core/FlowGraph/flowGraphContext"; -import { RichTypeNumber } from "core/FlowGraph/flowGraphRichTypes.pure"; +import { RichTypeNumber, RichTypeString } from "core/FlowGraph/flowGraphRichTypes.pure"; import { type FlowGraphDataConnection } from "core/FlowGraph/flowGraphDataConnection.pure"; import { FlowGraphBlockNames } from "../flowGraphBlockNames"; import { FlowGraphEventType } from "core/FlowGraph/flowGraphEventType"; +import { GetEventReference } from "core/FlowGraph/flowGraphEventReference"; import { RegisterClass } from "../../../Misc/typeStore"; /** @@ -36,12 +37,20 @@ export class FlowGraphSceneTickEventBlock extends FlowGraphEventBlock { */ public readonly deltaTime: FlowGraphDataConnection; + /** + * Output: the KHR_interactivity event reference for this lifecycle event. + * Per spec (event/onTick) all instances of this operation return the same, + * non-null event reference within a tick. + */ + public readonly eventRef: FlowGraphDataConnection; + public override readonly type: FlowGraphEventType = FlowGraphEventType.SceneBeforeRender; constructor() { super(); this.timeSinceStart = this.registerDataOutput("timeSinceStart", RichTypeNumber); this.deltaTime = this.registerDataOutput("deltaTime", RichTypeNumber); + this.eventRef = this.registerDataOutput("event", RichTypeString, GetEventReference("onTick")); } /** @@ -57,6 +66,7 @@ export class FlowGraphSceneTickEventBlock extends FlowGraphEventBlock { public override _executeEvent(context: FlowGraphContext, payload: IFlowGraphOnTickEventPayload): boolean { this.timeSinceStart.setValue(payload.timeSinceStart, context); this.deltaTime.setValue(payload.deltaTime, context); + this.eventRef.setValue(GetEventReference("onTick"), context); this._execute(context); return true; } diff --git a/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphStopEventPropagationBlock.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphStopEventPropagationBlock.pure.ts new file mode 100644 index 000000000000..ce6ec8647b02 --- /dev/null +++ b/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphStopEventPropagationBlock.pure.ts @@ -0,0 +1,67 @@ +/** This file must only contain pure code and pure imports */ + +import { FlowGraphExecutionBlockWithOutSignal } from "../../flowGraphExecutionBlockWithOutSignal"; +import { type FlowGraphContext } from "../../flowGraphContext"; +import { type FlowGraphDataConnection } from "../../flowGraphDataConnection.pure"; +import { RichTypeBoolean, RichTypeString } from "../../flowGraphRichTypes.pure"; +import { type IFlowGraphBlockConfiguration } from "../../flowGraphBlock"; +import { FlowGraphBlockNames } from "../flowGraphBlockNames"; +import { RegisterClass } from "../../../Misc/typeStore"; + +/** + * Stops the propagation of an in-flight custom event, backing the + * KHR_interactivity `event/stopPropagation` operation. + * + * When activated it asks the coordinator to skip the remaining handler nodes of + * the currently-dispatching event referenced by the `event` input, then activates + * its `out` flow. If the `event` input is not a valid, currently-dispatching event + * reference, activating this block only fires `out` with no other effect (per spec). + */ +export class FlowGraphStopEventPropagationBlock extends FlowGraphExecutionBlockWithOutSignal { + /** + * Input: the event reference (produced by an event operation's `event` output) + * whose propagation should be stopped. + */ + public readonly event: FlowGraphDataConnection; + + /** + * Input: whether to also stop remaining immediate handlers. See + * `FlowGraphCoordinator.stopEventPropagation` for how this maps onto the + * Babylon single-Observable dispatch model. + */ + public readonly stopImmediate: FlowGraphDataConnection; + + public constructor(config?: IFlowGraphBlockConfiguration) { + super(config); + this.event = this.registerDataInput("event", RichTypeString); + this.stopImmediate = this.registerDataInput("stopImmediate", RichTypeBoolean, false); + } + + public _execute(context: FlowGraphContext): void { + const event = this.event.getValue(context); + const stopImmediate = this.stopImmediate.getValue(context); + context.configuration.coordinator.stopEventPropagation(event, stopImmediate); + this.out._activateSignal(context); + } + + /** + * @returns class name of the block. + */ + public override getClassName(): string { + return FlowGraphBlockNames.StopEventPropagation; + } +} + +let _Registered = false; +/** + * Register side effects for flowGraphStopEventPropagationBlock. + * Safe to call multiple times; only the first call has an effect. + */ +export function RegisterFlowGraphStopEventPropagationBlock(): void { + if (_Registered) { + return; + } + _Registered = true; + + RegisterClass(FlowGraphBlockNames.StopEventPropagation, FlowGraphStopEventPropagationBlock); +} diff --git a/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphStopEventPropagationBlock.ts b/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphStopEventPropagationBlock.ts new file mode 100644 index 000000000000..7f49305d75ea --- /dev/null +++ b/packages/dev/core/src/FlowGraph/Blocks/Event/flowGraphStopEventPropagationBlock.ts @@ -0,0 +1,8 @@ +/** + * Re-exports pure implementation and applies runtime side effects. + * Import flowGraphStopEventPropagationBlock.pure for tree-shakeable, side-effect-free usage. + */ +export * from "./flowGraphStopEventPropagationBlock.pure"; + +import { RegisterFlowGraphStopEventPropagationBlock } from "./flowGraphStopEventPropagationBlock.pure"; +RegisterFlowGraphStopEventPropagationBlock(); diff --git a/packages/dev/core/src/FlowGraph/Blocks/Event/index.ts b/packages/dev/core/src/FlowGraph/Blocks/Event/index.ts index f00612d25b13..34042717d2c6 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Event/index.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Event/index.ts @@ -1,6 +1,7 @@ export * from "./flowGraphMeshPickEventBlock"; export * from "./flowGraphSceneReadyEventBlock"; export * from "./flowGraphReceiveCustomEventBlock"; +export * from "./flowGraphStopEventPropagationBlock"; export * from "./flowGraphSendCustomEventBlock"; export * from "./flowGraphSceneTickEventBlock"; export * from "./flowGraphPointerOutEventBlock"; diff --git a/packages/dev/core/src/FlowGraph/Blocks/Event/pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Event/pure.ts index b47aaac46324..68cd3314666b 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Event/pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Event/pure.ts @@ -2,6 +2,7 @@ export * from "./flowGraphMeshPickEventBlock.pure"; export * from "./flowGraphSceneReadyEventBlock.pure"; export * from "./flowGraphReceiveCustomEventBlock.pure"; +export * from "./flowGraphStopEventPropagationBlock.pure"; export * from "./flowGraphSendCustomEventBlock.pure"; export * from "./flowGraphSceneTickEventBlock.pure"; export * from "./flowGraphPointerOutEventBlock.pure"; diff --git a/packages/dev/core/src/FlowGraph/Blocks/Execution/Animation/flowGraphBezierCurveEasingBlock.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Execution/Animation/flowGraphBezierCurveEasingBlock.pure.ts index 20bf4129e0bf..62ab10896be5 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Execution/Animation/flowGraphBezierCurveEasingBlock.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Execution/Animation/flowGraphBezierCurveEasingBlock.pure.ts @@ -1,6 +1,6 @@ /** This file must only contain pure code and pure imports */ -import { type EasingFunction, BezierCurveEase } from "core/Animations/easing"; +import { EasingFunction } from "core/Animations/easing"; import { type IFlowGraphBlockConfiguration, FlowGraphBlock } from "core/FlowGraph/flowGraphBlock"; import { type FlowGraphContext } from "core/FlowGraph/flowGraphContext"; import { type FlowGraphDataConnection } from "core/FlowGraph/flowGraphDataConnection.pure"; @@ -10,7 +10,40 @@ import { FlowGraphBlockNames } from "../../flowGraphBlockNames"; import { RegisterClass } from "core/Misc/typeStore"; /** - * An easing block that generates a BezierCurveEase easingFunction object based on the data provided. + * Cubic Bézier easing used by the KHR_interactivity `variable/interpolate` and + * `pointer/interpolate` operations. + * + * Per the KHR_interactivity specification, the eased output progress `q` is the + * Y coordinate of the cubic Bézier curve — with implicit endpoints `P0 = (0, 0)` + * and `P3 = (1, 1)` and the provided control points `P1`/`P2` — evaluated with the + * input progress `t` used **directly as the curve parameter**: + * + * q(t) = 3·(1 − t)²·t·p1y + 3·(1 − t)·t²·p2y + t³ + * + * This differs from the CSS-style `BezierCurveEase` (which solves for the curve + * parameter where X equals `t` before reading Y). Only the Y components of the + * control points influence the eased progress; the X components are validated by the + * interpolate operation (and can trigger its `err` flow) but do not affect the curve. + */ +class FlowGraphCubicBezierParametricEase extends EasingFunction { + public constructor( + public x1: number, + public y1: number, + public x2: number, + public y2: number + ) { + super(); + } + + public override easeInCore(gradient: number): number { + const t = gradient; + const oneMinusT = 1 - t; + return 3 * oneMinusT * oneMinusT * t * this.y1 + 3 * oneMinusT * t * t * this.y2 + t * t * t; + } +} + +/** + * An easing block that generates a cubic Bézier easing function based on the data provided. */ export class FlowGraphBezierCurveEasingBlock extends FlowGraphBlock { /** @@ -63,9 +96,16 @@ export class FlowGraphBezierCurveEasingBlock extends FlowGraphBlock { return; } + // Include the X components in the cache key so control points that differ + // only in X (e.g. a valid vs NaN X) map to distinct easing instances; the + // X components are not used by the curve but are validated downstream + // (FlowGraphPlayAnimationBlock checks the easing for NaN control points). const key = `${mode}-${controlPoint1.x}-${controlPoint1.y}-${controlPoint2.x}-${controlPoint2.y}`; if (!this._easingFunctions[key]) { - const easing = new BezierCurveEase(controlPoint1.x, controlPoint1.y, controlPoint2.x, controlPoint2.y); + // KHR_interactivity evaluates the cubic Bézier easing parametrically + // (input progress used directly as the curve parameter), so only the + // Y components of the control points affect the eased progress. + const easing = new FlowGraphCubicBezierParametricEase(controlPoint1.x, controlPoint1.y, controlPoint2.x, controlPoint2.y); easing.setEasingMode(mode); this._easingFunctions[key] = easing; } diff --git a/packages/dev/core/src/FlowGraph/Blocks/Execution/Animation/flowGraphPlayAnimationBlock.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Execution/Animation/flowGraphPlayAnimationBlock.pure.ts index a79b13f183fe..1e08c995d19e 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Execution/Animation/flowGraphPlayAnimationBlock.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Execution/Animation/flowGraphPlayAnimationBlock.pure.ts @@ -101,6 +101,9 @@ export class FlowGraphPlayAnimationBlock extends FlowGraphAsyncExecutionBlock { currentAnimationGroup.dispose(); } let animationGroupToUse = ag; + let isInterpolation = false; + let interpolationAnimationsArray: Animation[] | undefined; + let interpolationTarget: any; // check which animation to use. If no animationGroup was defined and an animation was provided, use the animation if (animation && !animationGroupToUse) { const target = this.object.getValue(context); @@ -110,7 +113,6 @@ export class FlowGraphPlayAnimationBlock extends FlowGraphAsyncExecutionBlock { const animationsArray = Array.isArray(animation) ? animation : [animation]; const name = animationsArray[0].name; animationGroupToUse = new AnimationGroup("flowGraphAnimationGroup-" + name + "-" + target.name, context.configuration.scene); - let isInterpolation = false; const interpolationAnimations = context._getGlobalContextVariable("interpolationAnimations", []) as number[]; for (const anim of animationsArray) { animationGroupToUse.addTargetedAnimation(anim, target); @@ -118,16 +120,46 @@ export class FlowGraphPlayAnimationBlock extends FlowGraphAsyncExecutionBlock { isInterpolation = true; } } - - if (isInterpolation) { - this._checkInterpolationDuplications(context, animationsArray, target); - } + interpolationAnimationsArray = animationsArray; + interpolationTarget = target; } // not accepting 0 const speed = this.speed.getValue(context) || 1; const from = this.from.getValue(context) ?? 0; // not accepting 0 const to = this.to.getValue(context) || animationGroupToUse.to; + + // Validate duration for interpolation animations: non-finite or negative values trigger the error flow. + // Only validate when animation is provided (interpolation case), not for general animation/start + // where the AnimationGroup's default to value may legitimately be negative. + if (!isFinite(to) || !isFinite(from)) { + return this._reportError(context, "Invalid animation duration"); + } + if (animation && to < 0) { + return this._reportError(context, "Invalid animation duration"); + } + + // Validate easing function: NaN bezier control points trigger the error flow + if (animation) { + const animationsArray = Array.isArray(animation) ? animation : [animation]; + for (const anim of animationsArray) { + const easing = anim.getEasingFunction?.(); + if (easing && "x1" in easing) { + const bezier = easing as unknown as { x1: number; y1: number; x2: number; y2: number }; + if (isNaN(bezier.x1) || isNaN(bezier.y1) || isNaN(bezier.x2) || isNaN(bezier.y2)) { + return this._reportError(context, "Invalid bezier curve control points"); + } + } + } + } + + // Stop any interpolation already running on the same target/property, but only after validation has + // passed. An invalid interpolation (bad duration or NaN control points) must report [err] without + // disturbing an interpolation that is already running on the same target. + if (isInterpolation && interpolationAnimationsArray && interpolationTarget !== undefined) { + this._checkInterpolationDuplications(context, interpolationAnimationsArray, interpolationTarget); + } + const loop = !isFinite(to) || this.loop.getValue(context); this.currentAnimationGroup.setValue(animationGroupToUse, context); diff --git a/packages/dev/core/src/FlowGraph/Blocks/Execution/ControlFlow/flowGraphCancelDelayBlock.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Execution/ControlFlow/flowGraphCancelDelayBlock.pure.ts index 42a88b181b66..3146c6301e51 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Execution/ControlFlow/flowGraphCancelDelayBlock.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Execution/ControlFlow/flowGraphCancelDelayBlock.pure.ts @@ -10,6 +10,7 @@ import { type FlowGraphSignalConnection } from "../../../flowGraphSignalConnecti import { FlowGraphBlockNames } from "../../flowGraphBlockNames"; import { type FlowGraphInteger } from "core/FlowGraph/CustomTypes/flowGraphInteger.pure"; import { getNumericValue } from "core/FlowGraph/utils"; +import { MarkDelayInactive } from "core/FlowGraph/flowGraphDelayReference"; import { RegisterClass } from "core/Misc/typeStore"; /** @@ -37,6 +38,9 @@ export class FlowGraphCancelDelayBlock extends FlowGraphExecutionBlockWithOutSig timer.dispose(); // not removing it from the array. Disposing it will clear all of its resources } + // The delay is cancelled, so drop it from the active set used by the + // `/extensions/KHR_interactivity/delays/{}` validity check. + MarkDelayInactive(context, delayIndex); // activate the out output flow this.out._activateSignal(context); } diff --git a/packages/dev/core/src/FlowGraph/Blocks/Execution/ControlFlow/flowGraphForLoopBlock.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Execution/ControlFlow/flowGraphForLoopBlock.pure.ts index 506c2043234d..e19167a7c2e5 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Execution/ControlFlow/flowGraphForLoopBlock.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Execution/ControlFlow/flowGraphForLoopBlock.pure.ts @@ -36,7 +36,7 @@ export class FlowGraphForLoopBlock extends FlowGraphExecutionBlockWithOutSignal * The maximum number of iterations allowed for the loop. * If the loop exceeds this number, it will stop. This number is configurable to avoid infinite loops. */ - public static MaxLoopIterations = 1000; + public static MaxLoopIterations = 100000; /** * Input connection: The start index of the loop. */ @@ -85,11 +85,14 @@ export class FlowGraphForLoopBlock extends FlowGraphExecutionBlockWithOutSignal const index = getNumericValue(this.startIndex.getValue(context)); const step = this.step.getValue(context); let endIndex = getNumericValue(this.endIndex.getValue(context)); + let iterations = 0; for (let i = index; i < endIndex; i += step) { this.index.setValue(new FlowGraphInteger(i), context); this.executionFlow._activateSignal(context); endIndex = getNumericValue(this.endIndex.getValue(context)); - if (i > FlowGraphForLoopBlock.MaxLoopIterations * step) { + // Safety net against runaway loops. The cap counts iterations (not the index value) so it + // behaves correctly regardless of startIndex/step. + if (++iterations >= FlowGraphForLoopBlock.MaxLoopIterations) { break; } } diff --git a/packages/dev/core/src/FlowGraph/Blocks/Execution/ControlFlow/flowGraphSetDelayBlock.pure.ts b/packages/dev/core/src/FlowGraph/Blocks/Execution/ControlFlow/flowGraphSetDelayBlock.pure.ts index fc16a89123fd..a52713bb1b3e 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/Execution/ControlFlow/flowGraphSetDelayBlock.pure.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/Execution/ControlFlow/flowGraphSetDelayBlock.pure.ts @@ -10,6 +10,7 @@ import { AdvancedTimer } from "../../../../Misc/timer"; import { Logger } from "../../../../Misc/logger"; import { FlowGraphBlockNames } from "../../flowGraphBlockNames"; import { FlowGraphInteger } from "core/FlowGraph/CustomTypes/flowGraphInteger.pure"; +import { MarkDelayActive, MarkDelayInactive } from "core/FlowGraph/flowGraphDelayReference"; import { RegisterClass } from "core/Misc/typeStore"; /** @@ -68,6 +69,10 @@ export class FlowGraphSetDelayBlock extends FlowGraphAsyncExecutionBlock { const newIndex = lastDelayIndex + 1; this.lastDelayIndex.setValue(new FlowGraphInteger(newIndex), context); context._setGlobalContextVariable("lastDelayIndex", newIndex); + // Track the delay as active so `pointer/get` on + // `/extensions/KHR_interactivity/delays/{}` reports it as valid until it + // fires or is cancelled (KHR_interactivity spec §4.2.4). + MarkDelayActive(context, newIndex); timers[newIndex] = timer; context._setExecutionVariable(this, "pendingDelays", timers); @@ -76,8 +81,13 @@ export class FlowGraphSetDelayBlock extends FlowGraphAsyncExecutionBlock { public override _cancelPendingTasks(context: FlowGraphContext): void { const timers = context._getExecutionVariable(this, "pendingDelays", [] as AdvancedTimer[]); - for (const timer of timers) { - timer?.dispose(); + for (let index = 0; index < timers.length; index++) { + const timer = timers[index]; + if (timer) { + timer.dispose(); + // The delay is no longer scheduled, so drop it from the active set. + MarkDelayInactive(context, index); + } } context._deleteExecutionVariable(this, "pendingDelays"); this.lastDelayIndex.setValue(new FlowGraphInteger(-1), context); @@ -103,6 +113,8 @@ export class FlowGraphSetDelayBlock extends FlowGraphAsyncExecutionBlock { const index = timers.indexOf(timer); if (index !== -1) { timers.splice(index, 1); + // The delay has fired and is no longer scheduled. + MarkDelayInactive(context, index); } else { Logger.Warn("FlowGraphTimerBlock: Timer ended but was not found in the running timers list"); } diff --git a/packages/dev/core/src/FlowGraph/Blocks/flowGraphBlockFactory.ts b/packages/dev/core/src/FlowGraph/Blocks/flowGraphBlockFactory.ts index 13fd25bcaa48..b3bcece3bd80 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/flowGraphBlockFactory.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/flowGraphBlockFactory.ts @@ -48,12 +48,16 @@ export function blockFactory(blockName: FlowGraphBlockNames | string): () => Pro return async () => (await import("./Event/flowGraphSendCustomEventBlock")).FlowGraphSendCustomEventBlock; case FlowGraphBlockNames.ReceiveCustomEvent: return async () => (await import("./Event/flowGraphReceiveCustomEventBlock")).FlowGraphReceiveCustomEventBlock; + case FlowGraphBlockNames.StopEventPropagation: + return async () => (await import("./Event/flowGraphStopEventPropagationBlock")).FlowGraphStopEventPropagationBlock; case FlowGraphBlockNames.MeshPickEvent: return async () => (await import("./Event/flowGraphMeshPickEventBlock")).FlowGraphMeshPickEventBlock; case FlowGraphBlockNames.E: return async () => (await import("./Data/Math/flowGraphMathBlocks")).FlowGraphEBlock; case FlowGraphBlockNames.PI: return async () => (await import("./Data/Math/flowGraphMathBlocks")).FlowGraphPiBlock; + case FlowGraphBlockNames.Tau: + return async () => (await import("./Data/Math/flowGraphMathBlocks")).FlowGraphTauBlock; case FlowGraphBlockNames.Inf: return async () => (await import("./Data/Math/flowGraphMathBlocks")).FlowGraphInfBlock; case FlowGraphBlockNames.NaN: @@ -96,6 +100,14 @@ export function blockFactory(blockName: FlowGraphBlockNames | string): () => Pro return async () => (await import("./Data/Math/flowGraphMathBlocks")).FlowGraphSaturateBlock; case FlowGraphBlockNames.MathInterpolation: return async () => (await import("./Data/Math/flowGraphMathBlocks")).FlowGraphMathInterpolationBlock; + case FlowGraphBlockNames.MathSlerp: + return async () => (await import("./Data/Math/flowGraphMathBlocks")).FlowGraphMathSlerpBlock; + case FlowGraphBlockNames.SmoothStep: + return async () => (await import("./Data/Math/flowGraphMathBlocks")).FlowGraphMathSmoothStepBlock; + case FlowGraphBlockNames.RGBToOkLCh: + return async () => (await import("./Data/Math/flowGraphMathBlocks")).FlowGraphRGBToOkLChBlock; + case FlowGraphBlockNames.RGBFromOkLCh: + return async () => (await import("./Data/Math/flowGraphMathBlocks")).FlowGraphRGBFromOkLChBlock; case FlowGraphBlockNames.Equality: return async () => (await import("./Data/Math/flowGraphMathBlocks")).FlowGraphEqualityBlock; case FlowGraphBlockNames.LessThan: @@ -278,6 +290,10 @@ export function blockFactory(blockName: FlowGraphBlockNames | string): () => Pro return async () => (await import("./Data/Math/flowGraphVectorMathBlocks")).FlowGraphAxisAngleFromQuaternionBlock; case FlowGraphBlockNames.QuaternionFromDirections: return async () => (await import("./Data/Math/flowGraphVectorMathBlocks")).FlowGraphQuaternionFromDirectionsBlock; + case FlowGraphBlockNames.QuaternionFromUpForward: + return async () => (await import("./Data/Math/flowGraphVectorMathBlocks")).FlowGraphQuaternionFromUpForwardBlock; + case FlowGraphBlockNames.VectorSlerp: + return async () => (await import("./Data/Math/flowGraphVectorMathBlocks")).FlowGraphVectorSlerpBlock; case FlowGraphBlockNames.MatrixDecompose: return async () => (await import("./Data/Math/flowGraphMatrixMathBlocks")).FlowGraphMatrixDecomposeBlock; case FlowGraphBlockNames.MatrixCompose: diff --git a/packages/dev/core/src/FlowGraph/Blocks/flowGraphBlockNames.ts b/packages/dev/core/src/FlowGraph/Blocks/flowGraphBlockNames.ts index ddf2bfc96370..7a459974e9ec 100644 --- a/packages/dev/core/src/FlowGraph/Blocks/flowGraphBlockNames.ts +++ b/packages/dev/core/src/FlowGraph/Blocks/flowGraphBlockNames.ts @@ -11,6 +11,7 @@ export const enum FlowGraphBlockNames { SceneTickEvent = "FlowGraphSceneTickEventBlock", SendCustomEvent = "FlowGraphSendCustomEventBlock", ReceiveCustomEvent = "FlowGraphReceiveCustomEventBlock", + StopEventPropagation = "FlowGraphStopEventPropagationBlock", MeshPickEvent = "FlowGraphMeshPickEventBlock", PointerEvent = "FlowGraphPointerEventBlock", PointerDownEvent = "FlowGraphPointerDownEventBlock", @@ -23,6 +24,7 @@ export const enum FlowGraphBlockNames { IsKeyPressed = "FlowGraphIsKeyPressedBlock", E = "FlowGraphEBlock", PI = "FlowGraphPIBlock", + Tau = "FlowGraphTauBlock", Inf = "FlowGraphInfBlock", NaN = "FlowGraphNaNBlock", Random = "FlowGraphRandomBlock", @@ -44,6 +46,10 @@ export const enum FlowGraphBlockNames { Clamp = "FlowGraphClampBlock", Saturate = "FlowGraphSaturateBlock", MathInterpolation = "FlowGraphMathInterpolationBlock", + MathSlerp = "FlowGraphMathSlerpBlock", + SmoothStep = "FlowGraphSmoothStepBlock", + RGBToOkLCh = "FlowGraphRGBToOkLChBlock", + RGBFromOkLCh = "FlowGraphRGBFromOkLChBlock", Equality = "FlowGraphEqualityBlock", LessThan = "FlowGraphLessThanBlock", LessThanOrEqual = "FlowGraphLessThanOrEqualBlock", @@ -135,6 +141,8 @@ export const enum FlowGraphBlockNames { QuaternionFromAxisAngle = "FlowGraphQuaternionFromAxisAngleBlock", AxisAngleFromQuaternion = "FlowGraphAxisAngleFromQuaternionBlock", QuaternionFromDirections = "FlowGraphQuaternionFromDirectionsBlock", + QuaternionFromUpForward = "FlowGraphQuaternionFromUpForwardBlock", + VectorSlerp = "FlowGraphVectorSlerpBlock", MatrixDecompose = "FlowGraphMatrixDecompose", MatrixCompose = "FlowGraphMatrixCompose", BooleanToFloat = "FlowGraphBooleanToFloat", diff --git a/packages/dev/core/src/FlowGraph/flowGraphCoordinator.ts b/packages/dev/core/src/FlowGraph/flowGraphCoordinator.ts index 2b3d47c77529..78ca901d656e 100644 --- a/packages/dev/core/src/FlowGraph/flowGraphCoordinator.ts +++ b/packages/dev/core/src/FlowGraph/flowGraphCoordinator.ts @@ -1,10 +1,11 @@ -import { type Observer, Observable } from "core/Misc/observable"; +import { type Observer, type EventState, Observable } from "core/Misc/observable"; import { type Scene } from "../scene"; import { FlowGraph } from "./flowGraph"; import { type IPathToObjectConverter } from "../ObjectModel/objectModelInterfaces"; import { type IObjectAccessor } from "./typeDefinitions"; import { type IAssetContainer } from "core/IAssetContainer"; import { Logger } from "core/Misc/logger"; +import { EventReferencePrefix, IsEventReference } from "./flowGraphEventReference"; /** * Parameters used to create a flow graph engine. @@ -78,6 +79,16 @@ export class FlowGraphCoordinator { private _executeOnNextFrame: { id: string; data?: any; uniqueId: number }[] = []; private _eventUniqueId: number = 0; + /** + * Stack of custom-event dispatches currently in progress. Each entry pairs the + * dispatched event id with the Observable's EventState so that + * `event/stopPropagation` can stop the remaining handlers of an in-flight + * dispatch. A stack (rather than a single value) tolerates re-entrant + * dispatching, e.g. an event handler synchronously sending another event. + * @internal + */ + public _eventDispatchStack: { eventId: string; state: EventState }[] = []; + public constructor( /** * the configuration of the block @@ -234,4 +245,57 @@ export class FlowGraphCoordinator { observable.notifyObservers(data); } } + + /** + * @internal + * Marks the beginning of a custom-event dispatch. Called by event receiver + * blocks from within their Observable callback so that the dispatch's + * EventState becomes reachable by `event/stopPropagation` while the receiver + * flow executes synchronously. + * @param eventId the id of the event being dispatched + * @param state the Observable EventState for this dispatch + */ + public _beginEventDispatch(eventId: string, state: EventState): void { + this._eventDispatchStack.push({ eventId, state }); + } + + /** + * @internal + * Marks the end of the most recent custom-event dispatch started with + * {@link _beginEventDispatch}. + */ + public _endEventDispatch(): void { + this._eventDispatchStack.pop(); + } + + /** + * Stops the propagation of an in-flight custom event, preventing any event + * handler nodes that have not been activated yet from running for the current + * dispatch. This backs the KHR_interactivity `event/stopPropagation` operation. + * + * The `event` argument is the opaque event reference produced by an event + * operation (see {@link IsEventReference}). If it does not reference an event + * that is currently being dispatched, this is a no-op. + * + * In the Babylon runtime all handlers of a given custom event share a single + * Observable dispatch chain, so the `stopImmediate` distinction from the spec + * (transitive-only vs. also-immediate) collapses: stopping propagation always + * skips the remaining handlers of the current dispatch. The flag is accepted + * for spec/forward compatibility. + * @param event the event reference to stop propagation for + * @param _stopImmediate whether to also stop remaining immediate handlers (see remarks) + */ + public stopEventPropagation(event: string, _stopImmediate: boolean): void { + if (!IsEventReference(event)) { + return; + } + const eventId = event.substring(EventReferencePrefix.length); + // Find the most recent matching in-flight dispatch and skip its remaining observers. + for (let i = this._eventDispatchStack.length - 1; i >= 0; i--) { + if (this._eventDispatchStack[i].eventId === eventId) { + this._eventDispatchStack[i].state.skipNextObservers = true; + return; + } + } + } } diff --git a/packages/dev/core/src/FlowGraph/flowGraphDelayReference.ts b/packages/dev/core/src/FlowGraph/flowGraphDelayReference.ts new file mode 100644 index 000000000000..40beceed2587 --- /dev/null +++ b/packages/dev/core/src/FlowGraph/flowGraphDelayReference.ts @@ -0,0 +1,70 @@ +/** + * KHR_interactivity opaque "delay reference" support. + * + * The KHR_interactivity spec (§4.2.4 Delay References) lets a behavior graph + * validate a delay reference produced by `flow/setDelay` via `pointer/get` on + * `/extensions/KHR_interactivity/delays/{}`. A delay reference is valid only + * while it is contained in the runtime "dynamic array of delay activation + * references" — i.e. while the corresponding delay is scheduled and has not yet + * fired or been cancelled. + * + * In the Babylon FlowGraph runtime a delay reference is represented by the + * unique integer index produced by `flow/setDelay` (its `lastDelayIndex` + * output). This module tracks which of those indices are currently active per + * {@link FlowGraphContext} so the `delays/{}` validity accessor can answer the + * `pointer/get` query. + */ + +import { type FlowGraphContext } from "./flowGraphContext"; + +/** + * The JSON Pointer prefix shared by all KHR_interactivity delay references. + */ +export const DelayReferencePrefix = "/extensions/KHR_interactivity/delays/"; + +/** + * Name of the global context variable holding the set of active delay indices. + * @internal + */ +const ActiveDelayIndicesKey = "activeDelayIndices"; + +function GetActiveDelaySet(context: FlowGraphContext): Set { + let set = context._getGlobalContextVariable | null>(ActiveDelayIndicesKey, null); + if (!set) { + set = new Set(); + context._setGlobalContextVariable(ActiveDelayIndicesKey, set); + } + return set; +} + +/** + * Marks the given delay index as active (scheduled and pending) in the context. + * Called by `flow/setDelay` when it schedules a new delayed activation. + * @param context the flow graph context owning the delay. + * @param index the unique delay index produced by `flow/setDelay`. + */ +export function MarkDelayActive(context: FlowGraphContext, index: number): void { + GetActiveDelaySet(context).add(index); +} + +/** + * Marks the given delay index as no longer active. Called when a delay fires, + * is cancelled via the `cancel` input, or is cancelled by `flow/cancelDelay`. + * @param context the flow graph context owning the delay. + * @param index the unique delay index to clear. + */ +export function MarkDelayInactive(context: FlowGraphContext, index: number): void { + context._getGlobalContextVariable | null>(ActiveDelayIndicesKey, null)?.delete(index); +} + +/** + * Returns whether the given delay index is currently active (scheduled and + * pending) in the context, as required by the `delays/{}` `pointer/get` + * validity check. + * @param context the flow graph context to query. + * @param index the delay index to test. + * @returns true if the delay is currently scheduled and has not yet fired or been cancelled. + */ +export function IsDelayActive(context: FlowGraphContext, index: number): boolean { + return context._getGlobalContextVariable | null>(ActiveDelayIndicesKey, null)?.has(index) ?? false; +} diff --git a/packages/dev/core/src/FlowGraph/flowGraphEventReference.ts b/packages/dev/core/src/FlowGraph/flowGraphEventReference.ts new file mode 100644 index 000000000000..9e9b32734099 --- /dev/null +++ b/packages/dev/core/src/FlowGraph/flowGraphEventReference.ts @@ -0,0 +1,45 @@ +/** + * KHR_interactivity opaque "event reference" support. + * + * The latest KHR_interactivity spec gives `event/onStart`, `event/onTick`, and + * `event/receive` a `ref event` output value socket — a runtime reference to the + * event instance that is consumed by `event/stopPropagation` and validated via + * `pointer/get` on `/extensions/KHR_interactivity/events/{}`. + * + * In the Babylon FlowGraph runtime the `ref` type is represented as a JSON + * Pointer string (with the empty string acting as the canonical "null" + * reference). Event references therefore use the spec object-model namespace + * `/extensions/KHR_interactivity/events/` so that: + * - they are non-empty (i.e. "not null"), + * - two references produced by the same event source compare equal via `ref/eq` + * (string comparison), and + * - they can be recognised as event references by `IsEventReference`. + */ + +/** + * The JSON Pointer prefix shared by all KHR_interactivity event references. + */ +export const EventReferencePrefix = "/extensions/KHR_interactivity/events/"; + +/** + * Builds a stable event reference string for the given key. + * + * Lifecycle events use a constant key (`"onStart"` / `"onTick"`) so that all + * instances of the same operation return the same reference. Custom event + * receivers use their event id so that receivers of the same event compare equal. + * @param key the event source key (e.g. `"onStart"`, `"onTick"`, or a custom event id). + * @returns the event reference string. + */ +export function GetEventReference(key: string): string { + return EventReferencePrefix + key; +} + +/** + * Returns whether the provided value is a KHR_interactivity event reference, + * i.e. a non-empty string addressing the events object-model namespace. + * @param value the value to test. + * @returns true if the value was produced by an event operation as a reference. + */ +export function IsEventReference(value: unknown): value is string { + return typeof value === "string" && value.startsWith(EventReferencePrefix); +} diff --git a/packages/dev/core/src/FlowGraph/flowGraphMath.ts b/packages/dev/core/src/FlowGraph/flowGraphMath.ts index 75633740de4a..7ed3367a5ef1 100644 --- a/packages/dev/core/src/FlowGraph/flowGraphMath.ts +++ b/packages/dev/core/src/FlowGraph/flowGraphMath.ts @@ -1,6 +1,6 @@ import { type IQuaternionLike } from "../Maths/math.like"; import { Clamp } from "../Maths/math.scalar.functions"; -import { Quaternion, Vector3 } from "../Maths/math.vector.pure"; +import { Matrix, Quaternion, Vector2, Vector3 } from "../Maths/math.vector.pure"; import { Vector3Dot, Vector4Dot } from "../Maths/math.vector.functions"; import { type DeepImmutable } from "../types"; @@ -8,6 +8,33 @@ import { type DeepImmutable } from "../types"; // These functions should ideally go in math.vector.functions.ts, but they require math.vector.ts to // be imported which is big. To avoid the larger bundle size, they are kept inside flow graph for now. +/** + * Implementation-defined threshold used by the KHR_interactivity slerp/quatFromUpForward operations + * to detect near-zero lengths and (anti)parallel vectors. + */ +const SlerpEpsilon = 1e-6; + +/** + * Returns a unit vector perpendicular to the provided vector. + * @param v the input vector (does not need to be unit length) + * @returns a unit vector perpendicular to `v` + */ +function GetAnyPerpendicularVector(v: DeepImmutable): Vector3 { + const absX = Math.abs(v.x); + const absY = Math.abs(v.y); + const absZ = Math.abs(v.z); + // Cross with whichever cardinal axis is least aligned with `v` to avoid a degenerate cross product. + let other: Vector3; + if (absX <= absY && absX <= absZ) { + other = new Vector3(1, 0, 0); + } else if (absY <= absZ) { + other = new Vector3(0, 1, 0); + } else { + other = new Vector3(0, 0, 1); + } + return Vector3.Cross(v, other).normalize(); +} + /** * Returns the angle in radians between two quaternions * @param q1 defines the first quaternion @@ -43,3 +70,90 @@ export function GetQuaternionFromDirectionsToRef, b: DeepImmutable, c: number): Vector2 { + const lengthA = Math.sqrt(a.x * a.x + a.y * a.y); + const lengthB = Math.sqrt(b.x * b.x + b.y * b.y); + // If either vector is (close to) zero length the rotation is undefined; fall back to a linear interpolation. + if (lengthA < SlerpEpsilon || lengthB < SlerpEpsilon) { + return new Vector2((1 - c) * a.x + c * b.x, (1 - c) * a.y + c * b.y); + } + const aHatX = a.x / lengthA; + const aHatY = a.y / lengthA; + const bHatX = b.x / lengthB; + const bHatY = b.y / lengthB; + let theta = Math.acos(Clamp(aHatX * bHatX + aHatY * bHatY, -1, 1)); + if (aHatX * bHatY - aHatY * bHatX < 0) { + theta = -theta; + } + const length = (1 - c) * lengthA + c * lengthB; + const cosCTheta = Math.cos(c * theta); + const sinCTheta = Math.sin(c * theta); + return new Vector2((aHatX * cosCTheta - aHatY * sinCTheta) * length, (aHatX * sinCTheta + aHatY * cosCTheta) * length); +} + +/** + * Spherical linear interpolation between two 3D vectors, as defined by the KHR_interactivity + * `math/slerp` operation. NaN and infinity values are propagated through the arithmetic. + * @param a the first vector + * @param b the second vector + * @param c the (unclamped) interpolation coefficient + * @returns the interpolated 3D vector + */ +export function GetVector3Slerp(a: DeepImmutable, b: DeepImmutable, c: number): Vector3 { + const lengthA = a.length(); + const lengthB = b.length(); + const lerp = () => new Vector3((1 - c) * a.x + c * b.x, (1 - c) * a.y + c * b.y, (1 - c) * a.z + c * b.z); + // If either vector is (close to) zero length the rotation is undefined; fall back to a linear interpolation. + if (lengthA < SlerpEpsilon || lengthB < SlerpEpsilon) { + return lerp(); + } + const aHat = new Vector3(a.x / lengthA, a.y / lengthA, a.z / lengthA); + const bHat = new Vector3(b.x / lengthB, b.y / lengthB, b.z / lengthB); + const dot = Vector3Dot(aHat, bHat); + // Parallel vectors share a direction; a linear interpolation already produces the correct result. + if (dot > 1 - SlerpEpsilon) { + return lerp(); + } + let rotationAxis: Vector3; + if (dot < -1 + SlerpEpsilon) { + // Anti-parallel vectors: any axis perpendicular to aHat is a valid rotation axis. + rotationAxis = GetAnyPerpendicularVector(aHat); + } else { + rotationAxis = Vector3.Cross(aHat, bHat).normalize(); + } + const angle = c * Math.acos(Clamp(dot, -1, 1)); + const rotation = Quaternion.RotationAxis(rotationAxis, angle); + const length = (1 - c) * lengthA + c * lengthB; + return aHat.applyRotationQuaternion(rotation).scaleInPlace(length); +} + +/** + * Creates a quaternion from the specified up and forward directions, as defined by the + * KHR_interactivity `math/quatFromUpForward` operation. Both inputs are assumed to be unit length. + * @param up the up direction + * @param forward the forward direction + * @returns the rotation quaternion + */ +export function GetQuaternionFromUpForward(up: DeepImmutable, forward: DeepImmutable): Quaternion { + const r = new Vector3(forward.x, forward.y, forward.z); + let s = Vector3.Cross(up, r); + if (s.lengthSquared() < SlerpEpsilon * SlerpEpsilon) { + // up and forward are colinear; pick any unit vector perpendicular to forward. + s = GetAnyPerpendicularVector(r); + } else { + s.normalize(); + } + const t = Vector3.Cross(r, s); + // Build the rotation matrix with columns s, t and r (Babylon matrices are column-major) and convert it. + const matrix = Matrix.FromValues(s.x, s.y, s.z, 0, t.x, t.y, t.z, 0, r.x, r.y, r.z, 0, 0, 0, 0, 1); + return Quaternion.FromRotationMatrix(matrix); +} diff --git a/packages/dev/core/src/FlowGraph/flowGraphPathConverterComponent.ts b/packages/dev/core/src/FlowGraph/flowGraphPathConverterComponent.ts index 51962ba588c6..1144e5a4fc7e 100644 --- a/packages/dev/core/src/FlowGraph/flowGraphPathConverterComponent.ts +++ b/packages/dev/core/src/FlowGraph/flowGraphPathConverterComponent.ts @@ -3,10 +3,28 @@ import { type FlowGraphBlock } from "./flowGraphBlock"; import { type FlowGraphContext } from "./flowGraphContext"; import { type FlowGraphDataConnection } from "./flowGraphDataConnection"; import { FlowGraphInteger } from "./CustomTypes/flowGraphInteger.pure"; -import { RichTypeFlowGraphInteger } from "./flowGraphRichTypes.pure"; +import { RichTypeAny } from "./flowGraphRichTypes.pure"; import { type IObjectAccessor } from "./typeDefinitions"; -const PathHasTemplatesRegex = new RegExp(/\/\{(\w+)\}(?=\/|$)/g); +// KHR_interactivity JSON Pointer templates may use either bracket style: +// {name} → originally an integer template, repurposed by the "opaque reference" spec +// update for refs. Real-world assets mix both conventions, so the bracket +// style by itself is not enough to determine the input's type. +// [name] → integer template (post-ref-update spec). +// We therefore accept both and decide how to substitute at resolution time based on the +// runtime value supplied to the input socket (FlowGraphInteger / number → int substitution, +// string → ref substitution by extracting the matching JSON-Pointer segment). +const RefTemplateRegex = new RegExp(/\/\{(\w+)\}(?=\/|$)/g); +const IntTemplateRegex = new RegExp(/\/\[(\w+)\](?=\/|$)/g); + +interface IPathTemplateInfo { + /** Template variable name (without surrounding brackets). */ + name: string; + /** Bracket style used in the source path; preserved so we replace the right placeholder. */ + style: "curly" | "square"; + /** The connection that supplies the runtime value for substitution. */ + connection: FlowGraphDataConnection; +} /** * @experimental @@ -14,24 +32,40 @@ const PathHasTemplatesRegex = new RegExp(/\/\{(\w+)\}(?=\/|$)/g); */ export class FlowGraphPathConverterComponent { /** - * The templated inputs for the provided path. + * The templated inputs for the provided path. Values may be FlowGraphInteger, number, or + * string (an opaque reference encoded as a JSON Pointer). */ - public readonly templatedInputs: FlowGraphDataConnection[] = []; + public readonly templatedInputs: FlowGraphDataConnection[] = []; + + /** Per-template metadata (name + bracket style + input connection). */ + public readonly templateInfos: IPathTemplateInfo[] = []; + public constructor( public path: string, public ownerBlock: FlowGraphBlock ) { - let match = PathHasTemplatesRegex.exec(path); const templateSet = new Set(); - while (match) { - const [, matchGroup] = match; - if (templateSet.has(matchGroup)) { - throw new Error("Duplicate template variable detected."); + + const collect = (regex: RegExp, style: "curly" | "square") => { + let match = regex.exec(path); + while (match) { + const [, name] = match; + if (templateSet.has(name)) { + throw new Error("Duplicate template variable detected."); + } + templateSet.add(name); + // Use RichTypeAny so the same socket can receive either an integer (legacy / + // [name] style) or a string ref (post-ref-update {name} style); the value's + // runtime type drives the substitution behaviour in getAccessor. + const conn = ownerBlock.registerDataInput(name, RichTypeAny, undefined); + this.templatedInputs.push(conn); + this.templateInfos.push({ name, style, connection: conn }); + match = regex.exec(path); } - templateSet.add(matchGroup); - this.templatedInputs.push(ownerBlock.registerDataInput(matchGroup, RichTypeFlowGraphInteger, new FlowGraphInteger(0))); - match = PathHasTemplatesRegex.exec(path); - } + }; + + collect(RefTemplateRegex, "curly"); + collect(IntTemplateRegex, "square"); } /** @@ -43,13 +77,102 @@ export class FlowGraphPathConverterComponent { */ public getAccessor(pathConverter: IPathToObjectConverter, context: FlowGraphContext): IObjectInfo { let finalPath = this.path; - for (const templatedInput of this.templatedInputs) { - const valueToReplace = templatedInput.getValue(context).value; - if (typeof valueToReplace !== "number" || valueToReplace < 0) { - throw new Error("Invalid value for templated input."); - } - finalPath = finalPath.replace(`{${templatedInput.name}}`, valueToReplace.toString()); + for (const info of this.templateInfos) { + const raw = info.connection.getValue(context); + const placeholder = info.style === "curly" ? `{${info.name}}` : `[${info.name}]`; + const substitution = ResolveTemplateSubstitution(this.path, info.name, raw); + finalPath = finalPath.replace(placeholder, substitution); } return pathConverter.convert(finalPath); } } + +/** + * Decide what string to splice into a templated path for a given runtime value. + * + * - FlowGraphInteger / number → use the integer's decimal representation. + * - string → treat as a JSON Pointer to a glTF object and pull the segment whose position + * in the ref matches the position of `{name}` (or `[name]`) in the surrounding template. + * Falls back to the last non-empty segment, then to the raw ref string. + * @param template the original templated path (used to locate the placeholder position) + * @param name the name of the template parameter being resolved + * @param raw the runtime value supplied for the template parameter + * @returns the substring to splice into the templated path in place of the placeholder + */ +function ResolveTemplateSubstitution(template: string, name: string, raw: any): string { + if (raw instanceof FlowGraphInteger) { + AssertNonNegativeInt(raw.value, name); + return raw.value.toString(); + } + if (typeof raw === "number") { + AssertNonNegativeInt(raw, name); + return raw.toString(); + } + if (typeof raw === "string") { + if (raw === "") { + throw new Error(`Templated reference input "${name}" is null.`); + } + return ExtractRefSubstitution(template, name, raw); + } + // Babylon object refs (e.g. a Mesh delivered by `event/onSelect.selectedNode`): + // the glTF loader stamps `_internalMetadata.gltf.pointers` with one entry + // per JSON-Pointer the object can be addressed by, e.g. a single-primitive + // Mesh holds both `/nodes/` and `/meshes//primitives/`. We pick + // the pointer whose root segment matches the segment in the template that + // immediately precedes the placeholder, so a template like + // `/nodes/{nodeRef}/globalMatrix` resolves against the `/nodes/` ref + // even if `/meshes//primitives/` was added to the object first. + if (raw && typeof raw === "object") { + const pointer = ExtractGltfPointerFromObject(raw, template, name); + if (pointer) { + return ExtractRefSubstitution(template, name, pointer); + } + } + throw new Error(`Invalid value for templated input "${name}": got ${typeof raw}.`); +} + +function ExtractGltfPointerFromObject(obj: any, template: string, name: string): string | undefined { + const pointers = obj?._internalMetadata?.gltf?.pointers; + if (!Array.isArray(pointers) || pointers.length === 0) { + return undefined; + } + const stringPointers = pointers.filter((p: unknown): p is string => typeof p === "string"); + if (stringPointers.length === 0) { + return undefined; + } + // Find the segment in the template that precedes the placeholder, e.g. + // "nodes" for "/nodes/{nodeRef}/globalMatrix". + const placeholders = [`{${name}}`, `[${name}]`]; + const templateSegments = template.split("/"); + const placeholderIndex = templateSegments.findIndex((s) => placeholders.indexOf(s) >= 0); + const expectedRoot = placeholderIndex > 0 ? templateSegments[placeholderIndex - 1] : undefined; + if (expectedRoot) { + const match = stringPointers.find((p) => p.split("/")[1] === expectedRoot); + if (match) { + return match; + } + } + return stringPointers[0]; +} + +function AssertNonNegativeInt(value: number, name: string): void { + if (typeof value !== "number" || value < 0 || !Number.isFinite(value)) { + throw new Error(`Invalid value for templated input "${name}": ${value}.`); + } +} + +function ExtractRefSubstitution(template: string, name: string, refValue: string): string { + const templateSegments = template.split("/"); + const placeholders = [`{${name}}`, `[${name}]`]; + const placeholderIndex = templateSegments.findIndex((s) => placeholders.indexOf(s) >= 0); + const refSegments = refValue.split("/"); + if (placeholderIndex >= 0 && placeholderIndex < refSegments.length && refSegments[placeholderIndex] !== "") { + return refSegments[placeholderIndex]; + } + for (let i = refSegments.length - 1; i >= 0; i--) { + if (refSegments[i] !== "") { + return refSegments[i]; + } + } + return refValue; +} diff --git a/packages/dev/core/src/FlowGraph/flowGraphSignalConnection.pure.ts b/packages/dev/core/src/FlowGraph/flowGraphSignalConnection.pure.ts index 551f074f7582..49bf53a42a83 100644 --- a/packages/dev/core/src/FlowGraph/flowGraphSignalConnection.pure.ts +++ b/packages/dev/core/src/FlowGraph/flowGraphSignalConnection.pure.ts @@ -58,11 +58,18 @@ export class FlowGraphSignalConnection extends FlowGraphConnection { + let engine: NullEngine; + let scene: Scene; + let context: FlowGraphContext; + + beforeEach(() => { + engine = new NullEngine(); + scene = new Scene(engine); + const coordinator = new FlowGraphCoordinator({ scene }); + context = coordinator.createGraph().createContext(); + }); + + afterEach(() => { + scene.dispose(); + engine.dispose(); + }); + + function easeFor(p1: Vector2, p2: Vector2, mode: number = EasingFunction.EASINGMODE_EASEIN): EasingFunction { + const block = new FlowGraphBezierCurveEasingBlock(); + block.mode.setValue(mode, context); + block.controlPoint1.setValue(p1, context); + block.controlPoint2.setValue(p2, context); + block._updateOutputs(context); + return block.easingFunction.getValue(context) as EasingFunction; + } + + it("evaluates the cubic Bézier Y at parameter = t (P1=P2=(1,1) => q(0.5)=0.875)", () => { + const easing = easeFor(new Vector2(1, 1), new Vector2(1, 1)); + expect(easing.ease(0)).toBeCloseTo(0, 6); + expect(easing.ease(0.5)).toBeCloseTo(0.875, 6); + expect(easing.ease(1)).toBeCloseTo(1, 6); + }); + + it("matches the closed-form 3(1-t)^2 t p1y + 3(1-t) t^2 p2y + t^3", () => { + const p1y = 0.25; + const p2y = 0.9; + const easing = easeFor(new Vector2(0.3, p1y), new Vector2(0.7, p2y)); + for (const t of [0.1, 0.25, 0.5, 0.75, 0.9]) { + const u = 1 - t; + const expected = 3 * u * u * t * p1y + 3 * u * t * t * p2y + t * t * t; + expect(easing.ease(t)).toBeCloseTo(expected, 6); + } + }); + + it("uses only the Y components of the control points for the curve", () => { + const a = easeFor(new Vector2(0.2, 1), new Vector2(0.8, 1)); + const b = easeFor(new Vector2(1, 1), new Vector2(0.0, 1)); + expect(a.ease(0.5)).toBeCloseTo(b.ease(0.5), 6); + }); + + it("retains x1/y1/x2/y2 components so downstream NaN validation still works", () => { + const easing = easeFor(new Vector2(NaN, 1), new Vector2(1, 1)) as unknown as { x1: number; y1: number; x2: number; y2: number }; + expect("x1" in easing).toBe(true); + expect(isNaN(easing.x1)).toBe(true); + expect(easing.y1).toBe(1); + }); +}); diff --git a/packages/dev/core/test/unit/FlowGraph/flowGraphDataNodes.test.ts b/packages/dev/core/test/unit/FlowGraph/flowGraphDataNodes.test.ts index 666023e8838f..79e84feaeb85 100644 --- a/packages/dev/core/test/unit/FlowGraph/flowGraphDataNodes.test.ts +++ b/packages/dev/core/test/unit/FlowGraph/flowGraphDataNodes.test.ts @@ -9,6 +9,8 @@ import { FlowGraphAddBlock, FlowGraphRandomBlock, FlowGraphConstantBlock, + FlowGraphRGBToOkLChBlock, + FlowGraphRGBFromOkLChBlock, } from "core/FlowGraph"; import { Logger } from "core/Misc/logger"; import { Scene } from "core/scene"; @@ -61,6 +63,52 @@ describe("Flow Graph Data Nodes", () => { expect(Logger.Log).toHaveBeenCalledWith(43); }); + it("RGB <-> OkLCh conversion blocks", () => { + const toOkLCh = new FlowGraphRGBToOkLChBlock(); + + // Pure (linear) sRGB red -> OkLCh, hue in radians (Ottosson / CSS Color 4 reference values). + toOkLCh.r.setValue(1, flowGraphContext); + toOkLCh.g.setValue(0, flowGraphContext); + toOkLCh.b.setValue(0, flowGraphContext); + expect(toOkLCh.l.getValue(flowGraphContext)).toBeCloseTo(0.628, 2); + expect(toOkLCh.c.getValue(flowGraphContext)).toBeCloseTo(0.2577, 2); + expect(toOkLCh.h.getValue(flowGraphContext)).toBeCloseTo(0.5082, 2); + + // Black -> L=0, C=0. + toOkLCh.r.setValue(0, flowGraphContext); + toOkLCh.g.setValue(0, flowGraphContext); + toOkLCh.b.setValue(0, flowGraphContext); + expect(toOkLCh.l.getValue(flowGraphContext)).toBeCloseTo(0, 5); + expect(toOkLCh.c.getValue(flowGraphContext)).toBeCloseTo(0, 5); + + // White -> L=1, C=0. + toOkLCh.r.setValue(1, flowGraphContext); + toOkLCh.g.setValue(1, flowGraphContext); + toOkLCh.b.setValue(1, flowGraphContext); + expect(toOkLCh.l.getValue(flowGraphContext)).toBeCloseTo(1, 5); + expect(toOkLCh.c.getValue(flowGraphContext)).toBeCloseTo(0, 5); + + // Inverse: OkLCh of red -> back to (1, 0, 0). + const fromOkLCh = new FlowGraphRGBFromOkLChBlock(); + fromOkLCh.l.setValue(0.628, flowGraphContext); + fromOkLCh.c.setValue(0.2577, flowGraphContext); + fromOkLCh.h.setValue(0.5082, flowGraphContext); + expect(fromOkLCh.r.getValue(flowGraphContext)).toBeCloseTo(1, 2); + expect(fromOkLCh.g.getValue(flowGraphContext)).toBeCloseTo(0, 2); + expect(fromOkLCh.b.getValue(flowGraphContext)).toBeCloseTo(0, 2); + + // Round-trip rgb(0.8, 0.3, 0.5) -> OkLCh -> rgb. + toOkLCh.r.setValue(0.8, flowGraphContext); + toOkLCh.g.setValue(0.3, flowGraphContext); + toOkLCh.b.setValue(0.5, flowGraphContext); + fromOkLCh.l.setValue(toOkLCh.l.getValue(flowGraphContext), flowGraphContext); + fromOkLCh.c.setValue(toOkLCh.c.getValue(flowGraphContext), flowGraphContext); + fromOkLCh.h.setValue(toOkLCh.h.getValue(flowGraphContext), flowGraphContext); + expect(fromOkLCh.r.getValue(flowGraphContext)).toBeCloseTo(0.8, 4); + expect(fromOkLCh.g.getValue(flowGraphContext)).toBeCloseTo(0.3, 4); + expect(fromOkLCh.b.getValue(flowGraphContext)).toBeCloseTo(0.5, 4); + }); + it("Values are cached for the same execution id", () => { const sceneReady = new FlowGraphSceneReadyEventBlock({ name: "SceneReady" }); flowGraph.addEventBlock(sceneReady); diff --git a/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_interactivity.pure.ts b/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_interactivity.pure.ts index 2dd008e30164..974637d5eb18 100644 --- a/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_interactivity.pure.ts +++ b/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_interactivity.pure.ts @@ -12,6 +12,13 @@ import { addToBlockFactory } from "core/FlowGraph/Blocks/flowGraphBlockFactory"; import { Quaternion, Vector3 } from "core/Maths/math.vector.pure"; import { type Scene } from "core/scene"; import { type IAnimation } from "../glTFLoaderInterfaces"; +import { CompositePathToObjectConverter, type IPathConverterPrefixEntry } from "./compositePathToObjectConverter"; +import { BabylonScenePathToObjectConverter, BABYLON_SCENE_OBJECT_MODEL_PREFIX, CreateDefaultBabylonSceneObjectModelTree } from "./babylonScenePathToObjectConverter"; +import { InteractivityRefPathToObjectConverter } from "./interactivityRefPathToObjectConverter"; +import { EventReferencePrefix } from "core/FlowGraph/flowGraphEventReference"; +import { DelayReferencePrefix } from "core/FlowGraph/flowGraphDelayReference"; +import { type IObjectAccessor } from "core/FlowGraph/typeDefinitions"; +import { type IPathToObjectConverter } from "core/ObjectModel/objectModelInterfaces"; const NAME = "KHR_interactivity"; @@ -28,7 +35,8 @@ export class KHR_interactivity implements IGLTFLoaderExtension { */ public enabled: boolean; - private _pathConverter?: GLTFPathToObjectConverter; + private _gltfPathConverter?: GLTFPathToObjectConverter; + private _pathConverter?: CompositePathToObjectConverter; /** * @internal @@ -36,13 +44,38 @@ export class KHR_interactivity implements IGLTFLoaderExtension { */ constructor(private _loader: GLTFLoader) { this.enabled = this._loader.isExtensionUsed(NAME); - this._pathConverter = GetPathToObjectConverter(this._loader.gltf); + this._gltfPathConverter = GetPathToObjectConverter(this._loader.gltf); + const scene = _loader.babylonScene; + if (this._gltfPathConverter) { + // Build a composite that handles both: + // - The Babylon-scene namespace (`/extensions/BABYLON_scene_objects/...`), + // used by ref values that point at scene objects not described by the + // source glTF (e.g. refs emitted by engine-side event blocks). + // - The standard glTF object model (everything else), via the existing + // glTF converter as a fallback. + const initialPrefixes: IPathConverterPrefixEntry[] = []; + if (scene) { + initialPrefixes.push({ + prefix: BABYLON_SCENE_OBJECT_MODEL_PREFIX, + converter: new BabylonScenePathToObjectConverter(scene, CreateDefaultBabylonSceneObjectModelTree()), + }); + } + // KHR_interactivity ref-validity pointers (`/extensions/KHR_interactivity/events/{}` + // and `/extensions/KHR_interactivity/delays/{}`) are virtual: they validate an opaque + // event/delay reference rather than addressing a glTF object, so route them to a + // dedicated converter instead of the glTF fallback. + const refConverter = new InteractivityRefPathToObjectConverter(); + initialPrefixes.push({ prefix: EventReferencePrefix, converter: refConverter }); + initialPrefixes.push({ prefix: DelayReferencePrefix, converter: refConverter }); + this._pathConverter = new CompositePathToObjectConverter( + initialPrefixes, + this._gltfPathConverter as unknown as IPathToObjectConverter + ); + } // avoid starting animations automatically. _loader._skipStartAnimationStep = true; // Update object model with new pointers - - const scene = _loader.babylonScene; if (scene) { _AddInteractivityObjectModel(scene); } @@ -50,6 +83,7 @@ export class KHR_interactivity implements IGLTFLoaderExtension { public dispose() { (this._loader as any) = null; + delete this._gltfPathConverter; delete this._pathConverter; } diff --git a/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.ts b/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.ts index 03936ce0c72f..c79722428ebc 100644 --- a/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.ts +++ b/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.ts @@ -59,6 +59,12 @@ interface IGLTFToFlowGraphMappingObject { defaultValue?: any; } +/** + * Description of how a KHR_interactivity declaration (op such as + * `pointer/get`, `event/onSelect`, `math/add`) maps to one or more + * FlowGraph blocks. Used by {@link InteractivityGraphToFlowGraphParser} + * to translate the source glTF graph into the serialized FlowGraph form. + */ export interface IGLTFToFlowGraphMapping { /** * The type of the FlowGraph block(s). @@ -252,6 +258,10 @@ const gltfToFlowGraphMapping: { [key: string]: IGLTFToFlowGraphMapping } = { "event/onStart": { blocks: [FlowGraphBlockNames.SceneReadyEvent], outputs: { + values: { + // KHR_interactivity `ref event` output (the event reference). + event: { name: "event" }, + }, flows: { out: { name: "done" }, }, @@ -263,6 +273,8 @@ const gltfToFlowGraphMapping: { [key: string]: IGLTFToFlowGraphMapping } = { outputs: { values: { timeSinceLastTick: { name: "deltaTime", gltfType: "number" /*, dataTransformer: (time: number) => time / 1000*/ }, + // KHR_interactivity `ref event` output (the event reference). + event: { name: "event" }, }, flows: { out: { name: "done" }, @@ -293,6 +305,10 @@ const gltfToFlowGraphMapping: { [key: string]: IGLTFToFlowGraphMapping } = { "event/receive": { blocks: [FlowGraphBlockNames.ReceiveCustomEvent], outputs: { + values: { + // KHR_interactivity `ref event` output (the event reference). + event: { name: "event" }, + }, flows: { out: { name: "done" }, }, @@ -338,8 +354,26 @@ const gltfToFlowGraphMapping: { [key: string]: IGLTFToFlowGraphMapping } = { return serializedObjects; }, }, + "event/stopPropagation": { + blocks: [FlowGraphBlockNames.StopEventPropagation], + inputs: { + values: { + event: { name: "event" }, + stopImmediate: { name: "stopImmediate" }, + }, + flows: { + in: { name: "in" }, + }, + }, + outputs: { + flows: { + out: { name: "out" }, + }, + }, + }, "math/E": getSimpleInputMapping(FlowGraphBlockNames.E), "math/Pi": getSimpleInputMapping(FlowGraphBlockNames.PI), + "math/Tau": getSimpleInputMapping(FlowGraphBlockNames.Tau), "math/Inf": getSimpleInputMapping(FlowGraphBlockNames.Inf), "math/NaN": getSimpleInputMapping(FlowGraphBlockNames.NaN), "math/abs": getSimpleInputMapping(FlowGraphBlockNames.Abs), @@ -408,7 +442,57 @@ const gltfToFlowGraphMapping: { [key: string]: IGLTFToFlowGraphMapping } = { "math/clamp": getSimpleInputMapping(FlowGraphBlockNames.Clamp, ["a", "b", "c"]), "math/saturate": getSimpleInputMapping(FlowGraphBlockNames.Saturate), "math/mix": getSimpleInputMapping(FlowGraphBlockNames.MathInterpolation, ["a", "b", "c"]), + // Smooth-step (Hermite interpolation): edges a/b and value c. + "math/smoothStep": getSimpleInputMapping(FlowGraphBlockNames.SmoothStep, ["a", "b", "c"]), + // Linear sRGB <-> OkLCh (Oklab polar form). Scalar r/g/b inputs map to l/c/h + // outputs (hue in radians) and vice-versa. + "math/rgbToOkLCh": { + blocks: [FlowGraphBlockNames.RGBToOkLCh], + inputs: { + values: { + r: { name: "r", gltfType: "number" }, + g: { name: "g", gltfType: "number" }, + b: { name: "b", gltfType: "number" }, + }, + }, + outputs: { + values: { + l: { name: "l" }, + c: { name: "c" }, + h: { name: "h" }, + }, + }, + }, + "math/rgbFromOkLCh": { + blocks: [FlowGraphBlockNames.RGBFromOkLCh], + inputs: { + values: { + l: { name: "l", gltfType: "number" }, + c: { name: "c", gltfType: "number" }, + h: { name: "h", gltfType: "number" }, + }, + }, + outputs: { + values: { + r: { name: "r" }, + g: { name: "g" }, + b: { name: "b" }, + }, + }, + }, + // Quaternion spherical-linear interpolation. Inputs are two unit + // quaternions and an unclamped float coefficient. + "math/quatSlerp": getSimpleInputMapping(FlowGraphBlockNames.MathSlerp, ["a", "b", "c"]), + // Vector spherical-linear interpolation (float2/float3). Inputs are two + // vectors and an unclamped float coefficient. + "math/slerp": getSimpleInputMapping(FlowGraphBlockNames.VectorSlerp, ["a", "b", "c"]), "math/eq": getSimpleInputMapping(FlowGraphBlockNames.Equality, ["a", "b"]), + // Reference equality. The spec defines `ref/eq` as: true if both refs are + // null, true if both refer to the same object (regardless of whether it + // exists), false otherwise. FlowGraphEqualityBlock falls through to a + // strict `===` comparison for non-vector/matrix/numeric types, which + // already produces the spec-defined behaviour for Babylon object refs. + "ref/eq": getSimpleInputMapping(FlowGraphBlockNames.Equality, ["a", "b"]), "math/lt": getSimpleInputMapping(FlowGraphBlockNames.LessThan, ["a", "b"]), "math/le": getSimpleInputMapping(FlowGraphBlockNames.LessThanOrEqual, ["a", "b"]), "math/gt": getSimpleInputMapping(FlowGraphBlockNames.GreaterThan, ["a", "b"]), @@ -680,6 +764,20 @@ const gltfToFlowGraphMapping: { [key: string]: IGLTFToFlowGraphMapping } = { }, "math/quatToAxisAngle": getSimpleInputMapping(FlowGraphBlockNames.AxisAngleFromQuaternion, ["a"]), "math/quatFromDirections": getSimpleInputMapping(FlowGraphBlockNames.QuaternionFromDirections, ["a", "b"]), + "math/quatFromUpForward": { + blocks: [FlowGraphBlockNames.QuaternionFromUpForward], + inputs: { + values: { + up: { name: "a", gltfType: "float3" }, + forward: { name: "b", gltfType: "float3" }, + }, + }, + outputs: { + values: { + value: { name: "value" }, + }, + }, + }, "math/combine2x2": { blocks: [FlowGraphBlockNames.CombineMatrix2D], inputs: { @@ -1104,10 +1202,22 @@ const gltfToFlowGraphMapping: { [key: string]: IGLTFToFlowGraphMapping } = { flows: { err: { name: "error" }, }, + values: { + // New spec renames this output to `lastDelay` (ref). Internally we still produce a + // FlowGraphInteger; the index is unique per delay so it acts as the opaque handle. + lastDelay: { name: "lastDelayIndex" }, + }, }, }, "flow/cancelDelay": { blocks: [FlowGraphBlockNames.CancelDelay], + inputs: { + values: { + // New spec renames this input to `delay` (ref). The underlying block reads an int + // from `delayIndex`; when a ref-string flows in we coerce it via the path converter. + delay: { name: "delayIndex" }, + }, + }, }, "variable/get": { blocks: [FlowGraphBlockNames.GetVariable], diff --git a/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.ts b/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.ts index a02190499245..1dfa1c468190 100644 --- a/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.ts +++ b/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.ts @@ -8,9 +8,21 @@ import { type FlowGraphBlockNames } from "core/FlowGraph/Blocks/flowGraphBlockNa import { FlowGraphConnectionType } from "core/FlowGraph/flowGraphConnection"; import { FlowGraphTypes } from "core/FlowGraph/flowGraphRichTypes"; +/** + * Description of a KHR_interactivity custom event, as parsed from the + * glTF `events` array. Used by the importer to register the event with the + * FlowGraph send/receive event blocks. + */ // eslint-disable-next-line @typescript-eslint/naming-convention export interface InteractivityEvent { + /** Identifier of the event, used to match send and receive blocks. */ eventId: string; + /** + * Optional payload schema for the event. Each entry describes one + * value carried by the event: an `id` (the FlowGraph data socket name), + * a `type` (glTF interactivity type name) and an optional default + * `value`. `eventData` (the boolean) is currently unused. + */ eventData?: { eventData: boolean; id: string; @@ -20,7 +32,7 @@ export interface InteractivityEvent { } // eslint-disable-next-line @typescript-eslint/naming-convention export const gltfTypeToBabylonType: { - [key: string]: { length: number; flowGraphType: FlowGraphTypes; elementType: "number" | "boolean" }; + [key: string]: { length: number; flowGraphType: FlowGraphTypes; elementType: "number" | "boolean" | "string" }; } = { float: { length: 1, flowGraphType: FlowGraphTypes.Number, elementType: "number" }, bool: { length: 1, flowGraphType: FlowGraphTypes.Boolean, elementType: "boolean" }, @@ -31,14 +43,27 @@ export const gltfTypeToBabylonType: { float2x2: { length: 4, flowGraphType: FlowGraphTypes.Matrix2D, elementType: "number" }, float3x3: { length: 9, flowGraphType: FlowGraphTypes.Matrix3D, elementType: "number" }, int: { length: 1, flowGraphType: FlowGraphTypes.Integer, elementType: "number" }, + // KHR_interactivity opaque reference type. Represented as a JSON Pointer string + // (e.g. "/nodes/17/") that addresses a glTF object. The empty string is the + // canonical "null reference" sentinel used by the parser. + ref: { length: 1, flowGraphType: FlowGraphTypes.String, elementType: "string" }, }; +/** + * Parses a KHR_interactivity graph definition (the raw glTF JSON object) into + * the serialized FlowGraph form consumed by {@link ParseFlowGraphAsync}. + * + * The class walks the interactivity types, declarations, variables, events + * and nodes in order, applies any importer-side transforms (e.g. the + * relative-pointer-prefix bake) and emits an {@link ISerializedFlowGraph} + * via {@link serializeToFlowGraph}. + */ export class InteractivityGraphToFlowGraphParser { /** * Note - the graph should be rejected if the same type is defined twice. * We currently don't validate that. */ - private _types: { length: number; flowGraphType: FlowGraphTypes; elementType: "number" | "boolean" }[] = []; + private _types: { length: number; flowGraphType: FlowGraphTypes; elementType: "number" | "boolean" | "string" }[] = []; private _mappings: { flowGraphMapping: IGLTFToFlowGraphMapping; fullOperationName: string }[] = []; private _staticVariables: { type: FlowGraphTypes; value: any[] }[] = []; private _events: InteractivityEvent[] = []; @@ -132,6 +157,10 @@ export class InteractivityGraphToFlowGraphParser { case FlowGraphTypes.Number: value.push(NaN); break; + case FlowGraphTypes.String: + // Default for a `ref`-typed value is the null reference, encoded as the empty string. + value.push("" as any); + break; case FlowGraphTypes.Vector2: value.push(NaN, NaN); break; @@ -214,6 +243,10 @@ export class InteractivityGraphToFlowGraphParser { throw new Error(`Error validating interactivity node ${this._interactivityGraph.declarations?.[node.declaration].op} - ${validationResult.error}`); } } + // Bake any static ref-typed value sockets into pointer templates that + // were authored as "relative" (no leading slash). See + // _bakeRelativePointerPrefix for full rationale. + this._bakeRelativePointerPrefix(node, mapping.fullOperationName); const blocks: ISerializedFlowGraphBlock[] = []; // create block(s) for this node using the mapping for (const blockType of mapping.flowGraphMapping.blocks) { @@ -225,6 +258,90 @@ export class InteractivityGraphToFlowGraphParser { } } + /** + * KHR_interactivity test assets such as `Calculator.glb` author + * `pointer/get` and `pointer/set` nodes whose `pointer` configuration value + * is a *relative* JSON Pointer (no leading slash), e.g. + * `extensions/KHR_node_visibility/visible`, paired with a static ref-typed + * value socket like `nodeRef = "/nodes/22/"` that supplies the absolute + * prefix. The standard spec algorithm only substitutes `{name}`/`[name]` + * template parameters and would leave the relative path untouched, so the + * effective JSON Pointer ends up invalid and the `nodeRef` socket has + * nowhere to land on the FlowGraph block (causing + * "Could not find data input with name nodeRef" failures at parse time). + * + * To support this convention we splice a static ref value into the + * pointer template at parse time, dropping the now-baked socket from the + * node's `values` map so the connection wiring step ignores it. Only + * literal/static refs are baked here; refs that come from an upstream + * connection (`{ node, socket }` shape) are left untouched, since we have + * no way to know their value at parse time. Such cases would still + * surface as the same parse-time error and need a richer runtime + * substitution strategy. + * @param node The interactivity node to patch in place. + * @param fullOperationName The fully qualified op name (e.g. `pointer/get`). + */ + private _bakeRelativePointerPrefix(node: IKHRInteractivity_Node, fullOperationName: string): void { + if (!fullOperationName.startsWith("pointer/")) { + return; + } + const pointerCfg = node.configuration?.pointer; + const template = pointerCfg?.value?.[0]; + if (typeof template !== "string" || template.startsWith("/")) { + return; + } + if (!node.values) { + return; + } + for (const name of Object.keys(node.values)) { + const socket = node.values[name] as IKHRInteractivity_Variable & { node?: number; socket?: string }; + // Skip non-ref-typed inputs. By KHR_interactivity convention the ref + // socket of a `pointer/*` op is named after the ref it carries + // (``nodeRef``, ``materialRef``, ``meshRef``, etc.) and never + // ``value`` — `value` is the data being read or written by the + // pointer op, not the pointer prefix. Only consider sockets whose + // declared type is ``ref`` (a literal whose serialized type maps to + // FlowGraphTypes.String) or whose name follows the *Ref convention. + const literal = socket?.value?.[0]; + const declaredType = (socket as any)?.type; + const isLiteralRef = typeof literal === "string" && literal.startsWith("/"); + const isTypedRef = typeof declaredType === "number" && this._types[declaredType]?.flowGraphType === FlowGraphTypes.String; + const isNamedRef = name.endsWith("Ref"); + if (!isLiteralRef && !isTypedRef && !isNamedRef) { + continue; + } + // Static-literal case: the socket has a hardcoded JSON-Pointer ref + // value (e.g. ``"/nodes/22/"``). Splice the literal into the template + // and drop the socket entirely so the connection-wiring step ignores + // it. This is the Calculator.glb pattern. + if (isLiteralRef) { + const trimmedRef = (literal as string).replace(/\/+$/, ""); + (pointerCfg!.value as any[])[0] = trimmedRef + "/" + template; + delete (node.values as any)[name]; + break; + } + // Dynamic-ref case: the socket is connected to an upstream output + // (``{ node, socket }``). We can't bake the value at parse time, so + // instead we rewrite the template so the existing template-parameter + // substitution machinery substitutes the runtime ref. For a socket + // named ``nodeRef`` and a relative template like + // ``extensions/KHR_node_visibility/visible``, rewrite to + // ``/nodes/{nodeRef}/extensions/KHR_node_visibility/visible``. The + // FlowGraphPathConverterComponent will register a ``nodeRef`` data + // input on the block and the connection-wiring step will hook it + // up to the upstream output. At runtime the runtime substitution + // logic extracts the matching JSON-Pointer segment from the ref + // string delivered by that connection. + // This is the MagicBall.glb pattern. + if (typeof socket?.node === "number") { + (pointerCfg!.value as any[])[0] = `/nodes/{${name}}/${template}`; + // Leave the value socket entry as is — it is now the data input + // that the substitution machinery will consume. + break; + } + } + } + private _getEmptyBlock(className: string, type: string): ISerializedFlowGraphBlock { return { uniqueId: RandomGUID(), @@ -295,6 +412,17 @@ export class InteractivityGraphToFlowGraphParser { Logger.Error(["No mapping found for node", gltfNode]); throw new Error("Error parsing node connections"); } + // KHR_interactivity spec section 3.2.4 "Unsupported Operations": + // nodes referring to unsupported operations are demoted to no-ops. + // Activations of their input flow sockets are ignored, their output + // flow sockets are never activated, and their output value sockets + // return constant type-default values. They have no backing + // FlowGraph blocks (blocks.length === 0), so there is nothing to + // wire for this node — skip all of its connections. + if (flowGraphBlocks.blocks.length === 0) { + Logger.Warn(`Skipping connections for no-op node #${i} (unsupported operation: ${flowGraphBlocks.fullOperationName})`); + continue; + } const flowsFromGLTF = gltfNode.flows || {}; const flowsKeys = Object.keys(flowsFromGLTF).sort(); // sorting as some operations require sorted keys // connect the flows @@ -302,10 +430,6 @@ export class InteractivityGraphToFlowGraphParser { const flow = flowsFromGLTF[flowKey]; const flowMapping = outputMapper.flowGraphMapping.outputs?.flows?.[flowKey]; const socketOutName = flowMapping?.name || flowKey; - // create a serialized socket - const socketOut = this._createNewSocketConnection(socketOutName, true); - const block = (flowMapping && flowMapping.toBlock && flowGraphBlocks.blocks.find((b) => b.className === flowMapping.toBlock)) || flowGraphBlocks.blocks[0]; - block.signalOutputs.push(socketOut); // get the input node of this block const inputNodeId = flow.node; const nodeIn = this._nodes[inputNodeId]; @@ -313,6 +437,17 @@ export class InteractivityGraphToFlowGraphParser { Logger.Error(["No node found for input node id", inputNodeId]); throw new Error("Error parsing node connections"); } + // Spec 3.2.4: input flow activations on no-op nodes are ignored, + // so a flow connection into a no-op target is itself a no-op. + // Drop it instead of crashing on the missing target block. + if (nodeIn.blocks.length === 0) { + Logger.Warn(`Dropping flow connection from node #${i} "${flowKey}" to no-op node #${inputNodeId} (unsupported operation: ${nodeIn.fullOperationName})`); + continue; + } + // create a serialized socket + const socketOut = this._createNewSocketConnection(socketOutName, true); + const block = (flowMapping && flowMapping.toBlock && flowGraphBlocks.blocks.find((b) => b.className === flowMapping.toBlock)) || flowGraphBlocks.blocks[0]; + block.signalOutputs.push(socketOut); // get the mapper for the input node - in case it mapped to multiple blocks const inputMapper = getMappingForFullOperationName(nodeIn.fullOperationName); if (!inputMapper) { @@ -373,6 +508,16 @@ export class InteractivityGraphToFlowGraphParser { Logger.Error(["No node found for output socket reference", value]); throw new Error("Error parsing node connections"); } + // Spec 3.2.4: output value sockets of no-op nodes return + // constant type-default values. Leave the consumer's + // dataInput unconnected (no connectedPointIds) so the + // FlowGraph runtime falls back to the RichType default. + if (nodeOut.blocks.length === 0) { + Logger.Warn( + `Dropping value connection from no-op node #${nodeOutId} (unsupported operation: ${nodeOut.fullOperationName}) into node #${i} "${valueKey}"; consumer will use type-default value` + ); + continue; + } const outputMapper = getMappingForFullOperationName(nodeOut.fullOperationName); if (!outputMapper) { Logger.Error(["No mapping found for output socket reference", value]); @@ -462,10 +607,22 @@ export class InteractivityGraphToFlowGraphParser { outputConnection.connectedPointIds.push(inputConnection.uniqueId); } + /** + * Returns the deterministic FlowGraph user-variable name used for the + * static variable at the given declaration index. + * @param index zero-based index into the interactivity graph's `variables` array. + * @returns the FlowGraph variable name (e.g. `staticVariable_3`). + */ public getVariableName(index: number) { return "staticVariable_" + index; } + /** + * Serializes the parsed interactivity graph into the {@link ISerializedFlowGraph} + * payload consumed by `ParseFlowGraphAsync`. Performs node-connection wiring + * and seeds the execution context with the graph's static variables. + * @returns the serialized FlowGraph for the parsed KHR_interactivity graph. + */ public serializeToFlowGraph(): ISerializedFlowGraph { const context: ISerializedFlowGraphContext = { uniqueId: RandomGUID(), diff --git a/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_node_hoverability.pure.ts b/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_node_hoverability.pure.ts index 602afddafb51..b75adafeb115 100644 --- a/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_node_hoverability.pure.ts +++ b/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_node_hoverability.pure.ts @@ -83,6 +83,10 @@ export function RegisterKHR_node_hoverability(): void { outputs: { values: { hoverNodeIndex: { name: "index", toBlock: FlowGraphBlockNames.IndexOf }, + // `hoveredNode` is the new ref-typed output from the Opaque-Reference + // spec update — the picked Babylon mesh itself, available directly + // from FlowGraphPointerOverEventBlock.meshUnderPointer (no IndexOf). + hoveredNode: { name: "meshUnderPointer", toBlock: FlowGraphBlockNames.PointerOverEvent }, controllerIndex: { name: "pointerId" }, }, flows: { @@ -150,6 +154,8 @@ export function RegisterKHR_node_hoverability(): void { outputs: { values: { hoverNodeIndex: { name: "index", toBlock: FlowGraphBlockNames.IndexOf }, + // Ref-typed output: the mesh that the pointer just left. + hoveredNode: { name: "meshOutOfPointer", toBlock: FlowGraphBlockNames.PointerOutEvent }, controllerIndex: { name: "pointerId" }, }, flows: { diff --git a/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_node_selectability.pure.ts b/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_node_selectability.pure.ts index 3759cd4f2a98..5f58e495eba4 100644 --- a/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_node_selectability.pure.ts +++ b/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_node_selectability.pure.ts @@ -80,6 +80,10 @@ export function RegisterKHR_node_selectability(): void { outputs: { values: { selectedNodeIndex: { name: "index", toBlock: FlowGraphBlockNames.IndexOf }, + // `selectedNode` is the new ref-typed output from the Opaque-Reference + // spec update. It's the picked Babylon mesh itself, available directly + // from FlowGraphMeshPickEventBlock.pickedMesh — no IndexOf lookup needed. + selectedNode: { name: "pickedMesh", toBlock: FlowGraphBlockNames.MeshPickEvent }, controllerIndex: { name: "pointerId" }, selectionPoint: { name: "pickedPoint" }, selectionRayOrigin: { name: "pickOrigin" }, diff --git a/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_node_visibility.pure.ts b/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_node_visibility.pure.ts index 800c11928fde..ccfac7b11d1b 100644 --- a/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_node_visibility.pure.ts +++ b/packages/dev/loaders/src/glTF/2.0/Extensions/KHR_node_visibility.pure.ts @@ -46,7 +46,18 @@ export class KHR_node_visibility implements IGLTFLoaderExtension { if (babylonTransformNode) { babylonTransformNode.inheritVisibility = true; if (node.extensions && node.extensions.KHR_node_visibility && node.extensions.KHR_node_visibility.visible === false) { - babylonTransformNode.isVisible = false; + // Apply ``visible: false`` to the same set of meshes the + // runtime ``pointer/set`` accessor writes to. The wrapping + // ``babylonTransformNode`` is often a non-rendering + // ``TransformNode``, so setting ``isVisible`` only there + // leaves the primitive child meshes visible. Mirror the + // accessor below so assets that author hidden defaults + // (e.g. MagicBall.glb's FortuneWords) start hidden as intended. + (babylonTransformNode as AbstractMesh).isVisible = false; + node._primitiveBabylonMeshes?.forEach((mesh) => { + mesh.inheritVisibility = true; + mesh.isVisible = false; + }); } } } diff --git a/packages/dev/loaders/src/glTF/2.0/Extensions/babylonScenePathToObjectConverter.ts b/packages/dev/loaders/src/glTF/2.0/Extensions/babylonScenePathToObjectConverter.ts new file mode 100644 index 000000000000..f75b6e75b6c7 --- /dev/null +++ b/packages/dev/loaders/src/glTF/2.0/Extensions/babylonScenePathToObjectConverter.ts @@ -0,0 +1,346 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { type Scene } from "core/scene"; +import { type TransformNode } from "core/Meshes/transformNode"; +import { type AbstractMesh } from "core/Meshes/abstractMesh"; +import { type Material } from "core/Materials/material"; +import { type Vector3, Quaternion, type Matrix } from "core/Maths/math.vector"; +import { type IObjectAccessor } from "core/FlowGraph/typeDefinitions"; +import { type IObjectInfo, type IPathToObjectConverter } from "core/ObjectModel/objectModelInterfaces"; + +/** + * Root of the JSON-Pointer namespace under which Babylon-scene objects are + * addressed by KHR_interactivity refs that did not originate from the source + * glTF asset (e.g. refs emitted by engine-specific event blocks). + * + * Trailing `/` is intentional: it lets path-prefix dispatchers like + * {@link CompositePathToObjectConverter} match cleanly. + */ +export const BABYLON_SCENE_OBJECT_MODEL_PREFIX = "/extensions/BABYLON_scene_objects/"; + +/** + * Shape of the Babylon-scene object model tree consumed by + * {@link BabylonScenePathToObjectConverter}. Mirrors `IGLTFObjectModelTree` + * but is rooted at scene-asset arrays (`transformNodes`, `meshes`, …) and + * keyed by Babylon `uniqueId`. Initially we only expose the property leaves + * needed to validate the seam end-to-end; future leaves can be added without + * any path-converter changes. + */ +export interface IBabylonSceneObjectModelTree { + /** + * + */ + transformNodes: IBabylonObjectCollection; + /** + * + */ + meshes: IBabylonObjectCollection; + /** + * + */ + materials: IBabylonObjectCollection; +} + +/** + * Generic per-collection node in the tree. `length` exposes a `.length` + * accessor (mirroring glTF). `__array__` is the per-instance leaf hit when a + * uniqueId index appears in the path. + */ +export interface IBabylonObjectCollection { + /** + * + */ + length: IObjectAccessor; + /** + * + */ + __array__: IBabylonObjectLeaves; +} + +/** Per-instance accessors. Add new properties here as they are needed. */ +export interface IBabylonObjectLeaves { + /** Marks this position as a `getTarget` boundary so the resolver can hand back the instance itself. */ + __target__?: boolean; + /** + * + */ + name?: IObjectAccessor; + /** + * + */ + translation?: IObjectAccessor; + /** + * + */ + rotation?: IObjectAccessor; + /** + * + */ + scale?: IObjectAccessor; + /** + * + */ + matrix?: IObjectAccessor; + /** + * + */ + globalMatrix?: IObjectAccessor; + /** + * + */ + visible?: IObjectAccessor; +} + +/** + * Type aliases used internally to keep the per-property accessor signatures + * narrow without forcing every leaf to be re-typed at the use site. + */ +type AnyAccessor = IObjectAccessor; + +/** + * Resolves JSON Pointer paths in the `/extensions/BABYLON_scene_objects/...` + * namespace to Babylon scene objects. + * + * The path layout is `/{root}/{collection}/{uniqueId}/{property}` where + * `{root}` is the literal `extensions/BABYLON_scene_objects` prefix and + * `{uniqueId}` is the Babylon `uniqueId` (stable per session) of the target + * instance. For example: + * + * - `/extensions/BABYLON_scene_objects/transformNodes/42/translation` + * - `/extensions/BABYLON_scene_objects/meshes/17/visible` + * + * Composite path dispatchers (see {@link CompositePathToObjectConverter}) + * route paths starting with the prefix here; everything else continues to be + * resolved by the standard glTF converter. + */ +export class BabylonScenePathToObjectConverter implements IPathToObjectConverter { + public constructor( + private _scene: Scene, + private _tree: IBabylonSceneObjectModelTree + ) {} + + /** + * @param path the full JSON Pointer (must start with the Babylon prefix) + * @returns an object-info container holding the resolved instance and accessor + */ + public convert(path: string): IObjectInfo { + if (!path.startsWith(BABYLON_SCENE_OBJECT_MODEL_PREFIX)) { + throw new Error(`BabylonScenePathToObjectConverter: path "${path}" does not start with the expected prefix "${BABYLON_SCENE_OBJECT_MODEL_PREFIX}".`); + } + + // Strip the namespace prefix and split. Ignore trailing empty segments + // so refs of the form "/extensions/BABYLON_scene_objects/transformNodes/42/" parse cleanly. + const tail = path.slice(BABYLON_SCENE_OBJECT_MODEL_PREFIX.length); + const parts = tail.split("/").filter((p) => p.length > 0); + if (parts.length === 0) { + throw new Error(`BabylonScenePathToObjectConverter: path "${path}" is missing a collection name.`); + } + + const collectionName = parts[0]; + const collection = (this._tree as unknown as Record | undefined>)[collectionName]; + if (!collection) { + throw new Error(`BabylonScenePathToObjectConverter: unknown collection "${collectionName}" in path "${path}".`); + } + + // Handle `.length` (no instance lookup). + if (parts.length === 2 && parts[1] === "length") { + const arr = this._getCollectionArray(collectionName); + return { object: arr, info: collection.length as AnyAccessor }; + } + + if (parts.length < 2) { + throw new Error(`BabylonScenePathToObjectConverter: path "${path}" is missing an instance id.`); + } + + const uniqueId = parseInt(parts[1], 10); + if (!Number.isFinite(uniqueId) || uniqueId < 0) { + throw new Error(`BabylonScenePathToObjectConverter: invalid uniqueId "${parts[1]}" in path "${path}".`); + } + + const instance = this._lookupInstanceByUniqueId(collectionName, uniqueId); + if (!instance) { + throw new Error(`BabylonScenePathToObjectConverter: no ${collectionName} instance found with uniqueId ${uniqueId} (path "${path}").`); + } + + // No property after the id → the ref itself is just a handle to the instance. + // The accessor's `get` and `getTarget` both return the instance. + if (parts.length === 2) { + return { + object: instance, + info: this._buildIdentityAccessor(instance), + }; + } + + // Walk the leaf descriptors for the requested property path. We keep this + // very simple right now: only one segment after the id is supported, which + // covers every property the initial leaves expose. Nested paths can be + // added later by extending the walker. + if (parts.length > 3) { + throw new Error(`BabylonScenePathToObjectConverter: nested property paths are not yet supported (path "${path}").`); + } + const propertyName = parts[2]; + const leaf = (collection.__array__ as Record | boolean | undefined>)[propertyName]; + if (!leaf || typeof leaf === "boolean") { + throw new Error(`BabylonScenePathToObjectConverter: property "${propertyName}" is not registered on ${collectionName} (path "${path}").`); + } + + return { + object: instance, + info: leaf as AnyAccessor, + }; + } + + private _getCollectionArray(collectionName: string): readonly any[] { + switch (collectionName) { + case "transformNodes": + return this._scene.transformNodes; + case "meshes": + return this._scene.meshes; + case "materials": + return this._scene.materials; + default: + return []; + } + } + + private _lookupInstanceByUniqueId(collectionName: string, uniqueId: number): unknown | undefined { + switch (collectionName) { + case "transformNodes": { + const direct = this._scene.transformNodes.find((n) => n.uniqueId === uniqueId); + if (direct) { + return direct; + } + // Meshes are also transform nodes; allow the same path to resolve them. + return this._scene.meshes.find((m) => m.uniqueId === uniqueId); + } + case "meshes": + return this._scene.meshes.find((m) => m.uniqueId === uniqueId); + case "materials": + return this._scene.materials.find((m) => m.uniqueId === uniqueId); + default: + return undefined; + } + } + + private _buildIdentityAccessor(instance: unknown): AnyAccessor { + return { + type: "object", + get: () => instance, + getTarget: () => instance, + isReadOnly: true, + }; + } +} + +/** + * Builds the default Babylon-scene object-model tree. + * + * We deliberately start with a minimal set of properties: the goal of this + * tree is to prove the seam (refs in the BABYLON namespace resolving through + * the same `FlowGraphJsonPointerParserBlock` that the glTF refs use) without + * committing to a complete property surface in this PR. Add new leaves here + * as concrete event-source operations need them. + * @returns a fresh Babylon-scene object-model tree with the default property surface. + */ +export function CreateDefaultBabylonSceneObjectModelTree(): IBabylonSceneObjectModelTree { + return { + transformNodes: { + length: { + type: "number", + get: (arr: TransformNode[]) => arr.length, + getTarget: (arr: TransformNode[]) => arr, + }, + __array__: { + __target__: true, + name: { + type: "string", + get: (n: TransformNode) => n.name, + set: (v: string, n: TransformNode) => { + n.name = v; + }, + getTarget: (n: TransformNode) => n, + }, + translation: { + type: "Vector3", + get: (n: TransformNode) => n.position, + set: (v: Vector3, n: TransformNode) => n.position.copyFrom(v), + getTarget: (n: TransformNode) => n, + }, + rotation: { + type: "Quaternion", + get: (n: TransformNode) => n.rotationQuaternion ?? Quaternion.RotationYawPitchRoll(n.rotation.y, n.rotation.x, n.rotation.z), + set: (v: Quaternion, n: TransformNode) => { + if (!n.rotationQuaternion) { + n.rotationQuaternion = v.clone(); + } else { + n.rotationQuaternion.copyFrom(v); + } + }, + getTarget: (n: TransformNode) => n, + }, + scale: { + type: "Vector3", + get: (n: TransformNode) => n.scaling, + set: (v: Vector3, n: TransformNode) => n.scaling.copyFrom(v), + getTarget: (n: TransformNode) => n, + }, + matrix: { + type: "Matrix", + get: (n: TransformNode) => n.computeWorldMatrix(false), + getTarget: (n: TransformNode) => n, + isReadOnly: true, + }, + globalMatrix: { + type: "Matrix", + get: (n: TransformNode) => n.computeWorldMatrix(true), + getTarget: (n: TransformNode) => n, + isReadOnly: true, + }, + }, + }, + meshes: { + length: { + type: "number", + get: (arr: AbstractMesh[]) => arr.length, + getTarget: (arr: AbstractMesh[]) => arr, + }, + __array__: { + __target__: true, + name: { + type: "string", + get: (m: AbstractMesh) => m.name, + set: (v: string, m: AbstractMesh) => { + m.name = v; + }, + getTarget: (m: AbstractMesh) => m, + }, + visible: { + type: "boolean", + get: (m: AbstractMesh) => m.isVisible, + set: (v: boolean, m: AbstractMesh) => { + m.isVisible = v; + }, + getTarget: (m: AbstractMesh) => m, + }, + }, + }, + materials: { + length: { + type: "number", + get: (arr: Material[]) => arr.length, + getTarget: (arr: Material[]) => arr, + }, + __array__: { + __target__: true, + name: { + type: "string", + get: (m: Material) => m.name, + set: (v: string, m: Material) => { + m.name = v; + }, + getTarget: (m: Material) => m, + }, + }, + }, + }; +} diff --git a/packages/dev/loaders/src/glTF/2.0/Extensions/compositePathToObjectConverter.ts b/packages/dev/loaders/src/glTF/2.0/Extensions/compositePathToObjectConverter.ts new file mode 100644 index 000000000000..a21b33caf715 --- /dev/null +++ b/packages/dev/loaders/src/glTF/2.0/Extensions/compositePathToObjectConverter.ts @@ -0,0 +1,65 @@ +import { type IObjectInfo, type IPathToObjectConverter } from "core/ObjectModel/objectModelInterfaces"; + +/** + * Entry in the composite converter's prefix table. The first entry whose + * `prefix` matches the start of the path wins. + */ +export interface IPathConverterPrefixEntry { + /** Path prefix to match, e.g. `"/extensions/BABYLON_scene_objects/"`. */ + prefix: string; + /** Converter to delegate to when the path starts with `prefix`. */ + converter: IPathToObjectConverter; +} + +/** + * Composite path-to-object converter that dispatches by path prefix. + * + * The KHR_interactivity object model lives at the top of the JSON tree + * (`/nodes/...`, `/materials/...`, `/extensions/...`) and is resolved by + * `GLTFPathToObjectConverter` (see `gltfPathToObjectConverter`). + * + * Babylon-specific extensions can register additional namespaces here + * (for example `/extensions/BABYLON_scene_objects/...` for refs that point + * at scene objects not described by the source glTF) without forcing every + * caller to know about them — `FlowGraphJsonPointerParserBlock` and the + * existing template substitution machinery treat any path uniformly. + * + * Prefix entries are tried in order; if none matches, the fallback converter + * is used. The fallback is typically the glTF converter, since standard + * KHR_interactivity pointer paths sit at the JSON root and have no shared + * prefix that would distinguish them from a missing namespace. + */ +export class CompositePathToObjectConverter implements IPathToObjectConverter { + /** + * @param _prefixes prefix-keyed converter table, tried in order + * @param _fallback converter used when no prefix entry matches + */ + public constructor( + private _prefixes: IPathConverterPrefixEntry[], + private _fallback: IPathToObjectConverter + ) {} + + /** + * Adds a new prefix entry at the front of the lookup list so it is tried + * before any entries registered earlier. Useful for late-registered + * loader extensions that want to override or augment a previously + * registered namespace. + * @param entry the entry to add + */ + public addPrefix(entry: IPathConverterPrefixEntry): void { + this._prefixes.unshift(entry); + } + + /** + * @param path the JSON Pointer path to resolve + * @returns an object accessor for the resolved property + */ + public convert(path: string): IObjectInfo { + for (const { prefix, converter } of this._prefixes) { + if (path.startsWith(prefix)) { + return converter.convert(path); + } + } + return this._fallback.convert(path); + } +} diff --git a/packages/dev/loaders/src/glTF/2.0/Extensions/gltfPathToObjectConverter.ts b/packages/dev/loaders/src/glTF/2.0/Extensions/gltfPathToObjectConverter.ts index ecc3858d69f3..1f0eba93a1dd 100644 --- a/packages/dev/loaders/src/glTF/2.0/Extensions/gltfPathToObjectConverter.ts +++ b/packages/dev/loaders/src/glTF/2.0/Extensions/gltfPathToObjectConverter.ts @@ -14,6 +14,18 @@ export const OptionalPathExceptionsList: { // get the node as object when reading an extension regex: new RegExp(`^/nodes/\\d+/extensions/`), }, + { + // weights may be undefined on nodes without morph targets + regex: new RegExp(`^/nodes/\\d+/weights`), + }, + { + // weights may be undefined on meshes without morph targets + regex: new RegExp(`^/meshes/\\d+/weights`), + }, + { + // KHR_texture_transform may not be present on texture info objects + regex: new RegExp(`/extensions/KHR_texture_transform/`), + }, ]; /** @@ -61,6 +73,15 @@ export class GLTFPathToObjectConverter implements const parts = path.split("/"); parts.shift(); + // KHR_interactivity Opaque-Reference spec uses a trailing slash to mean + // "ref to the resource itself" (e.g. `/animations/0/` is a ref to the + // animation). Drop the trailing empty segment so we resolve to the + // accessor for the resource rather than descending into a non-existent + // empty-named child. + if (parts.length > 0 && parts[parts.length - 1] === "") { + parts.pop(); + } + //if the last part has ".length" in it, separate that as an extra part if (parts[parts.length - 1].includes(".length")) { const lastPart = parts[parts.length - 1]; @@ -73,13 +94,27 @@ export class GLTFPathToObjectConverter implements for (const part of parts) { const isLength = part === "length"; - if (isLength && !infoTree.__array__) { - throw new Error(`Path ${path} is invalid`); + if (isLength) { + // For .length, check if the current level has a 'length' accessor + if (infoTree.length) { + infoTree = infoTree.length; + } else if (infoTree.__array__) { + // Fallback: length of an array that doesn't have explicit length accessor + throw new Error(`Path ${path} is invalid - no length accessor`); + } else { + throw new Error(`Path ${path} is invalid`); + } + // Set target to the current object tree (the array itself) + // Only update target if objectTree is defined, otherwise keep the last valid target + if (objectTree !== undefined) { + target = objectTree; + } + continue; } if (infoTree.__ignoreObjectTree__) { ignoreObjectTree = true; } - if (infoTree.__array__ && !isLength) { + if (infoTree.__array__) { infoTree = infoTree.__array__; } else { infoTree = infoTree[part]; @@ -87,20 +122,43 @@ export class GLTFPathToObjectConverter implements throw new Error(`Path ${path} is invalid`); } } - if (!ignoreObjectTree) { + // __passThroughTarget__: skip objectTree traversal for this property. + // The accessor functions will receive the parent target (e.g., the INode) + // instead of the child property (e.g., node.weights which may be undefined). + if (infoTree.__passThroughTarget__) { + ignoreObjectTree = true; + } else if (!ignoreObjectTree) { if (objectTree === undefined) { // check if the path is in the exception list. If it is, break and return the last object that was found const exception = OptionalPathExceptionsList.find((e) => e.regex.test(path)); if (!exception) { throw new Error(`Path ${path} is invalid`); } - } else if (!isLength) { + } else { objectTree = objectTree?.[part]; } + } else { + // When ignoring object tree traversal and encountering a numeric array index, + // wrap the accessor functions to pass the index through as the second argument. + const numericIndex = parseInt(part); + if (!isNaN(numericIndex) && typeof infoTree.get === "function") { + const orig = infoTree; + infoTree = { ...orig }; + infoTree.get = (target: any) => orig.get(target, numericIndex); + if (typeof orig.set === "function") { + infoTree.set = (value: any, target: any) => orig.set(value, target, numericIndex); + } + if (typeof orig.getTarget === "function") { + infoTree.getTarget = (target: any) => orig.getTarget(target, numericIndex); + } + } } - if (infoTree.__target__ || isLength) { - target = objectTree; + if (infoTree.__target__) { + // Only update target if objectTree is defined, otherwise keep the last valid target + if (objectTree !== undefined) { + target = objectTree; + } } } diff --git a/packages/dev/loaders/src/glTF/2.0/Extensions/interactivityRefPathToObjectConverter.ts b/packages/dev/loaders/src/glTF/2.0/Extensions/interactivityRefPathToObjectConverter.ts new file mode 100644 index 000000000000..247f21afb102 --- /dev/null +++ b/packages/dev/loaders/src/glTF/2.0/Extensions/interactivityRefPathToObjectConverter.ts @@ -0,0 +1,75 @@ +import { type IObjectInfo, type IPathToObjectConverter } from "core/ObjectModel/objectModelInterfaces"; +import { type IObjectAccessor } from "core/FlowGraph/typeDefinitions"; +import { type FlowGraphContext } from "core/FlowGraph/flowGraphContext"; +import { FlowGraphInteger } from "core/FlowGraph/CustomTypes/flowGraphInteger"; +import { EventReferencePrefix } from "core/FlowGraph/flowGraphEventReference"; +import { DelayReferencePrefix, IsDelayActive } from "core/FlowGraph/flowGraphDelayReference"; + +/** + * Sentinel target returned by the validity accessors. The KHR_interactivity + * ref-validity pointers are not backed by a glTF object, so the accessor uses a + * non-null sentinel to satisfy callers that expect a truthy target. + */ +const RefValidityTarget = { isKhrInteractivityRef: true }; + +/** + * Path-to-object converter that resolves the KHR_interactivity ref-validity + * pointers `pointer/get` can query (KHR_interactivity spec §4.2.3 Event + * References and §4.2.4 Delay References): + * + * - `/extensions/KHR_interactivity/events/{}` — valid when the input reference + * was produced by an event operation (an event reference). Stateless: any + * event-reference path that reaches this converter is valid; a null ref is + * rejected earlier by the path-template substitution. + * - `/extensions/KHR_interactivity/delays/{}` — valid only while the referenced + * delay index is in the runtime active-delay set (i.e. the delay is scheduled + * and has not yet fired or been cancelled). This requires the runtime + * {@link FlowGraphContext}, which is supplied to the accessor `get` as its + * payload argument by `FlowGraphJsonPointerParserBlock`. + * + * On success `get` returns the input reference value (matching the spec, which + * sets the `value` output to the input reference); on failure it returns + * `undefined`, which the `pointer/get` block surfaces as `isValid = false`. + */ +export class InteractivityRefPathToObjectConverter implements IPathToObjectConverter { + /** + * @param path the (template-substituted) JSON Pointer to resolve + * @returns an object accessor whose `get` validates the reference + */ + public convert(path: string): IObjectInfo { + const normalized = path.endsWith("/") ? path.slice(0, -1) : path; + + if (normalized.startsWith(EventReferencePrefix)) { + const key = normalized.substring(EventReferencePrefix.length); + return { + object: RefValidityTarget, + info: { + type: "object", + isReadOnly: true, + // A non-empty key means a real event reference was supplied (the + // template substitution rejects null refs before we get here). + get: () => (key.length > 0 ? normalized : undefined), + getTarget: () => RefValidityTarget, + }, + }; + } + + // Delay reference: the substituted segment is the integer delay index. + const index = parseInt(normalized.substring(DelayReferencePrefix.length), 10); + return { + object: RefValidityTarget, + info: { + type: "object", + isReadOnly: true, + get: (_target: unknown, _index?: number, payload?: unknown) => { + const context = payload as FlowGraphContext | undefined; + if (!context || isNaN(index) || index < 0) { + return undefined; + } + return IsDelayActive(context, index) ? new FlowGraphInteger(index) : undefined; + }, + getTarget: () => RefValidityTarget, + }, + }; + } +} diff --git a/packages/dev/loaders/src/glTF/2.0/Extensions/objectModelMapping.ts b/packages/dev/loaders/src/glTF/2.0/Extensions/objectModelMapping.ts index ac28c473e4c9..1dd3bf4a3653 100644 --- a/packages/dev/loaders/src/glTF/2.0/Extensions/objectModelMapping.ts +++ b/packages/dev/loaders/src/glTF/2.0/Extensions/objectModelMapping.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { type TransformNode } from "core/Meshes/transformNode"; +import { type AbstractMesh } from "core/Meshes/abstractMesh"; +import { type MorphTargetManager } from "core/Morph/morphTargetManager"; import { type IAnimation, type ICamera, @@ -9,7 +11,10 @@ import { type IEXTLightsArea_Light, type IMaterial, type IMesh, + type IMeshPrimitive, type INode, + type IScene, + type ISkin, } from "../glTFLoaderInterfaces"; import { type Vector3, Matrix, Quaternion, Vector2 } from "core/Maths/math.vector.pure"; import { Constants } from "core/Engines/constants"; @@ -27,34 +32,42 @@ import { type Mesh } from "core/Meshes/mesh"; import { type RectAreaLight } from "core/Lights/rectAreaLight"; /** - * Describes the object model tree that maps glTF pointer paths to their corresponding Babylon objects. + * Top-level shape of the glTF Object Model accessor tree. Each property + * describes a navigable section of the JSON-Pointer namespace (e.g. `/nodes`, + * `/materials`, `/scenes`) that KHR_interactivity, KHR_animation_pointer and + * other extensions consume via {@link GetMappingForKey}. */ export interface IGLTFObjectModelTree { - /** Mapping for the glTF cameras collection. */ + /** Read-only accessor for the active scene index (`/scene`). */ + scene: { __target__: boolean } & IObjectAccessor; + /** Accessor tree for `/cameras`. */ cameras: IGLTFObjectModelTreeCamerasObject; - /** Mapping for the glTF nodes collection. */ + /** Accessor tree for `/nodes`. */ nodes: IGLTFObjectModelTreeNodesObject; - /** Mapping for the glTF materials collection. */ + /** Accessor tree for `/materials`. */ materials: IGLTFObjectModelTreeMaterialsObject; - /** Mapping for the glTF extensions. */ + /** Accessor tree for `/extensions` (root-level glTF extensions). */ extensions: IGLTFObjectModelTreeExtensionsObject; - /** Mapping for the glTF animations collection. */ + /** Accessor tree for `/animations`. */ animations: { length: IObjectAccessor; __array__: {}; }; - /** Mapping for the glTF meshes collection. */ - meshes: { - length: IObjectAccessor; - __array__: {}; - }; + /** Accessor tree for `/meshes`. */ + meshes: IGLTFObjectModelTreeMeshesObject; + /** Accessor tree for `/scenes`. */ + scenes: IGLTFObjectModelTreeScenesObject; + /** Accessor tree for `/skins`. */ + skins: IGLTFObjectModelTreeSkinsObject; } /** - * Describes the mapping of glTF node properties to their corresponding Babylon transform node properties. + * Accessor tree describing the `/nodes` section of the glTF Object Model. + * Exposes per-node TRS, ref-typed parent/children/camera/mesh/skin links, + * morph-target weights and node-extension properties. */ export interface IGLTFObjectModelTreeNodesObject { - /** Accessor for the number of nodes. */ + /** Number of nodes in the array. */ length: IObjectAccessor; __array__: { __target__: boolean; @@ -63,9 +76,22 @@ export interface IGLTFObjectModelTreeNodesObject; matrix: IObjectAccessor; globalMatrix: IObjectAccessor; + camera: IObjectAccessor; + mesh: IObjectAccessor; + skin: IObjectAccessor; + parent: IObjectAccessor; + children: { + length: IObjectAccessor; + __array__: { __target__: boolean } & IObjectAccessor; + }; weights: { + /** When true, the path converter skips objectTree traversal for this property, keeping the parent target. */ + __passThroughTarget__?: boolean; length: IObjectAccessor; - __array__: { __target__: boolean } & IObjectAccessor; + // The per-element target alternates between TransformNode (when the index is + // out of range or no morph host is reachable) and MorphTarget (when it is), + // so we widen the BabylonTargetType to `any` here. + __array__: { __target__: boolean } & IObjectAccessor; } & IObjectAccessor; extensions: { EXT_lights_ies?: { @@ -80,9 +106,12 @@ export interface IGLTFObjectModelTreeNodesObject; __array__: { __target__: boolean; orthographic: { @@ -101,11 +130,16 @@ export interface IGLTFObjectModelTreeCamerasObject { } /** - * Describes the mapping of glTF material properties to their corresponding Babylon material properties. + * Accessor tree describing the `/materials` section of the glTF Object Model. + * Covers core PBR properties as well as the family of KHR_materials_* extensions. */ export interface IGLTFObjectModelTreeMaterialsObject { + /** Number of materials in the array. */ + length: IObjectAccessor; __array__: { __target__: boolean; + doubleSided: IObjectAccessor; + alphaCutoff: IObjectAccessor; pbrMetallicRoughness: { baseColorFactor: IObjectAccessor; metallicFactor: IObjectAccessor>; @@ -265,15 +299,69 @@ interface ITextureDefinition { } /** - * Describes the mapping of glTF mesh properties to their corresponding Babylon mesh properties. + * Accessor tree describing the `/meshes` section of the glTF Object Model. + * Exposes per-mesh primitives (and their material refs) and the mesh-level + * morph-target weights array. */ -export interface IGLTFObjectModelTreeMeshesObject {} +export interface IGLTFObjectModelTreeMeshesObject { + /** Number of meshes in the array. */ + length: IObjectAccessor; + __array__: { + __target__: boolean; + primitives: { + length: IObjectAccessor; + __array__: { + __target__: boolean; + material: IObjectAccessor; + }; + }; + weights: { + length: IObjectAccessor; + __array__: { __target__: boolean } & IObjectAccessor; + }; + }; +} /** - * Describes the mapping of glTF extension properties to their corresponding Babylon properties. + * Accessor tree describing the `/scenes` section of the glTF Object Model. + * Per-scene root-node refs are exposed under `nodes/{i}`. + */ +export interface IGLTFObjectModelTreeScenesObject { + /** Number of scenes in the array. */ + length: IObjectAccessor; + __array__: { + __target__: boolean; + nodes: { + length: IObjectAccessor; + __array__: { __target__: boolean } & IObjectAccessor; + }; + }; +} + +/** + * Accessor tree describing the `/skins` section of the glTF Object Model. + * Joint and skeleton properties are exposed as JSON-Pointer refs. + */ +export interface IGLTFObjectModelTreeSkinsObject { + /** Number of skins in the array. */ + length: IObjectAccessor; + __array__: { + __target__: boolean; + joints: { + length: IObjectAccessor; + __array__: { __target__: boolean } & IObjectAccessor; + }; + skeleton: IObjectAccessor; + }; +} + +/** + * Accessor tree describing root-level glTF extensions exposed through the + * Object Model. Currently covers the punctual / area / IES / image-based + * light extension families. */ export interface IGLTFObjectModelTreeExtensionsObject { - /** Mapping for the KHR_lights_punctual extension. */ + /** Accessor tree for `/extensions/KHR_lights_punctual`. */ KHR_lights_punctual: { lights: { length: IObjectAccessor; @@ -289,7 +377,7 @@ export interface IGLTFObjectModelTreeExtensionsObject { }; }; }; - /** Mapping for the EXT_lights_area extension. */ + /** Accessor tree for `/extensions/EXT_lights_area`. */ EXT_lights_area: { lights: { length: IObjectAccessor; @@ -304,13 +392,13 @@ export interface IGLTFObjectModelTreeExtensionsObject { }; }; }; - /** Mapping for the EXT_lights_ies extension. */ + /** Accessor tree for `/extensions/EXT_lights_ies`. */ EXT_lights_ies: { lights: { length: IObjectAccessor; }; }; - /** Mapping for the EXT_lights_image_based extension. */ + /** Accessor tree for `/extensions/EXT_lights_image_based`. */ EXT_lights_image_based: { lights: { __array__: { @@ -354,24 +442,74 @@ const nodesTree: IGLTFObjectModelTreeNodesObject = { getPropertyName: [() => "scaling"], }, weights: { + // Skip glTF objectTree traversal — weights may be undefined on the glTF node + // but accessible via the Babylon MorphTargetManager on the INode's meshes + __passThroughTarget__: true, length: { type: "number", - get: (node: INode) => node._numMorphTargets, - getTarget: (node: INode) => node._babylonTransformNode, - getPropertyName: [() => "influence"], + get: (node: INode) => { + // KHR_interactivity treats /nodes//weights.length as always-valid. + // Resolve via Babylon scene graph (direct or descendant) and fall back + // to 0 when nothing is reachable so the pointer/get reports isValid=true. + const found = _findNodeMorphTargets(node); + return found ? found.mtm.numTargets : 0; + }, + getTarget: (node: INode) => node?._babylonTransformNode, + getPropertyName: [() => "length"], }, __array__: { __target__: true, type: "number", - get: (node: INode, index?: number) => (index !== undefined ? node._primitiveBabylonMeshes?.[0].morphTargetManager?.getTarget(index).influence : undefined), - // set: (value: number, node: INode, index?: number) => node._babylonTransformNode?.getMorphTargetManager()?.getTarget(index)?.setInfluence(value), - getTarget: (node: INode) => node._babylonTransformNode, + get: (node: INode, index?: number) => { + const found = _findNodeMorphTargets(node); + if (found && index !== undefined && index >= 0 && index < found.mtm.numTargets) { + return _roundFloat32Artifact(found.mtm.getTarget(index).influence); + } + // KHR_interactivity treats /nodes//weights/ as valid (returning the + // type's default of 0) when the node has its own mesh or has reachable + // morph targets, and as invalid only when no mesh is reachable at all. + if (node?.mesh !== undefined || found) { + return 0; + } + return undefined; + }, + set: (value: any, node: INode, index?: number) => { + const numValue = typeof value === "number" ? value : typeof value?.value === "number" ? value.value : value; + const found = _findNodeMorphTargets(node); + if (!found || index === undefined || index < 0 || index >= found.mtm.numTargets) { + return; + } + // Fan out to every mesh that shares this morph target manager so + // multi-primitive meshes stay in sync. + for (const mesh of found.meshes) { + const target = mesh.morphTargetManager?.getTarget(index); + if (target) { + target.influence = numValue; + } + } + }, + getTarget: (node: INode, index?: number) => { + const found = _findNodeMorphTargets(node); + if (found && index !== undefined && index >= 0 && index < found.mtm.numTargets) { + return found.mtm.getTarget(index); + } + return node?._babylonTransformNode; + }, getPropertyName: [() => "influence"], }, type: "number[]", - get: (node: INode, index?: number) => [0], // TODO: get the weights correctly - // set: (value: number, node: INode, index?: number) => node._babylonTransformNode?.getMorphTargetManager()?.getTarget(index)?.setInfluence(value), - getTarget: (node: INode) => node._babylonTransformNode, + get: (node: INode) => { + const found = _findNodeMorphTargets(node); + if (!found) { + return []; + } + const weights: number[] = []; + for (let i = 0; i < found.mtm.numTargets; i++) { + weights.push(_roundFloat32Artifact(found.mtm.getTarget(i).influence)); + } + return weights; + }, + getTarget: (node: INode) => node?._babylonTransformNode, getPropertyName: [() => "influence"], }, // readonly! @@ -407,6 +545,51 @@ const nodesTree: IGLTFObjectModelTreeNodesObject = { getTarget: (node: INode) => node._babylonTransformNode, isReadOnly: true, }, + camera: { + type: "string", + // Per KHR_interactivity Object Model: read-only ref pointing to the + // attached camera, encoded as a JSON Pointer string. Empty string + // when no camera is attached (the spec's null-ref convention). + get: (node: INode) => (node.camera !== undefined ? `/cameras/${node.camera}/` : ""), + getTarget: (node: INode) => node, + isReadOnly: true, + }, + mesh: { + type: "string", + get: (node: INode) => (node.mesh !== undefined ? `/meshes/${node.mesh}/` : ""), + getTarget: (node: INode) => node, + isReadOnly: true, + }, + skin: { + type: "string", + get: (node: INode) => (node.skin !== undefined ? `/skins/${node.skin}/` : ""), + getTarget: (node: INode) => node, + isReadOnly: true, + }, + parent: { + type: "string", + get: (node: INode) => (node.parent && node.parent.index !== undefined ? `/nodes/${node.parent.index}/` : ""), + getTarget: (node: INode) => node, + isReadOnly: true, + }, + children: { + length: { + type: "number", + get: (children: number[]) => children?.length ?? 0, + getTarget: (children: number[]) => children ?? [], + getPropertyName: [() => "length"], + }, + __array__: { + __target__: true, + type: "string", + // The wrapping converter passes the indexed child value (an + // INode index) as `childIndex`; convert it to a JSON Pointer + // ref string so ref/eq comparisons work as authored. + get: (childIndex: any) => (typeof childIndex === "number" ? `/nodes/${childIndex}/` : ""), + getTarget: () => ({ __nodeIndex: true }), + isReadOnly: true, + }, + }, extensions: { EXT_lights_ies: { multiplier: { @@ -465,20 +648,74 @@ const animationsTree = { getTarget: (animations: IAnimation[]) => animations.map((animation) => animation._babylonAnimationGroup!), getPropertyName: [() => "length"], }, - __array__: {}, + __array__: { + // Indexed access to the animation. KHR_interactivity Opaque-Reference + // spec defines the trailing-slash form ``/animations//`` as a ref + // to the animation itself; we surface that here as a JSON-Pointer ref + // string so blocks like ``animation/start`` can consume it directly. + // Use the animation's own ``index`` property (populated by the loader's + // ArrayItem.Assign step) so the ref is resolved without needing a + // separate index payload from the path converter. + __target__: true, + type: "string", + get: (animation: IAnimation) => (animation && typeof animation.index === "number" ? `/animations/${animation.index}/` : ""), + getTarget: (animation: IAnimation) => animation._babylonAnimationGroup, + isReadOnly: true, + }, }; -const meshesTree = { +const meshesTree: IGLTFObjectModelTreeMeshesObject = { length: { type: "number", get: (meshes: IMesh[]) => meshes.length, getTarget: (meshes: IMesh[]) => meshes.map((mesh) => mesh.primitives[0]._instanceData?.babylonSourceMesh), getPropertyName: [() => "length"], }, - __array__: {}, + __array__: { + __target__: true, + primitives: { + length: { + type: "number", + get: (primitives: IMeshPrimitive[]) => primitives?.length ?? 0, + getTarget: (primitives: IMeshPrimitive[]) => primitives ?? [], + getPropertyName: [() => "length"], + }, + __array__: { + __target__: true, + material: { + type: "string", + // Read-only ref to the assigned material, JSON Pointer encoded. + get: (primitive: IMeshPrimitive) => (primitive.material !== undefined ? `/materials/${primitive.material}/` : ""), + getTarget: (primitive: IMeshPrimitive) => primitive, + isReadOnly: true, + }, + }, + }, + weights: { + length: { + type: "number", + get: (weights: number[]) => weights?.length ?? 0, + getTarget: (weights: number[]) => weights ?? [], + getPropertyName: [() => "length"], + }, + __array__: { + __target__: true, + type: "number", + get: (weightValue: any) => weightValue, + getTarget: () => ({ __weightValue: true }), + isReadOnly: true, + }, + }, + }, }; const camerasTree: IGLTFObjectModelTreeCamerasObject = { + length: { + type: "number", + get: (cameras: ICamera[]) => cameras.length, + getTarget: (cameras: ICamera[]) => cameras.map((camera) => camera._babylonCamera!), + getPropertyName: [() => "length"], + }, __array__: { __target__: true, orthographic: { @@ -577,8 +814,38 @@ const camerasTree: IGLTFObjectModelTreeCamerasObject = { }; const materialsTree: IGLTFObjectModelTreeMaterialsObject = { + length: { + type: "number", + get: (materials: IMaterial[]) => materials.length, + getTarget: (materials: IMaterial[]) => materials.map((material) => material._data?.[Constants.MATERIAL_TriangleFillMode]?.babylonMaterial as PBRMaterial), + getPropertyName: [() => "length"], + }, __array__: { __target__: true, + doubleSided: { + type: "boolean", + get: (material, index?, payload?) => !GetMaterial(material, index, payload)?.backFaceCulling, + set: (value: boolean, material, index?, payload?) => { + const mat = GetMaterial(material, index, payload); + if (mat) { + mat.backFaceCulling = !value; + } + }, + getTarget: (material, index?, payload?) => GetMaterial(material, index, payload), + getPropertyName: [() => "backFaceCulling"], + }, + alphaCutoff: { + type: "number", + get: (material, index?, payload?) => GetMaterial(material, index, payload)?.alphaCutOff, + set: (value: number, material, index?, payload?) => { + const mat = GetMaterial(material, index, payload); + if (mat) { + mat.alphaCutOff = value; + } + }, + getTarget: (material, index?, payload?) => GetMaterial(material, index, payload), + getPropertyName: [() => "alphaCutOff"], + }, emissiveFactor: { type: "Color3", get: (material, index?, payload?) => GetMaterial(material, index, payload).emissiveColor, @@ -689,9 +956,12 @@ const materialsTree: IGLTFObjectModelTreeMaterialsObject = { }, anisotropyRotation: { type: "number", - get: (material, index?, payload?) => GetMaterial(material, index, payload).anisotropy.angle, + get: (material, index?, payload?) => GetMaterial(material, index, payload)?.anisotropy?.angle, set: (value: number, material, index?, payload?) => { - GetMaterial(material, index, payload).anisotropy.angle = value; + const mat = GetMaterial(material, index, payload); + if (mat) { + mat.anisotropy.angle = value; + } }, getTarget: (material, index?, payload?) => GetMaterial(material, index, payload), getPropertyName: [() => "anisotropy.angle"], @@ -823,7 +1093,7 @@ const materialsTree: IGLTFObjectModelTreeMaterialsObject = { }, sheenRoughnessTexture: { extensions: { - KHR_texture_transform: GenerateTextureMap("sheen", "thicknessTexture"), + KHR_texture_transform: GenerateTextureMap("sheen", "textureRoughness"), }, }, }, @@ -863,7 +1133,10 @@ const materialsTree: IGLTFObjectModelTreeMaterialsObject = { }, transmissionTexture: { extensions: { - KHR_texture_transform: GenerateTextureMap("subSurface", "refractionIntensityTexture"), + KHR_texture_transform: GenerateTextureMap("subSurface", "refractionIntensityTexture", { + extensionKey: "KHR_materials_transmission", + texturePath: ["transmissionTexture"], + }), }, }, }, @@ -876,7 +1149,10 @@ const materialsTree: IGLTFObjectModelTreeMaterialsObject = { }, diffuseTransmissionTexture: { extensions: { - KHR_texture_transform: GenerateTextureMap("subSurface", "translucencyIntensityTexture"), + KHR_texture_transform: GenerateTextureMap("subSurface", "translucencyIntensityTexture", { + extensionKey: "KHR_materials_diffuse_transmission", + texturePath: ["diffuseTransmissionTexture"], + }), }, }, diffuseTransmissionColorFactor: { @@ -887,7 +1163,10 @@ const materialsTree: IGLTFObjectModelTreeMaterialsObject = { }, diffuseTransmissionColorTexture: { extensions: { - KHR_texture_transform: GenerateTextureMap("subSurface", "translucencyColorTexture"), + KHR_texture_transform: GenerateTextureMap("subSurface", "translucencyColorTexture", { + extensionKey: "KHR_materials_diffuse_transmission", + texturePath: ["diffuseTransmissionColorTexture"], + }), }, }, }, @@ -912,7 +1191,7 @@ const materialsTree: IGLTFObjectModelTreeMaterialsObject = { }, thicknessTexture: { extensions: { - KHR_texture_transform: GenerateTextureMap("subSurface", "thicknessTexture"), + KHR_texture_transform: GenerateTextureMap("subSurface", "thicknessTexture", { extensionKey: "KHR_materials_volume", texturePath: ["thicknessTexture"] }), }, }, }, @@ -1074,7 +1353,191 @@ function GetTexture(material: IMaterial, payload: any, textureType: keyof PBRMat function GetMaterial(material: IMaterial, _index?: number, payload?: any) { return material._data?.[payload?.fillMode ?? Constants.MATERIAL_TriangleFillMode]?.babylonMaterial as PBRMaterial; } -function GenerateTextureMap(textureType: keyof PBRMaterial, textureInObject?: string): ITextureDefinition { +function _getNodeMorphTargetManager(node: INode): any { + const tn = node?._babylonTransformNode; + if (!tn) { + return undefined; + } + // Single primitive: transform node IS the mesh with morphTargetManager + if ((tn as any).morphTargetManager) { + return (tn as any).morphTargetManager; + } + // Multiple primitives: check each primitive mesh and its source + const primMeshes = node._primitiveBabylonMeshes; + if (primMeshes) { + for (const mesh of primMeshes) { + if (mesh?.morphTargetManager) { + return mesh.morphTargetManager; + } + // Check source mesh for instanced meshes + if ((mesh as any)?.sourceMesh?.morphTargetManager) { + return (mesh as any).sourceMesh.morphTargetManager; + } + } + } + return undefined; +} + +/** + * Result of a morph-target lookup: the active manager plus every Babylon mesh + * that shares it, so writes (set) can fan out across all primitives. + */ +interface IMorphTargetLookup { + mtm: MorphTargetManager; + meshes: AbstractMesh[]; +} + +/** + * KHR_interactivity test assets routinely use a glTF hierarchy where the + * **parent** node has no `mesh` but a descendant does. The Khronos morph-weight + * tests query `/nodes//weights/*` and expect the result to come from + * the morph targets of the first descendant mesh. To support this we walk the + * Babylon-side scene graph below the queried INode looking for a Mesh that has + * a morphTargetManager. + * + * For multi-primitive meshes (one INode → several Babylon meshes parented to a + * wrapper TransformNode) we also collect the sibling primitives so a `set` + * touches every mesh that shares the manager. + * @param node the glTF node to start the lookup from + * @returns the active morph target manager and every Babylon mesh that shares + * it, or `undefined` when no morph target manager is reachable from the node. + */ +function _findNodeMorphTargets(node: INode): IMorphTargetLookup | undefined { + const tn = node?._babylonTransformNode; + if (!tn) { + return undefined; + } + // Direct: this node's own mesh has a morph target manager. + const directMtm: MorphTargetManager | undefined = _getNodeMorphTargetManager(node); + if (directMtm && node._primitiveBabylonMeshes && node._primitiveBabylonMeshes.length > 0) { + return { mtm: directMtm, meshes: node._primitiveBabylonMeshes as AbstractMesh[] }; + } + // Fallback: search descendants in the Babylon scene graph for the first + // mesh that has a morph target manager, then collect every sibling mesh + // that shares it (covers the multi-primitive case). + const descendants = tn.getDescendants(false); + for (const desc of descendants) { + const candidate = desc as AbstractMesh; + const mtm: MorphTargetManager | undefined = (candidate as any).morphTargetManager ?? (candidate as any).sourceMesh?.morphTargetManager; + if (!mtm) { + continue; + } + const meshes: AbstractMesh[] = []; + const parent = candidate.parent; + if (parent) { + for (const sib of parent.getChildMeshes(true)) { + const sibMtm: MorphTargetManager | undefined = (sib as any).morphTargetManager ?? (sib as any).sourceMesh?.morphTargetManager; + if (sibMtm === mtm) { + meshes.push(sib); + } + } + } + if (meshes.length === 0) { + meshes.push(candidate); + } + return { mtm, meshes }; + } + return undefined; +} + +/** + * Collapse float32-precision artifacts back to the closest "clean" double. + * + * glTF stores numbers as JSON, but tools usually serialize float32 morph weights + * with their full double-precision text — `0.1` becomes `0.10000000149011612`. + * KHR_interactivity tests then compare the read-back weight via strict `math/eq` + * against literals like `0.1`, which fails because the two doubles aren't bitwise + * equal. Rounding the value to 7 significant figures (the precision of a float32) + * recovers the original "clean" double for any value that survived a float32 + * round-trip while leaving genuinely high-precision doubles essentially intact. + * @param v the value to round + * @returns the rounded value, or the input unchanged if it is not finite + */ +function _roundFloat32Artifact(v: number): number { + if (!Number.isFinite(v)) { + return v; + } + return parseFloat(v.toPrecision(7)); +} +/** + * Coordinate where a Babylon `Texture` lives on a PBRMaterial vs where its + * KHR_texture_transform definition lives in the source glTF JSON. Used by + * {@link GenerateTextureMap} to provide a glTF-side fallback when the loader + * extension that owns the texture decided not to materialise it on the + * Babylon material (e.g. KHR_materials_volume early-returns when there is no + * `thicknessFactor`, leaving `subSurface.thicknessTexture` null even though + * the glTF JSON has the texture+transform fully defined). + * + * The fallback gives KHR_interactivity `pointer/get` and `pointer/set` a + * place to read/write the transform values so round-trip tests succeed even + * when the texture is not yet active in the renderer. If/when the loader + * later activates the texture, the seeded values can be picked up from the + * glTF JSON. + */ +interface IGltfTextureTransformPath { + /** + * The path of nested keys under `material.extensions.` to reach the + * texture-info object. For example `["thicknessTexture"]` for + * `KHR_materials_volume.thicknessTexture`. + */ + extensionKey: string; + texturePath: string[]; +} + +/** + * Read the KHR_texture_transform object stored in the source glTF JSON for a + * texture-info that lives under one of the material's extensions, creating + * empty parent objects on demand so callers can write through it. Returns + * `undefined` when the input shape is incompatible. + * @param material the source IMaterial owning the extension + * @param gltfPath the path describing where the texture-info lives + * @param createMissing when true, create missing parent objects so writes succeed + * @returns the glTF-side KHR_texture_transform object, or undefined + */ +function _gltfTextureTransform(material: IMaterial, gltfPath: IGltfTextureTransformPath, createMissing: boolean): any | undefined { + if (!material) { + return undefined; + } + if (createMissing && !material.extensions) { + (material as any).extensions = {}; + } + let cursor: any = material.extensions?.[gltfPath.extensionKey]; + if (!cursor) { + if (!createMissing) { + return undefined; + } + cursor = {}; + (material as any).extensions[gltfPath.extensionKey] = cursor; + } + for (const key of gltfPath.texturePath) { + let next = cursor[key]; + if (!next) { + if (!createMissing) { + return undefined; + } + next = {}; + cursor[key] = next; + } + cursor = next; + } + if (!cursor.extensions) { + if (!createMissing) { + return undefined; + } + cursor.extensions = {}; + } + let xform = cursor.extensions.KHR_texture_transform; + if (!xform) { + if (!createMissing) { + return undefined; + } + xform = {}; + cursor.extensions.KHR_texture_transform = xform; + } + return xform; +} + +function GenerateTextureMap(textureType: keyof PBRMaterial, textureInObject?: string, gltfPath?: IGltfTextureTransformPath): ITextureDefinition { return { offset: { componentsCount: 2, @@ -1082,12 +1545,29 @@ function GenerateTextureMap(textureType: keyof PBRMaterial, textureInObject?: st type: "Vector2", get: (material, _index?, payload?) => { const texture = GetTexture(material, payload, textureType, textureInObject); - return new Vector2(texture?.uOffset, texture?.vOffset); + if (texture) { + return new Vector2(texture.uOffset, texture.vOffset); + } + if (gltfPath) { + const xform = _gltfTextureTransform(material, gltfPath, false); + const o = xform?.offset; + return new Vector2(o?.[0] ?? 0, o?.[1] ?? 0); + } + return new Vector2(0, 0); }, getTarget: GetMaterial, set: (value, material, _index?, payload?) => { const texture = GetTexture(material, payload, textureType, textureInObject); - ((texture.uOffset = value.x), (texture.vOffset = value.y)); + if (texture) { + texture.uOffset = value.x; + texture.vOffset = value.y; + } + if (gltfPath) { + const xform = _gltfTextureTransform(material, gltfPath, true); + if (xform) { + xform.offset = [value.x, value.y]; + } + } }, getPropertyName: [ () => `${textureType}${textureInObject ? "." + textureInObject : ""}.uOffset`, @@ -1096,9 +1576,30 @@ function GenerateTextureMap(textureType: keyof PBRMaterial, textureInObject?: st }, rotation: { type: "number", - get: (material, _index?, payload?) => GetTexture(material, payload, textureType, textureInObject)?.wAng, + get: (material, _index?, payload?) => { + const texture = GetTexture(material, payload, textureType, textureInObject); + if (texture) { + return texture.wAng; + } + if (gltfPath) { + const xform = _gltfTextureTransform(material, gltfPath, false); + return xform?.rotation ?? 0; + } + return 0; + }, getTarget: GetMaterial, - set: (value, material, _index?, payload?) => (GetTexture(material, payload, textureType, textureInObject).wAng = value), + set: (value, material, _index?, payload?) => { + const texture = GetTexture(material, payload, textureType, textureInObject); + if (texture) { + texture.wAng = value; + } + if (gltfPath) { + const xform = _gltfTextureTransform(material, gltfPath, true); + if (xform) { + xform.rotation = value; + } + } + }, getPropertyName: [() => `${textureType}${textureInObject ? "." + textureInObject : ""}.wAng`], }, scale: { @@ -1106,12 +1607,29 @@ function GenerateTextureMap(textureType: keyof PBRMaterial, textureInObject?: st type: "Vector2", get: (material, _index?, payload?) => { const texture = GetTexture(material, payload, textureType, textureInObject); - return new Vector2(texture?.uScale, texture?.vScale); + if (texture) { + return new Vector2(texture.uScale, texture.vScale); + } + if (gltfPath) { + const xform = _gltfTextureTransform(material, gltfPath, false); + const s = xform?.scale; + return new Vector2(s?.[0] ?? 1, s?.[1] ?? 1); + } + return new Vector2(1, 1); }, getTarget: GetMaterial, set: (value, material, index?, payload?) => { const texture = GetTexture(material, payload, textureType, textureInObject); - ((texture.uScale = value.x), (texture.vScale = value.y)); + if (texture) { + texture.uScale = value.x; + texture.vScale = value.y; + } + if (gltfPath) { + const xform = _gltfTextureTransform(material, gltfPath, true); + if (xform) { + xform.scale = [value.x, value.y]; + } + } }, getPropertyName: [ () => `${textureType}${textureInObject ? "." + textureInObject : ""}.uScale`, @@ -1121,13 +1639,91 @@ function GenerateTextureMap(textureType: keyof PBRMaterial, textureInObject?: st }; } +const scenesTree: IGLTFObjectModelTreeScenesObject = { + length: { + type: "number", + get: (scenes: IScene[]) => scenes.length, + getTarget: (scenes: IScene[]) => scenes, + getPropertyName: [() => "length"], + }, + __array__: { + __target__: true, + nodes: { + length: { + type: "number", + get: (nodes: number[]) => nodes?.length ?? 0, + getTarget: (nodes: number[]) => nodes ?? [], + getPropertyName: [() => "length"], + }, + __array__: { + __target__: true, + type: "string", + // Indexed scene root: the underlying value is the INode index; + // KHR_interactivity expects a ref-typed JSON Pointer string. + get: (nodeIndex: any) => (typeof nodeIndex === "number" ? `/nodes/${nodeIndex}/` : ""), + getTarget: () => ({ __nodeIndex: true }), + isReadOnly: true, + }, + }, + }, +}; + +const skinsTree: IGLTFObjectModelTreeSkinsObject = { + length: { + type: "number", + get: (skins: ISkin[]) => skins.length, + getTarget: (skins: ISkin[]) => skins.map((skin) => skin._data?.babylonSkeleton), + getPropertyName: [() => "length"], + }, + __array__: { + __target__: true, + joints: { + length: { + type: "number", + get: (joints: number[]) => joints?.length ?? 0, + getTarget: (joints: number[]) => joints ?? [], + getPropertyName: [() => "length"], + }, + __array__: { + __target__: true, + type: "string", + // Indexed skin joint: returns a ref to the joint node. + get: (jointIndex: any) => (typeof jointIndex === "number" ? `/nodes/${jointIndex}/` : ""), + getTarget: () => ({ __nodeIndex: true }), + isReadOnly: true, + }, + }, + skeleton: { + type: "string", + // Skin's skeleton root: returns a ref to the root node, or empty + // (null ref) when no skeleton root is declared. + get: (skin: ISkin) => { + const skeleton = (skin as any).skeleton; + return typeof skeleton === "number" ? `/nodes/${skeleton}/` : ""; + }, + getTarget: (skin: ISkin) => skin, + isReadOnly: true, + }, + }, +}; + const objectModelMapping: IGLTFObjectModelTree = { + scene: { + __target__: true, + type: "number", + get: (sceneIndex: any) => sceneIndex ?? 0, + getTarget: () => ({ __gltfRoot: true }), + isReadOnly: true, + getPropertyName: [() => "scene"], + }, cameras: camerasTree, nodes: nodesTree, materials: materialsTree, extensions: extensionsTree, animations: animationsTree, meshes: meshesTree, + scenes: scenesTree, + skins: skinsTree, }; /** diff --git a/packages/dev/loaders/test/unit/Interactivity/event nodes.test.ts b/packages/dev/loaders/test/unit/Interactivity/event nodes.test.ts index c32429ba9acc..7d579c8f1aad 100644 --- a/packages/dev/loaders/test/unit/Interactivity/event nodes.test.ts +++ b/packages/dev/loaders/test/unit/Interactivity/event nodes.test.ts @@ -2,6 +2,9 @@ import { NullEngine } from "core/Engines"; import { Scene } from "core/scene"; import { FlowGraphCoordinator } from "core/FlowGraph/flowGraphCoordinator"; import { Vector3 } from "core/Maths"; +import { Mesh } from "core/Meshes"; +import { PickingInfo } from "core/Collisions"; +import { PointerEventTypes, PointerInfo } from "core/Events"; import { ArcRotateCamera } from "core/Cameras/arcRotateCamera"; import { Logger } from "core/Misc"; import { ParseFlowGraphAsync } from "core/FlowGraph"; @@ -9,6 +12,7 @@ import { InteractivityGraphToFlowGraphParser } from "loaders/glTF/2.0/Extensions import "loaders/glTF/2.0/glTFLoaderAnimation"; import "loaders/glTF/2.0/Extensions/KHR_animation_pointer.data"; import "loaders/glTF/2.0/Extensions/KHR_interactivity"; +import "loaders/glTF/2.0/Extensions/KHR_node_selectability"; import { GetPathToObjectConverter } from "loaders/glTF/2.0/Extensions/objectModelMapping"; import { IKHRInteractivity_Declaration, @@ -108,6 +112,88 @@ describe("Interactivity event nodes", () => { ).rejects.toThrow(); }); + it("event/onSelect fires the flow when its configured node mesh is picked", async () => { + // The node at glTF index 0 is backed by this mesh; picking it must fire the event. + const mesh = new Mesh("selectMe", scene); + const gltf: any = { nodes: [{ _babylonTransformNode: mesh }] }; + + const ig: IKHRInteractivity_Graph = { + declarations: [ + { op: "event/onSelect", extension: "KHR_node_selectability" }, + { op: "flow/log", extension: "BABYLON" }, + ], + types: [{ signature: "int" }], + nodes: [ + { + declaration: 0, + configuration: { nodeIndex: { value: [0] } }, + flows: { out: { node: 1, socket: "in" } }, + }, + { declaration: 1, values: { message: { type: 0, value: [42] } } }, + ], + variables: [], + events: [], + }; + + const i2fg = new InteractivityGraphToFlowGraphParser(ig, gltf); + const json = i2fg.serializeToFlowGraph(); + const coordinator = new FlowGraphCoordinator({ scene }); + const localPathConverter = GetPathToObjectConverter(gltf); + await ParseFlowGraphAsync(json, { coordinator, pathConverter: localPathConverter }); + coordinator.start(); + + // Not fired before any pick. + expect(log).not.toHaveBeenCalledWith({ value: 42 }); + + // Simulate clicking the mesh. + const pickInfo = new PickingInfo(); + pickInfo.hit = true; + pickInfo.pickedMesh = mesh; + pickInfo.pickedPoint = new Vector3(); + scene.onPointerObservable.notifyObservers(new PointerInfo(PointerEventTypes.POINTERPICK, {} as any, pickInfo)); + + expect(log).toHaveBeenCalledWith({ value: 42 }); + }); + + it("event/onSelect does not fire when a different mesh is picked", async () => { + const target = new Mesh("target", scene); + const other = new Mesh("other", scene); + const gltf: any = { nodes: [{ _babylonTransformNode: target }] }; + + const ig: IKHRInteractivity_Graph = { + declarations: [ + { op: "event/onSelect", extension: "KHR_node_selectability" }, + { op: "flow/log", extension: "BABYLON" }, + ], + types: [{ signature: "int" }], + nodes: [ + { + declaration: 0, + configuration: { nodeIndex: { value: [0] } }, + flows: { out: { node: 1, socket: "in" } }, + }, + { declaration: 1, values: { message: { type: 0, value: [7] } } }, + ], + variables: [], + events: [], + }; + + const i2fg = new InteractivityGraphToFlowGraphParser(ig, gltf); + const json = i2fg.serializeToFlowGraph(); + const coordinator = new FlowGraphCoordinator({ scene }); + const localPathConverter = GetPathToObjectConverter(gltf); + await ParseFlowGraphAsync(json, { coordinator, pathConverter: localPathConverter }); + coordinator.start(); + + const pickInfo = new PickingInfo(); + pickInfo.hit = true; + pickInfo.pickedMesh = other; + pickInfo.pickedPoint = new Vector3(); + scene.onPointerObservable.notifyObservers(new PointerInfo(PointerEventTypes.POINTERPICK, {} as any, pickInfo)); + + expect(log).not.toHaveBeenCalledWith({ value: 7 }); + }); + it("should send an event with id", async () => { await generateSimpleNodeGraph( [{ op: "event/send" }, { op: "event/receive" }, { op: "flow/log", extension: "BABYLON" }], diff --git a/packages/dev/loaders/test/unit/Interactivity/interactivity.math nodes.test.ts b/packages/dev/loaders/test/unit/Interactivity/interactivity.math nodes.test.ts index 71f5dd6fcc3c..788b6245a849 100644 --- a/packages/dev/loaders/test/unit/Interactivity/interactivity.math nodes.test.ts +++ b/packages/dev/loaders/test/unit/Interactivity/interactivity.math nodes.test.ts @@ -4,9 +4,9 @@ import { NullEngine } from "core/Engines/nullEngine"; import { PerformanceConfigurator } from "core/Engines/performanceConfigurator"; import { FlowGraphCoordinator } from "core/FlowGraph/flowGraphCoordinator"; import { FlowGraphAction } from "core/FlowGraph/flowGraphLogger"; -import { GetAngleBetweenQuaternions, GetQuaternionFromDirections } from "core/FlowGraph/flowGraphMath"; +import { GetAngleBetweenQuaternions, GetQuaternionFromDirections, GetQuaternionFromUpForward, GetVector2Slerp, GetVector3Slerp } from "core/FlowGraph/flowGraphMath"; import { ParseFlowGraphAsync } from "core/FlowGraph/flowGraphParser"; -import { Quaternion, Vector3 } from "core/Maths/math.vector"; +import { Quaternion, Vector2, Vector3 } from "core/Maths/math.vector"; import { Logger } from "core/Misc/logger"; import { Scene } from "core/scene"; import { InteractivityGraphToFlowGraphParser } from "loaders/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser"; @@ -954,11 +954,13 @@ describe("Interactivity math nodes", () => { expect(logItem).toBeDefined(); // round result to 3 decimals const resultArray = roundArray3(logItem!.payload.value.asArray()); + // float4x4 values are column-major (like Babylon's Matrix storage), so the transform reads down the columns: + // value[i] = sum_j matrix[j * 4 + i] * a[j]. const expected = roundArray3([ - 1 * randomMatrix[0] + 1 * randomMatrix[1] + 1 * randomMatrix[2] + 1 * randomMatrix[3], - 1 * randomMatrix[4] + 1 * randomMatrix[5] + 1 * randomMatrix[6] + 1 * randomMatrix[7], - 1 * randomMatrix[8] + 1 * randomMatrix[9] + 1 * randomMatrix[10] + 1 * randomMatrix[11], - 1 * randomMatrix[12] + 1 * randomMatrix[13] + 1 * randomMatrix[14] + 1 * randomMatrix[15], + 1 * randomMatrix[0] + 1 * randomMatrix[4] + 1 * randomMatrix[8] + 1 * randomMatrix[12], + 1 * randomMatrix[1] + 1 * randomMatrix[5] + 1 * randomMatrix[9] + 1 * randomMatrix[13], + 1 * randomMatrix[2] + 1 * randomMatrix[6] + 1 * randomMatrix[10] + 1 * randomMatrix[14], + 1 * randomMatrix[3] + 1 * randomMatrix[7] + 1 * randomMatrix[11] + 1 * randomMatrix[15], ]); expect(resultArray).toEqual(expected); }); @@ -2091,4 +2093,299 @@ describe("Interactivity math nodes", () => { const expected = roundArray3(GetQuaternionFromDirections(randomDirection1, randomDirection2).asArray()); expect(resultArray).toEqual(expected); }); + + // math/slerp (vector spherical linear interpolation) + + it("should use math/slerp correctly - float2", async () => { + const a = [2, 5]; + const b = [4, 6]; + const c = 0.5; + const graph = await generateSimpleNodeGraph( + [{ op: "math/slerp" }], + [ + { + declaration: 0, + values: { + a: { type: 0, value: a }, + b: { type: 0, value: b }, + c: { type: 1, value: [c] }, + }, + }, + ], + [{ signature: "float2" }, { signature: "float" }] + ); + const logItem = graph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop(); + expect(logItem).toBeDefined(); + const resultArray = roundArray3(logItem!.payload.value.asArray()); + const expected = roundArray3(GetVector2Slerp(new Vector2(a[0], a[1]), new Vector2(b[0], b[1]), c).asArray()); + expect(resultArray).toEqual(expected); + }); + + it("should use math/slerp correctly - float3", async () => { + const a = [2, 5, 7]; + const b = [4, 6, 8]; + const c = 0.5; + const graph = await generateSimpleNodeGraph( + [{ op: "math/slerp" }], + [ + { + declaration: 0, + values: { + a: { type: 0, value: a }, + b: { type: 0, value: b }, + c: { type: 1, value: [c] }, + }, + }, + ], + [{ signature: "float3" }, { signature: "float" }] + ); + const logItem = graph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop(); + expect(logItem).toBeDefined(); + const resultArray = roundArray3(logItem!.payload.value.asArray()); + const expected = roundArray3(GetVector3Slerp(new Vector3(a[0], a[1], a[2]), new Vector3(b[0], b[1], b[2]), c).asArray()); + expect(resultArray).toEqual(expected); + }); + + // math/quatFromUpForward + + it("should use math/quatFromUpForward correctly", async () => { + const up = [0, 1, 0]; + const forward = [0, 0, 1]; + const graph = await generateSimpleNodeGraph( + [{ op: "math/quatFromUpForward" }], + [ + { + declaration: 0, + values: { + up: { type: 0, value: up }, + forward: { type: 0, value: forward }, + }, + }, + ], + [{ signature: "float3" }] + ); + const logItem = graph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop(); + expect(logItem).toBeDefined(); + const resultArray = roundArray3(logItem!.payload.value.asArray()); + const expected = roundArray3(GetQuaternionFromUpForward(new Vector3(up[0], up[1], up[2]), new Vector3(forward[0], forward[1], forward[2])).asArray()); + expect(resultArray).toEqual(expected); + // up=(0,1,0), forward=(0,0,1) is the identity orientation. + expect(resultArray).toEqual([0, 0, 0, 1]); + }); + + // math/combine4x4 uses column-major input ordering (matching Babylon's column-major Matrix storage) + + it("should use math/combine4x4 with column-major ordering", async () => { + const inputKeys = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p"]; + const values: any = {}; + inputKeys.forEach((key, index) => { + values[key] = { type: 0, value: [index] }; + }); + const graph = await generateSimpleNodeGraph( + [{ op: "math/combine4x4" }], + [ + { + declaration: 0, + values, + }, + ], + [{ signature: "float" }] + ); + const logItem = graph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop(); + expect(logItem).toBeDefined(); + // Column-major inputs map directly onto the matrix storage. + expect(Array.from(logItem!.payload.value.m)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]); + }); + + it("should use math/combine2x2 with column-major ordering", async () => { + const graph = await generateSimpleNodeGraph( + [{ op: "math/combine2x2" }], + [ + { + declaration: 0, + values: { + a: { type: 0, value: [0] }, + b: { type: 0, value: [1] }, + c: { type: 0, value: [2] }, + d: { type: 0, value: [3] }, + }, + }, + ], + [{ signature: "float" }] + ); + const logItem = graph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop(); + expect(logItem).toBeDefined(); + // Column-major inputs are stored directly, so math/extract2x2 reads them back in the same order. + expect(Array.from(logItem!.payload.value.m)).toEqual([0, 1, 2, 3]); + }); + + it("should use math/combine3x3 with column-major ordering", async () => { + const inputKeys = ["a", "b", "c", "d", "e", "f", "g", "h", "i"]; + const values: any = {}; + inputKeys.forEach((key, index) => { + values[key] = { type: 0, value: [index] }; + }); + const graph = await generateSimpleNodeGraph( + [{ op: "math/combine3x3" }], + [ + { + declaration: 0, + values, + }, + ], + [{ signature: "float" }] + ); + const logItem = graph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop(); + expect(logItem).toBeDefined(); + expect(Array.from(logItem!.payload.value.m)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]); + }); + + // math/smoothStep returns the smooth-step coefficient saturate((c-min(a,b))/|b-a|)^2 * (3-2t), NOT an interpolation between a and b + + it("should use math/smoothStep correctly - float3 with non-unit edges", async () => { + const graph = await generateSimpleNodeGraph( + [{ op: "math/smoothStep" }], + [ + { + declaration: 0, + values: { + a: { type: 0, value: [0, 0, 0] }, + b: { type: 0, value: [1, 2, 4] }, + c: { type: 0, value: [0.5, 1, 3] }, + }, + }, + ], + [{ signature: "float3" }] + ); + const logItem = graph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop(); + expect(logItem).toBeDefined(); + // t = (0.5, 0.5, 0.75) -> t*t*(3-2t) = (0.5, 0.5, 0.84375) + expect(roundArray3(logItem!.payload.value.asArray())).toEqual([0.5, 0.5, 0.844]); + }); + + it("should use math/smoothStep correctly with reversed edges", async () => { + const graph = await generateSimpleNodeGraph( + [{ op: "math/smoothStep" }], + [ + { + declaration: 0, + values: { + // a > b: uses min(a,b) and |b-a| + a: { type: 0, value: [1] }, + b: { type: 0, value: [0] }, + c: { type: 0, value: [0.25] }, + }, + }, + ], + [{ signature: "float" }, { signature: "float" }] + ); + const logItem = graph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop(); + expect(logItem).toBeDefined(); + // t = saturate((0.25 - 0) / 1) = 0.25 -> 0.25*0.25*(3-0.5) = 0.15625 + expect(round3(logItem!.payload.value)).toEqual(0.156); + }); + + it("should report math/inverse isValid = false for a singular matrix", async () => { + // A matrix with a zero column has determinant 0 and is not invertible. + const singular = [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const graph = await generateSimpleNodeGraph( + [{ op: "math/inverse" }], + [ + { + declaration: 0, + values: { + a: { type: 0, value: singular }, + }, + }, + ], + [{ signature: "float4x4" }], + 0, + "isValid" + ); + const logItem = graph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop(); + expect(logItem?.payload.value).toBe(false); + }); + + // math/normalize reports isValid = false for a zero-length vector + + it("should report math/normalize isValid = false for a zero vector", async () => { + const graph = await generateSimpleNodeGraph( + [{ op: "math/normalize" }], + [ + { + declaration: 0, + values: { + a: { type: 0, value: [0, 0, 0] }, + }, + }, + ], + [{ signature: "float3" }], + 0, + "isValid" + ); + const logItem = graph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop(); + expect(logItem?.payload.value).toBe(false); + }); + + // math/matDecompose ignores the fourth row of the matrix + + it("should ignore the fourth row in math/matDecompose", async () => { + // Column-major matrix: scale 2, identity rotation, translation (1,2,3) and a non-standard fourth row (5,6,7,8). + const matrix = [2, 0, 0, 5, 0, 2, 0, 6, 0, 0, 2, 7, 1, 2, 3, 8]; + const isValidGraph = await generateSimpleNodeGraph( + [{ op: "math/matDecompose" }], + [{ declaration: 0, values: { a: { type: 0, value: matrix } } }], + [{ signature: "float4x4" }], + 0, + "isValid" + ); + expect(isValidGraph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop()?.payload.value).toBe(true); + + const translationGraph = await generateSimpleNodeGraph( + [{ op: "math/matDecompose" }], + [{ declaration: 0, values: { a: { type: 0, value: matrix } } }], + [{ signature: "float4x4" }], + 0, + "translation" + ); + expect(roundArray3(translationGraph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop()!.payload.value.asArray())).toEqual([1, 2, 3]); + + const scaleGraph = await generateSimpleNodeGraph( + [{ op: "math/matDecompose" }], + [{ declaration: 0, values: { a: { type: 0, value: matrix } } }], + [{ signature: "float4x4" }], + 0, + "scaling" + ); + expect(roundArray3(scaleGraph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop()!.payload.value.asArray())).toEqual([2, 2, 2]); + }); + + // math/eq compares a quaternion against a float4 value (KHR_interactivity has no dedicated quaternion type) + + it("should compare a quaternion and a float4 with math/eq", async () => { + const graph = await generateSimpleNodeGraph( + [{ op: "math/eq" }, { op: "math/quatFromAxisAngle" }], + [ + { + declaration: 0, + values: { + // quaternion produced by quatFromAxisAngle (identity for a zero angle) + a: { node: 1, socket: "value" }, + // compared against a float4 literal + b: { type: 1, value: [0, 0, 0, 1] }, + }, + }, + { + declaration: 1, + values: { + axis: { type: 0, value: [0, 0, 1] }, + angle: { type: 2, value: [0] }, + }, + }, + ], + [{ signature: "float3" }, { signature: "float4" }, { signature: "float" }] + ); + const logItem = graph.logger.getItemsOfType(FlowGraphAction.GetConnectionValue).pop(); + expect(logItem?.payload.value).toBe(true); + }); }); diff --git a/packages/dev/loaders/test/unit/Interactivity/interactivityRefValidity.test.ts b/packages/dev/loaders/test/unit/Interactivity/interactivityRefValidity.test.ts new file mode 100644 index 000000000000..2f72f1a68ec5 --- /dev/null +++ b/packages/dev/loaders/test/unit/Interactivity/interactivityRefValidity.test.ts @@ -0,0 +1,99 @@ +import { NullEngine } from "core/Engines/nullEngine"; +import { Scene } from "core/scene"; +import { FlowGraphCoordinator } from "core/FlowGraph/flowGraphCoordinator"; +import { FlowGraphInteger } from "core/FlowGraph/CustomTypes/flowGraphInteger"; +import { getNumericValue } from "core/FlowGraph/utils"; +import { MarkDelayActive, MarkDelayInactive, IsDelayActive } from "core/FlowGraph/flowGraphDelayReference"; +import { GetEventReference } from "core/FlowGraph/flowGraphEventReference"; +import { InteractivityRefPathToObjectConverter } from "loaders/glTF/2.0/Extensions/interactivityRefPathToObjectConverter"; +import type { FlowGraphContext } from "core/FlowGraph/flowGraphContext"; + +/** + * Coverage for the KHR_interactivity ref-validity `pointer/get` accessors + * (spec §4.2.3 Event References and §4.2.4 Delay References), resolved by + * {@link InteractivityRefPathToObjectConverter}. + */ +describe("KHR_interactivity ref-validity accessors", () => { + let engine: NullEngine; + let scene: Scene; + let context: FlowGraphContext; + const converter = new InteractivityRefPathToObjectConverter(); + + beforeEach(() => { + engine = new NullEngine(); + scene = new Scene(engine); + const coordinator = new FlowGraphCoordinator({ scene }); + const graph = coordinator.createGraph(); + context = graph.createContext(); + }); + + afterEach(() => { + scene.dispose(); + engine.dispose(); + }); + + describe("delay references (/extensions/KHR_interactivity/delays/{})", () => { + it("marks a delay active and resolves it as valid until cleared", () => { + expect(IsDelayActive(context, 0)).toBe(false); + + MarkDelayActive(context, 0); + expect(IsDelayActive(context, 0)).toBe(true); + + // pointer/get on an active delay ref => value is the delay handle, isValid (truthy) true. + const active = converter.convert("/extensions/KHR_interactivity/delays/0/"); + const value = active.info.get(active.object, undefined, context); + expect(value).toBeInstanceOf(FlowGraphInteger); + expect(getNumericValue(value as FlowGraphInteger)).toBe(0); + + // Once the delay fires or is cancelled it is no longer valid. + MarkDelayInactive(context, 0); + expect(IsDelayActive(context, 0)).toBe(false); + expect(converter.convert("/extensions/KHR_interactivity/delays/0/").info.get(active.object, undefined, context)).toBeUndefined(); + }); + + it("resolves an unknown or never-scheduled delay ref as invalid", () => { + expect(converter.convert("/extensions/KHR_interactivity/delays/7/").info.get({}, undefined, context)).toBeUndefined(); + }); + + it("returns undefined when no context is supplied", () => { + MarkDelayActive(context, 2); + const accessor = converter.convert("/extensions/KHR_interactivity/delays/2/"); + // Without the runtime context the active-delay set cannot be queried. + expect(accessor.info.get(accessor.object)).toBeUndefined(); + }); + + it("tracks multiple independent delay indices", () => { + MarkDelayActive(context, 1); + MarkDelayActive(context, 4); + expect(IsDelayActive(context, 1)).toBe(true); + expect(IsDelayActive(context, 4)).toBe(true); + expect(IsDelayActive(context, 2)).toBe(false); + + MarkDelayInactive(context, 1); + expect(IsDelayActive(context, 1)).toBe(false); + expect(IsDelayActive(context, 4)).toBe(true); + }); + }); + + describe("event references (/extensions/KHR_interactivity/events/{})", () => { + it("resolves an event ref produced by an event operation as valid", () => { + const eventRef = GetEventReference("onStart"); + // The path-template substitution reconstructs the event ref path with a trailing slash. + const accessor = converter.convert(eventRef + "/"); + const value = accessor.info.get(accessor.object, undefined, context); + // Spec: on success the value output equals the input reference. + expect(value).toBe(eventRef); + }); + + it("resolves custom-event receiver refs as valid", () => { + const eventRef = GetEventReference("myCustomEvent"); + const accessor = converter.convert(eventRef + "/"); + expect(accessor.info.get(accessor.object, undefined, context)).toBe(eventRef); + }); + + it("exposes a non-null target for the event accessor", () => { + const accessor = converter.convert(GetEventReference("onTick") + "/"); + expect(accessor.info.getTarget(accessor.object)).toBeTruthy(); + }); + }); +}); diff --git a/packages/dev/loaders/test/unit/Interactivity/objectModel.test.ts b/packages/dev/loaders/test/unit/Interactivity/objectModel.test.ts index 1310e13d8008..355634ccedac 100644 --- a/packages/dev/loaders/test/unit/Interactivity/objectModel.test.ts +++ b/packages/dev/loaders/test/unit/Interactivity/objectModel.test.ts @@ -385,4 +385,64 @@ describe("glTF interactivity Object Model", () => { log.mockImplementation(() => {}); }); + + // A concurrent invalid pointer/interpolate (bad duration) must not cancel a running interpolation on the same target. + it("should not let an invalid pointer/interpolate cancel a running one", async () => { + const mesh = new Mesh("mesh", scene); + mesh.position.set(1, 2, 3); + const mockGltf: any = { + nodes: [ + { + _babylonTransformNode: mesh, + }, + ], + }; + + await generateSimpleNodeGraph( + mockGltf, + // 0: valid interpolate, 1: invalid interpolate (same op/declaration), 2: unused + [{ op: "pointer/interpolate" }], + [ + { + // valid interpolation to (2,3,4) over ~0.9s + declaration: 0, + configuration: { + pointer: { value: ["/nodes/0/translation"] }, + type: { value: [0] }, + }, + values: { + value: { value: [2, 3, 4], type: 0 }, + duration: { value: [0.9], type: 1 }, + }, + flows: { + // as soon as the valid interpolation starts, trigger the invalid one on the same target + out: { + node: 1, + socket: "in", + }, + }, + }, + { + // invalid interpolation (negative duration) targeting the same node + declaration: 0, + configuration: { + pointer: { value: ["/nodes/0/translation"] }, + type: { value: [0] }, + }, + values: { + value: { value: [9, 9, 9], type: 0 }, + duration: { value: [-1], type: 1 }, + }, + }, + ], + [{ signature: "float3" }, { signature: "float" }] + ); + + // wait for the valid interpolation to complete + await new Promise((resolve) => setTimeout(resolve, 1000 + 300)); + // the running interpolation must not have been cancelled by the invalid one + expect(mesh.position.x).toBeCloseTo(2, 3); + expect(mesh.position.y).toBeCloseTo(3, 3); + expect(mesh.position.z).toBeCloseTo(4, 3); + }); }); diff --git a/packages/public/@babylonjs/core/package.json b/packages/public/@babylonjs/core/package.json index b3d83f51ee4f..119b3e50b795 100644 --- a/packages/public/@babylonjs/core/package.json +++ b/packages/public/@babylonjs/core/package.json @@ -195,6 +195,7 @@ "FlowGraph/Blocks/Event/flowGraphSceneTickEventBlock.js", "FlowGraph/Blocks/Event/flowGraphSendCustomEventBlock.js", "FlowGraph/Blocks/Event/flowGraphSoundEndedEventBlock.js", + "FlowGraph/Blocks/Event/flowGraphStopEventPropagationBlock.js", "FlowGraph/Blocks/Execution/Animation/flowGraphBezierCurveEasingBlock.js", "FlowGraph/Blocks/Execution/Animation/flowGraphEasingBlock.js", "FlowGraph/Blocks/Execution/Animation/flowGraphInterpolationBlock.js", diff --git a/packages/public/glTF2Interface/babylon.glTF2Interface.d.ts b/packages/public/glTF2Interface/babylon.glTF2Interface.d.ts index 461d34bf1e16..11fa921d8b4f 100644 --- a/packages/public/glTF2Interface/babylon.glTF2Interface.d.ts +++ b/packages/public/glTF2Interface/babylon.glTF2Interface.d.ts @@ -1529,11 +1529,11 @@ declare namespace BABYLON.GLTF2 { */ type NodeIndex = number; /** - * Value types supported (in js it is either boolean or number) + * Value types supported (in js it is either boolean or number, or string for opaque references) */ - type ValueType = (boolean | number)[]; + type ValueType = (boolean | number | string)[]; - type ValueSignature = "bool" | "float" | "float2" | "float3" | "float4" | "float2x2" | "float3x3" | "float4x4" | "int" | "custom"; + type ValueSignature = "bool" | "float" | "float2" | "float3" | "float4" | "float2x2" | "float3x3" | "float4x4" | "int" | "ref" | "custom"; type ConfigurationValueType = (boolean | number | string)[]; diff --git a/packages/tools/sandbox/src/main.ts b/packages/tools/sandbox/src/main.ts index 786789e07f28..bace64aa6e34 100644 --- a/packages/tools/sandbox/src/main.ts +++ b/packages/tools/sandbox/src/main.ts @@ -15,6 +15,10 @@ import "loaders/glTF/2.0"; import "loaders/FBX/fbxFileLoader"; // Register Scene animation extensions (e.g. getAllAnimatablesByTarget) used by the Inspector's animation panel. import "core/Animations/animatable"; +// glTF scenes can reference a single mesh from multiple nodes, which the loader +// realizes with InstancedMesh. In the tree-shaken dev build that side-effect is +// not pulled in automatically, so import it explicitly here. +import "core/Meshes/instancedMesh"; import { Sandbox } from "./sandbox"; const HostElement = document.getElementById("host-element") as HTMLElement; diff --git a/packages/tools/sandbox/src/sandbox.tsx b/packages/tools/sandbox/src/sandbox.tsx index ca73fa349baf..4f5c379b2f60 100644 --- a/packages/tools/sandbox/src/sandbox.tsx +++ b/packages/tools/sandbox/src/sandbox.tsx @@ -16,6 +16,7 @@ import { Color3, Color4 } from "core/Maths/math"; import { FilesInputStore } from "core/Misc/filesInputStore"; import "./scss/main.scss"; +import "core/Loading/loadingScreen"; import fullScreenLogo from "./img/logo-fullscreen.svg"; import { type AbstractEngine } from "core/Engines/abstractEngine"; import { ImageProcessingConfiguration } from "core/Materials/imageProcessingConfiguration"; diff --git a/scripts/treeshaking/side-effects-manifest/core/FlowGraph.json b/scripts/treeshaking/side-effects-manifest/core/FlowGraph.json index ef804c7ad9f8..4eb51bb3370c 100644 --- a/scripts/treeshaking/side-effects-manifest/core/FlowGraph.json +++ b/scripts/treeshaking/side-effects-manifest/core/FlowGraph.json @@ -39,6 +39,7 @@ "FlowGraph/Blocks/Event/flowGraphSceneTickEventBlock.ts": ["top-level-call"], "FlowGraph/Blocks/Event/flowGraphSendCustomEventBlock.ts": ["top-level-call"], "FlowGraph/Blocks/Event/flowGraphSoundEndedEventBlock.ts": ["top-level-call"], + "FlowGraph/Blocks/Event/flowGraphStopEventPropagationBlock.ts": ["top-level-call"], "FlowGraph/Blocks/Execution/Animation/flowGraphBezierCurveEasingBlock.ts": ["top-level-call"], "FlowGraph/Blocks/Execution/Animation/flowGraphEasingBlock.ts": ["top-level-call"], "FlowGraph/Blocks/Execution/Animation/flowGraphInterpolationBlock.ts": ["top-level-call"],