From 3809aefbb92f4cd3bcb8a2326b5644f15c0c954b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 23 Jul 2025 16:07:49 +0200 Subject: [PATCH 01/82] Preserve gltf structure for root extensions --- source/Renderer/renderer.js | 6 +-- source/gltf/gltf.js | 52 ++++------------------- source/gltf/image_based_light.js | 71 -------------------------------- source/gltf/scene.js | 10 ----- 4 files changed, 11 insertions(+), 128 deletions(-) delete mode 100644 source/gltf/image_based_light.js diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index ff696d03..b38694da 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -426,9 +426,9 @@ class gltfRenderer if (primitive.skip) return; let material; - if(primitive.mappings !== undefined && state.variant != "default") + if(primitive.mappings !== undefined && state.variant != "default" && state.gltf.extensions?.KHR_materials_variants.variants !== undefined) { - const names = state.gltf.variants.map(obj => obj.name); + const names = state.gltf.extensions.KHR_materials_variants.variants.map(obj => obj.name); const idx = names.indexOf(state.variant); let materialIdx = primitive.material; primitive.mappings.forEach(element => { @@ -784,7 +784,7 @@ class gltfRenderer if (lightIndex === undefined) { continue; } - const light = gltf.lights[lightIndex]; + const light = gltf.extensions?.KHR_lights_punctual?.lights[lightIndex]; nodeLights.push([node, light]); } diff --git a/source/gltf/gltf.js b/source/gltf/gltf.js index 17958cec..882d45c1 100644 --- a/source/gltf/gltf.js +++ b/source/gltf/gltf.js @@ -4,7 +4,6 @@ import { gltfBufferView } from './buffer_view.js'; import { gltfCamera } from './camera.js'; import { gltfImage } from './image.js'; import { gltfLight } from './light.js'; -import { ImageBasedLight } from './image_based_light.js'; import { gltfMaterial } from './material.js'; import { gltfMesh } from './mesh.js'; import { gltfNode } from './node.js'; @@ -95,13 +94,17 @@ class glTF extends GltfObject this.scenes = objectsFromJsons(json.scenes, gltfScene); this.textures = objectsFromJsons(json.textures, gltfTexture); this.nodes = objectsFromJsons(json.nodes, gltfNode); - this.lights = objectsFromJsons(getJsonLightsFromExtensions(json.extensions), gltfLight); - this.imageBasedLights = objectsFromJsons(getJsonIBLsFromExtensions(json.extensions), ImageBasedLight); this.images = objectsFromJsons(json.images, gltfImage); this.animations = objectsFromJsons(json.animations, gltfAnimation); this.skins = objectsFromJsons(json.skins, gltfSkin); - this.variants = objectsFromJsons(getJsonVariantsFromExtension(json.extensions), gltfVariant); - this.variants = enforceVariantsUniqueness(this.variants); + + if (json.extensions?.KHR_lights_punctual !== undefined) { + this.extensions.KHR_lights_punctual.lights = objectsFromJsons(json.extensions.KHR_lights_punctual.lights, gltfLight); + } + if (json.extensions?.KHR_materials_variants !== undefined) { + this.extensions.KHR_materials_variants.variants = objectsFromJsons(json.extensions.KHR_materials_variants?.variants, gltfVariant); + this.extensions.KHR_materials_variants.variants = enforceVariantsUniqueness(this.extensions.KHR_materials_variants.variants); + } this.materials.push(gltfMaterial.createDefault()); this.samplers.push(gltfSampler.createDefault()); @@ -216,45 +219,6 @@ class glTF extends GltfObject } } -function getJsonLightsFromExtensions(extensions) -{ - if (extensions === undefined) - { - return []; - } - if (extensions.KHR_lights_punctual === undefined) - { - return []; - } - return extensions.KHR_lights_punctual.lights; -} - -function getJsonIBLsFromExtensions(extensions) -{ - if (extensions === undefined) - { - return []; - } - if (extensions.KHR_lights_image_based === undefined) - { - return []; - } - return extensions.KHR_lights_image_based.imageBasedLights; -} - -function getJsonVariantsFromExtension(extensions) -{ - if (extensions === undefined) - { - return []; - } - if (extensions.KHR_materials_variants === undefined) - { - return []; - } - return extensions.KHR_materials_variants.variants; -} - function enforceVariantsUniqueness(variants) { for(let i=0;i Date: Wed, 23 Jul 2025 16:23:38 +0200 Subject: [PATCH 02/82] Fix light animation pointer --- source/gltf/animation.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/source/gltf/animation.js b/source/gltf/animation.js index e1f4f21f..ff77723a 100644 --- a/source/gltf/animation.js +++ b/source/gltf/animation.js @@ -98,10 +98,6 @@ class gltfAnimation extends GltfObject } if (property != null) { - if (property.startsWith("/extensions/KHR_lights_punctual/")) { - const suffix = property.substring("/extensions/KHR_lights_punctual/".length); - property = "/" + suffix; - } let jsonPointer = JsonPointer.create(property); let parentObject = jsonPointer.parent(gltf); let back = jsonPointer.path.at(-1); From 44aa729b14a5906515efde29c0df46f466ec2fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 29 Jul 2025 09:46:48 +0200 Subject: [PATCH 03/82] Register json pointer --- package-lock.json | 21 ++- package.json | 5 +- source/GltfState/gltf_state.js | 4 + source/gltf/gltf.js | 10 +- source/gltf/interactivity.js | 241 +++++++++++++++++++++++++++++++++ source/gltf/light.js | 2 +- source/gltf/texture.js | 5 +- 7 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 source/gltf/interactivity.js diff --git a/package-lock.json b/package-lock.json index 8806b0e7..6ab46d25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "gl-matrix": "^3.2.1", "globals": "^15.5.0", "jpeg-js": "^0.4.3", - "json-ptr": "^3.1.0" + "json-ptr": "^3.1.0", + "khr_interactivity_authoring_engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine" }, "devDependencies": { "@rollup/plugin-commonjs": "^26.0.1", @@ -29,6 +30,14 @@ "serve": "^14.2.4" } }, + "../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine": { + "name": "khr_interactivity_authoring_engine", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "gl-matrix": "^3.4.3" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.24.8", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", @@ -2977,6 +2986,10 @@ "json-buffer": "3.0.1" } }, + "node_modules/khr_interactivity_authoring_engine": { + "resolved": "../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", + "link": true + }, "node_modules/klaw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", @@ -7105,6 +7118,12 @@ "json-buffer": "3.0.1" } }, + "khr_interactivity_authoring_engine": { + "version": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", + "requires": { + "gl-matrix": "^3.4.3" + } + }, "klaw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", diff --git a/package.json b/package.json index e2051dbb..05a85748 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "gl-matrix": "^3.2.1", "globals": "^15.5.0", "jpeg-js": "^0.4.3", - "json-ptr": "^3.1.0" + "json-ptr": "^3.1.0", + "khr_interactivity_authoring_engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine" }, "devDependencies": { "@rollup/plugin-commonjs": "^26.0.1", @@ -41,8 +42,8 @@ "jsdoc-to-markdown": "^8.0.1", "rollup": "^4.23.0", "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-license": "^3.5.2", "rollup-plugin-glslify": "^1.3.1", + "rollup-plugin-license": "^3.5.2", "serve": "^14.2.4" }, "bugs": { diff --git a/source/GltfState/gltf_state.js b/source/GltfState/gltf_state.js index ab1e6f4e..0265749f 100644 --- a/source/GltfState/gltf_state.js +++ b/source/GltfState/gltf_state.js @@ -33,6 +33,9 @@ class GltfState /** KHR_materials_variants */ this.variant = undefined; + /** the active graph for interactivity */ + this.graphIndex = 0; + /** parameters used to configure the rendering */ this.renderingParameters = { /** morphing between vertices */ @@ -61,6 +64,7 @@ class GltfState /** KHR_materials_dispersion defines configuring the strength of the angular separation of colors (chromatic abberation)*/ KHR_materials_dispersion: true, KHR_materials_emissive_strength: true, + KHR_interactivity: true, }, /** clear color expressed as list of ints in the range [0, 255] */ clearColor: [58, 64, 74, 255], diff --git a/source/gltf/gltf.js b/source/gltf/gltf.js index 882d45c1..306a69d3 100644 --- a/source/gltf/gltf.js +++ b/source/gltf/gltf.js @@ -16,10 +16,12 @@ import { GltfObject } from './gltf_object.js'; import { gltfAnimation } from './animation.js'; import { gltfSkin } from './skin.js'; import { gltfVariant } from './variant.js'; +import { gltfGraph } from './interactivity.js'; const allowedExtensions = [ "KHR_animation_pointer", "KHR_draco_mesh_compression", + "KHR_interactivity", "KHR_lights_image_based", "KHR_lights_punctual", "KHR_materials_anisotropy", @@ -54,7 +56,6 @@ class glTF extends GltfObject this.scene = undefined; // the default scene to show. this.scenes = []; this.cameras = []; - this.lights = []; this.imageBasedLights = []; this.textures = []; this.images = []; @@ -105,6 +106,10 @@ class glTF extends GltfObject this.extensions.KHR_materials_variants.variants = objectsFromJsons(json.extensions.KHR_materials_variants?.variants, gltfVariant); this.extensions.KHR_materials_variants.variants = enforceVariantsUniqueness(this.extensions.KHR_materials_variants.variants); } + if (json.extensions?.KHR_interactivity !== undefined) { + this.extensions.KHR_interactivity.graphs = objectFromJson(json.extensions.KHR_interactivity?.graphs, gltfGraph); + this.extensions.KHR_interactivity.graph = json.extensions.KHR_interactivity?.graph ?? 0; + } this.materials.push(gltfMaterial.createDefault()); this.samplers.push(gltfSampler.createDefault()); @@ -254,5 +259,6 @@ export { GltfObject, gltfAnimation, gltfSkin, - gltfVariant + gltfVariant, + gltfGraph }; diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js new file mode 100644 index 00000000..19878c11 --- /dev/null +++ b/source/gltf/interactivity.js @@ -0,0 +1,241 @@ +import { GltfObject } from "./gltf_object"; +import * as interactivity from "khr_interactivity_authoring_engine/"; +import { mat4, quat, vec3, vec2, vec4} from "gl-matrix"; + +class gltfGraph extends GltfObject { + constructor() { + super(); + //interactivity.IBehaveEngine + } +} + +class SampleViewerDecorator extends interactivity.ADecorator { + + constructor(behaveEngine, world) { + super(behaveEngine); + this.world = world; + + this.registerKnownPointers(); + + + + this.behaveEngine.stopAnimation = this.stopAnimation; + this.behaveEngine.stopAnimationAt = this.stopAnimationAt; + this.behaveEngine.startAnimation = this.startAnimation; + + this.registerBehaveEngineNode("animation/stop", interactivity.AnimationStop); + this.registerBehaveEngineNode("animation/start", interactivity.AnimationStart); + this.registerBehaveEngineNode("animation/stopAt", interactivity.AnimationStopAt); + + this.behaveEngine.alertParentOnSelect = this.alertParentOnSelect; + this.behaveEngine.alertParentOnHoverIn = this.alertParentOnHoverIn; + this.behaveEngine.alertParentOnHoverOut = this.alertParentOnHoverOut; + this.behaveEngine.addNodeClickedListener = this.addNodeClickedListener; + + //this.registerBehaveEngineNode("event/onSelect", interactivity.OnSelect); + //this.registerBehaveEngineNode("event/onHoverIn", interactivity.OnHoverIn); + //this.registerBehaveEngineNode("event/onHoverOut", interactivity.OnHoverOut); + } + + loadGraph(graph) { + this.behaveEngine.loadBehaveGraph(graph); + } + + processNodeStarted(node) { + //pass + } + + processAddingNodeToQueue(flow) { + //pass + } + + processExecutingNextNode(flow) { + //pass + } + + getTypeFromValue(value) { + if (value instanceof Number) { + return "float"; + } + if (value instanceof Boolean) { + return "bool"; + } + if (value instanceof vec2) { + return "float2"; + } + if (value instanceof vec3) { + return "float3"; + } + if (value instanceof vec4) { + return "float4"; + } + if (value instanceof quat) { + return "float4"; + } + if (value instanceof mat4) { + return "float4x4"; + } + return undefined; + } + + getDefaultValueFromType(type) { + switch (type) { + case "int": + return 0; + case "float": + return NaN; + case "bool": + return false; + case "float2": + return [NaN, NaN]; + case "float3": + return [NaN, NaN, NaN]; + case "float2x2": + case "float4": + return [NaN, NaN, NaN, NaN]; + case "float3x3": + return [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]; + case "float4x4": + return [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]; + } + return undefined; + } + + traversePath(path, value = undefined) { + const pathPieces = path.split('/'); + const lastPiece = pathPieces[pathPieces.length - 1]; + if (value !== undefined) { + pathPieces.pop(); + } + let currentNode = this.world; + + for (let i = 0; i < pathPieces.length; i++) { + if (Array.isArray(currentNode)) { + const index = parseInt(pathPieces[i]); + if (isNaN(index) || index < 0 || index >= currentNode.length) { + return undefined; // Invalid index + } + currentNode = currentNode[index]; + continue; + } + const pathPiece = pathPieces[i]; + if (currentNode[pathPiece] !== undefined) { + currentNode = currentNode[pathPiece]; + } else { + return undefined; + } + } + if (value !== undefined) { + currentNode[lastPiece] = value; + } + return currentNode; + } + + + registerKnownPointersHelper(gltfObject, currentPath = "") { + if (gltfObject === undefined || !(gltfObject instanceof gltfObject)) { + return; + } + for (const property of gltfObject.constructor.animatedProperties) { + if (gltfObject[property] === undefined) { + continue; + } + const jsonPtr = currentPath + "/" + property; + const type = this.getTypeFromValue(gltfObject[property]); + if (type === undefined) { + continue; + } + this.registerJsonPointer(jsonPtr, (path) => { + const result = this.traversePath(path); + if (result === undefined) { + return this.getDefaultValueFromType(type); + } + return result; + }, (path, value) => { + this.traversePath(path, value); + }, type, false); + } + for (const key in gltfObject) { + if (gltfObject[key] instanceof GltfObject) { + this.registerKnownPointersHelper(gltfObject[key], currentPath + "/" + key); + } else if (Array.isArray(gltfObject[key])) { + if (gltfObject[key].length === 0 || !(gltfObject[key][0] instanceof GltfObject)) { + continue; + } + for (let i = 0; i < gltfObject[key].length; i++) { + this.registerKnownPointersHelper(gltfObject[key][i], currentPath + "/" + key + "[" + i + "]"); + } + } + } + for (const extensionName in gltfObject.extensions) { + const extension = gltfObject.extensions[extensionName]; + if (extension instanceof GltfObject) { + this.registerKnownPointersHelper(extension, currentPath + "/extensions/" + extensionName); + } + } + + } + + registerKnownPointers() { + if (this.world === undefined) { + return; + } + this.registerJsonPointerHelper(this.world); + } + + registerJsonPointer(jsonPtr, getterCallback, setterCallback, typeName, readOnly) { + // Register a custom JSON pointer for property access + // Store or use the callbacks as needed for Sample Viewer + if (typeof this.behaveEngine.registerJsonPointer === "function") { + this.behaveEngine.registerJsonPointer(jsonPtr, getterCallback, setterCallback, typeName, readOnly); + } + } + + animateProperty(path, easingParameters, callback) { + // Animate a property at the given path using easing parameters + // Implement animation logic for Sample Viewer properties + // Example: interpolate value over time, then call callback + if (callback) callback(); + } + + animateCubicBezier(path, p1, p2, initialValue, targetValue, duration, valueType, callback) { + // Animate a property using a cubic bezier curve + // Implement animation logic for Sample Viewer properties + if (callback) callback(); + } + + getWorld() { + // Return the world or scene context for the Sample Viewer + return this.world; + } + + stopAnimation(animationIndex) { + + } + + stopAnimationAt(animationIndex, stopTime, callback) { + // Stop animation at a specific time + } + + startAnimation(animationIndex, startTime, endTime, speed, callback) { + + } + + alertParentOnSelect(selectionPoint, selectedNodeIndex, controllerIndex, selectionRayOrigin, childNodeIndex) { + + } + + alertParentOnHoverIn(selectedNodeIndex, controllerIndex, childNodeIndex) { + + } + + alertParentOnHoverOut(selectedNodeIndex, controllerIndex, childNodeIndex) { + + } + + addNodeClickedListener = (nodeIndex, callback) => { + + } +} + +export { gltfGraph }; \ No newline at end of file diff --git a/source/gltf/light.js b/source/gltf/light.js index 8c58f0e5..55117ede 100644 --- a/source/gltf/light.js +++ b/source/gltf/light.js @@ -10,7 +10,7 @@ class gltfLight extends GltfObject super(); this.name = undefined; this.type = "directional"; - this.color = [1, 1, 1]; + this.color = vec3.fromValues(1, 1, 1); this.intensity = 1; this.range = -1; this.spot = new gltfLightSpot(); diff --git a/source/gltf/texture.js b/source/gltf/texture.js index 62010ebb..10c9eefa 100644 --- a/source/gltf/texture.js +++ b/source/gltf/texture.js @@ -3,6 +3,7 @@ import { fromKeys, initGlForMembers } from './utils.js'; import { GL } from '../Renderer/webgl.js'; import { GltfObject } from './gltf_object.js'; +import { vec2 } from 'gl-matrix'; class gltfTexture extends GltfObject { @@ -101,8 +102,8 @@ class KHR_texture_transform extends GltfObject { static animatedProperties = ["offset", "scale", "rotation"]; constructor() { super(); - this.offset = [0, 0]; - this.scale = [1, 1]; + this.offset = vec2.fromValues(0, 0); + this.scale = vec2.fromValues(1, 1); this.rotation = 0; } } From 4cc84ba1dcaba2a3f87c59c3a3f6179ab7c8fb96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 30 Jul 2025 16:08:39 +0200 Subject: [PATCH 04/82] First working version of graph without event --- package-lock.json | 30 +++++---- package.json | 2 +- rollup.config.js | 2 +- source/GltfState/gltf_state.js | 5 +- source/gltf/gltf.js | 2 +- source/gltf/interactivity.js | 116 ++++++++++++++++++++++++++------- 6 files changed, 118 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ab46d25..4e0326e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,12 @@ "version": "1.1.0", "license": "Apache-2.0", "dependencies": { + "@khronosgroup/khr_interactivity_authoring_engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", "fast-png": "^6.2.0", "gl-matrix": "^3.2.1", "globals": "^15.5.0", "jpeg-js": "^0.4.3", - "json-ptr": "^3.1.0", - "khr_interactivity_authoring_engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine" + "json-ptr": "^3.1.0" }, "devDependencies": { "@rollup/plugin-commonjs": "^26.0.1", @@ -36,6 +36,10 @@ "license": "Apache-2.0", "dependencies": { "gl-matrix": "^3.4.3" + }, + "devDependencies": { + "@types/node": "^24.1.0", + "typescript": "^5.8.3" } }, "node_modules/@babel/helper-string-parser": { @@ -331,6 +335,10 @@ "node": ">=v12.0.0" } }, + "node_modules/@khronosgroup/khr_interactivity_authoring_engine": { + "resolved": "../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", + "link": true + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2986,10 +2994,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/khr_interactivity_authoring_engine": { - "resolved": "../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", - "link": true - }, "node_modules/klaw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", @@ -5108,6 +5112,14 @@ "lodash": "^4.17.21" } }, + "@khronosgroup/khr_interactivity_authoring_engine": { + "version": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", + "requires": { + "@types/node": "^24.1.0", + "gl-matrix": "^3.4.3", + "typescript": "^5.8.3" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -7118,12 +7130,6 @@ "json-buffer": "3.0.1" } }, - "khr_interactivity_authoring_engine": { - "version": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", - "requires": { - "gl-matrix": "^3.4.3" - } - }, "klaw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", diff --git a/package.json b/package.json index 05a85748..a25a9e9d 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "globals": "^15.5.0", "jpeg-js": "^0.4.3", "json-ptr": "^3.1.0", - "khr_interactivity_authoring_engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine" + "@khronosgroup/khr_interactivity_authoring_engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine" }, "devDependencies": { "@rollup/plugin-commonjs": "^26.0.1", diff --git a/rollup.config.js b/rollup.config.js index 2203a1f3..d422dc46 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -27,7 +27,7 @@ export default { resolve({ browser: true, preferBuiltins: false, - dedupe: ['gl-matrix', 'jpeg-js', 'fast-png'] + dedupe: ['gl-matrix', 'jpeg-js', 'fast-png', '@khronosgroup/khr_interactivity_authoring_engine'] }), copy({ targets: [ diff --git a/source/GltfState/gltf_state.js b/source/GltfState/gltf_state.js index 0265749f..4cb71c73 100644 --- a/source/GltfState/gltf_state.js +++ b/source/GltfState/gltf_state.js @@ -1,3 +1,4 @@ +import { GraphController } from '../gltf/interactivity.js'; import { UserCamera } from '../gltf/user_camera.js'; import { AnimationTimer } from '../gltf/utils.js'; @@ -33,8 +34,8 @@ class GltfState /** KHR_materials_variants */ this.variant = undefined; - /** the active graph for interactivity */ - this.graphIndex = 0; + /** the graph controller allows selecting and playing graphs from KHR_interactivity */ + this.graphController = new GraphController(); /** parameters used to configure the rendering */ this.renderingParameters = { diff --git a/source/gltf/gltf.js b/source/gltf/gltf.js index 306a69d3..5915a532 100644 --- a/source/gltf/gltf.js +++ b/source/gltf/gltf.js @@ -107,7 +107,7 @@ class glTF extends GltfObject this.extensions.KHR_materials_variants.variants = enforceVariantsUniqueness(this.extensions.KHR_materials_variants.variants); } if (json.extensions?.KHR_interactivity !== undefined) { - this.extensions.KHR_interactivity.graphs = objectFromJson(json.extensions.KHR_interactivity?.graphs, gltfGraph); + this.extensions.KHR_interactivity.graphs = objectsFromJsons(json.extensions.KHR_interactivity?.graphs, gltfGraph); this.extensions.KHR_interactivity.graph = json.extensions.KHR_interactivity?.graph ?? 0; } diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 19878c11..0e8a9066 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -1,23 +1,93 @@ import { GltfObject } from "./gltf_object"; -import * as interactivity from "khr_interactivity_authoring_engine/"; -import { mat4, quat, vec3, vec2, vec4} from "gl-matrix"; +import * as interactivity from "@khronosgroup/khr_interactivity_authoring_engine"; class gltfGraph extends GltfObject { + static animatedProperties = []; + constructor() { super(); - //interactivity.IBehaveEngine } } +/** + * A controller for managing KHR_interactivity graphs in a glTF scene. + */ +class GraphController { + constructor(fps = 60) { + this.fps = fps; + this.graphIndex = undefined; + this.playing = false; + } + + /** + * Initialize the graph controller with the given state and debug flag. + * This needs to be called every time a glTF assets is loaded. + * @param {GltfState} state - The state of the application. + * @param {boolean} debug - Whether to enable debug mode. + */ + initializeGraphs(state, debug = false) { + this.eventBus = new interactivity.DOMEventBus(); + this.engine = new interactivity.BasicBehaveEngine(this.fps, this.eventBus); + this.decorator = new SampleViewerDecorator(this.engine, state, debug); + this.playing = false; + } + + /** + * Starts playing the specified graph. Resets the engine. + * @param {number} graphIndex + */ + startGraph(graphIndex) { + this.engine.clearCustomEventListeners(); + try { + this.decorator.loadGraph(graphIndex); + this.graphIndex = graphIndex; + this.playing = true; + } catch (error) { + console.error("Error loading graph:", error); + } + } + + /** + * Pauses the currently playing graph. + */ + pauseGraph() { + //TODO + this.playing = false; + } + + /** + * Resumes the currently paused graph. + */ + playGraph() { + //TODO + this.playing = true; + } + + /** + * Resets the current graph. + */ + resetGraph() { + if (this.graphIndex === undefined) { + return; + } + this.startGraph(this.graphIndex); + } + +} + class SampleViewerDecorator extends interactivity.ADecorator { - constructor(behaveEngine, world) { + constructor(behaveEngine, world, debug = false) { super(behaveEngine); this.world = world; this.registerKnownPointers(); - + if (debug) { + this.behaveEngine.processNodeStarted = this.processNodeStarted; + this.behaveEngine.processAddingNodeToQueue = this.processAddingNodeToQueue; + this.behaveEngine.processExecutingNextNode = this.processExecutingNextNode; + } this.behaveEngine.stopAnimation = this.stopAnimation; this.behaveEngine.stopAnimationAt = this.stopAnimationAt; @@ -37,20 +107,24 @@ class SampleViewerDecorator extends interactivity.ADecorator { //this.registerBehaveEngineNode("event/onHoverOut", interactivity.OnHoverOut); } - loadGraph(graph) { - this.behaveEngine.loadBehaveGraph(graph); + loadGraph(graphIndex) { + const graphArray = this.world?.gltf?.extensions?.KHR_interactivity.graphs; + if (graphArray && graphArray.length > graphIndex) { + const graphCopy = JSON.parse(JSON.stringify(graphArray[graphIndex])); + this.behaveEngine.loadBehaveGraph(graphCopy); + } } processNodeStarted(node) { - //pass + console.log("Node started:", node); } processAddingNodeToQueue(flow) { - //pass + console.log("Adding node to queue:", flow); } processExecutingNextNode(flow) { - //pass + console.log("Executing next node:", flow); } getTypeFromValue(value) { @@ -60,19 +134,16 @@ class SampleViewerDecorator extends interactivity.ADecorator { if (value instanceof Boolean) { return "bool"; } - if (value instanceof vec2) { + if (value.length === 2) { return "float2"; } - if (value instanceof vec3) { + if (value.length === 3) { return "float3"; } - if (value instanceof vec4) { - return "float4"; - } - if (value instanceof quat) { + if (value.length === 4) { return "float4"; } - if (value instanceof mat4) { + if (value.length === 16) { return "float4x4"; } return undefined; @@ -103,11 +174,12 @@ class SampleViewerDecorator extends interactivity.ADecorator { traversePath(path, value = undefined) { const pathPieces = path.split('/'); + pathPieces.shift(); // Remove first empty piece from split const lastPiece = pathPieces[pathPieces.length - 1]; if (value !== undefined) { pathPieces.pop(); } - let currentNode = this.world; + let currentNode = this.world.gltf; for (let i = 0; i < pathPieces.length; i++) { if (Array.isArray(currentNode)) { @@ -133,7 +205,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { registerKnownPointersHelper(gltfObject, currentPath = "") { - if (gltfObject === undefined || !(gltfObject instanceof gltfObject)) { + if (gltfObject === undefined || !(gltfObject instanceof GltfObject)) { return; } for (const property of gltfObject.constructor.animatedProperties) { @@ -163,7 +235,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { continue; } for (let i = 0; i < gltfObject[key].length; i++) { - this.registerKnownPointersHelper(gltfObject[key][i], currentPath + "/" + key + "[" + i + "]"); + this.registerKnownPointersHelper(gltfObject[key][i], currentPath + "/" + key + "/" + i); } } } @@ -180,7 +252,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { if (this.world === undefined) { return; } - this.registerJsonPointerHelper(this.world); + this.registerKnownPointersHelper(this.world.gltf); } registerJsonPointer(jsonPtr, getterCallback, setterCallback, typeName, readOnly) { @@ -238,4 +310,4 @@ class SampleViewerDecorator extends interactivity.ADecorator { } } -export { gltfGraph }; \ No newline at end of file +export { gltfGraph, GraphController }; \ No newline at end of file From a3e98d43c40b0326907adca1f522dd9bdd8a879f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 30 Jul 2025 16:09:19 +0200 Subject: [PATCH 05/82] Use performance.now for timer --- source/gltf/utils.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/source/gltf/utils.js b/source/gltf/utils.js index 17196b5f..4b856119 100644 --- a/source/gltf/utils.js +++ b/source/gltf/utils.js @@ -146,13 +146,13 @@ class Timer { } start() { - this.startTime = new Date().getTime() / 1000; + this.startTime = performance.now() / 1000; this.endTime = undefined; this.seconds = undefined; } stop() { - this.endTime = new Date().getTime() / 1000; + this.endTime = performance.now() / 1000; this.seconds = this.endTime - this.startTime; } } @@ -170,7 +170,7 @@ class AnimationTimer { return this.pausedTime / 1000; } else { - return this.fixedTime || (new Date().getTime() - this.startTime) / 1000; + return this.fixedTime || (performance.now() - this.startTime) / 1000; } } @@ -184,24 +184,24 @@ class AnimationTimer { } start() { - this.startTime = new Date().getTime(); + this.startTime = performance.now(); this.paused = false; } pause() { - this.pausedTime = new Date().getTime() - this.startTime; + this.pausedTime = performance.now() - this.startTime; this.paused = true; } unpause() { - this.startTime += new Date().getTime() - this.startTime - this.pausedTime; + this.startTime += performance.now() - this.startTime - this.pausedTime; this.paused = false; } reset() { if (!this.paused) { // Animation is running. - this.startTime = new Date().getTime(); + this.startTime = performance.now(); } else { this.startTime = 0; From 89f53f7578984c7598204213d45e88ac450dbbf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 31 Jul 2025 15:26:24 +0200 Subject: [PATCH 06/82] Expose custom events --- source/gltf/interactivity.js | 40 ++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 0e8a9066..f1b7aa30 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -17,6 +17,10 @@ class GraphController { this.fps = fps; this.graphIndex = undefined; this.playing = false; + this.customEvents = []; + this.eventBus = undefined; + this.engine = undefined; + this.decorator = undefined; } /** @@ -34,17 +38,30 @@ class GraphController { /** * Starts playing the specified graph. Resets the engine. - * @param {number} graphIndex + * @param {number} graphIndex + * @return {Array} An array of custom events defined in the graph. */ startGraph(graphIndex) { this.engine.clearCustomEventListeners(); try { - this.decorator.loadGraph(graphIndex); + this.customEvents = this.decorator.loadGraph(graphIndex); this.graphIndex = graphIndex; this.playing = true; } catch (error) { console.error("Error loading graph:", error); } + return this.customEvents; + } + + /** + * Stops the currently playing graph. + */ + stopGraph() { + this.graphIndex = undefined; + this.playing = false; + if (this.engine !== undefined) { + this.engine.clearCustomEventListeners(); + } } /** @@ -73,6 +90,15 @@ class GraphController { this.startGraph(this.graphIndex); } + /** + * Dispatches an event to the behavior engine. + * @param {string} eventName + * @param {*} data + */ + dispatchEvent(eventName, data) { + this.behaveEngine.dispatchEvent(eventName, data); + } + } class SampleViewerDecorator extends interactivity.ADecorator { @@ -111,8 +137,18 @@ class SampleViewerDecorator extends interactivity.ADecorator { const graphArray = this.world?.gltf?.extensions?.KHR_interactivity.graphs; if (graphArray && graphArray.length > graphIndex) { const graphCopy = JSON.parse(JSON.stringify(graphArray[graphIndex])); + let events = graphCopy.events ?? []; + events = events.filter(event => event.id !== undefined); + events = JSON.parse(JSON.stringify(events)); // Deep copy to avoid mutation + for (const event of events) { + for (const value of Object.values(event.values)) { + value.type = graphCopy.types[value.type].signature; + } + } this.behaveEngine.loadBehaveGraph(graphCopy); + return events; } + throw new Error(`Graph with index ${graphIndex} does not exist.`); } processNodeStarted(node) { From ae8a86094bec5322e7f3d7accf49fae9d2d52723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 1 Aug 2025 16:03:01 +0200 Subject: [PATCH 07/82] Fix dispatch event --- source/gltf/interactivity.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index f1b7aa30..e3106f87 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -96,7 +96,9 @@ class GraphController { * @param {*} data */ dispatchEvent(eventName, data) { - this.behaveEngine.dispatchEvent(eventName, data); + if (this.decorator !== undefined) { + this.decorator.dispatchCustomEvent(eventName, data); + } } } From 090296369ccf1e02c806cbcad0b81ea81cb48442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 1 Aug 2025 16:24:42 +0200 Subject: [PATCH 08/82] Fix event name --- source/gltf/interactivity.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index e3106f87..0570f23c 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -153,6 +153,10 @@ class SampleViewerDecorator extends interactivity.ADecorator { throw new Error(`Graph with index ${graphIndex} does not exist.`); } + dispatchCustomEvent(eventName, data) { + this.behaveEngine.dispatchCustomEvent(`KHR_INTERACTIVITY:${eventName}`, data); + } + processNodeStarted(node) { console.log("Node started:", node); } From c57467e38c983ea9e1ffb3e981b575893cfcc6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Mon, 4 Aug 2025 16:50:07 +0200 Subject: [PATCH 09/82] Fix Event call --- source/gltf/interactivity.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 0570f23c..3704926e 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -97,7 +97,7 @@ class GraphController { */ dispatchEvent(eventName, data) { if (this.decorator !== undefined) { - this.decorator.dispatchCustomEvent(eventName, data); + this.decorator.dispatchCustomEvent(`KHR_INTERACTIVITY:${eventName}`, data); } } @@ -108,6 +108,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { constructor(behaveEngine, world, debug = false) { super(behaveEngine); this.world = world; + this.dispatchCustomEvent = this.dispatchCustomEvent.bind(this); this.registerKnownPointers(); @@ -153,10 +154,6 @@ class SampleViewerDecorator extends interactivity.ADecorator { throw new Error(`Graph with index ${graphIndex} does not exist.`); } - dispatchCustomEvent(eventName, data) { - this.behaveEngine.dispatchCustomEvent(`KHR_INTERACTIVITY:${eventName}`, data); - } - processNodeStarted(node) { console.log("Node started:", node); } From 4867a709dcb2123b936d33cce99bc9d77f3ceb47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 5 Aug 2025 18:05:50 +0200 Subject: [PATCH 10/82] Add play/pause/reset --- source/gltf/interactivity.js | 122 +++++++++++++++++++++++------------ 1 file changed, 81 insertions(+), 41 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 3704926e..df1defcb 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -13,14 +13,15 @@ class gltfGraph extends GltfObject { * A controller for managing KHR_interactivity graphs in a glTF scene. */ class GraphController { - constructor(fps = 60) { + constructor(fps = 60, debug = false) { this.fps = fps; this.graphIndex = undefined; this.playing = false; + this.reset = false; this.customEvents = []; - this.eventBus = undefined; - this.engine = undefined; - this.decorator = undefined; + this.eventBus = new interactivity.DOMEventBus(); + this.engine = new interactivity.BasicBehaveEngine(this.fps, this.eventBus); + this.decorator = new SampleViewerDecorator(this.engine, debug); } /** @@ -29,11 +30,17 @@ class GraphController { * @param {GltfState} state - The state of the application. * @param {boolean} debug - Whether to enable debug mode. */ - initializeGraphs(state, debug = false) { - this.eventBus = new interactivity.DOMEventBus(); - this.engine = new interactivity.BasicBehaveEngine(this.fps, this.eventBus); - this.decorator = new SampleViewerDecorator(this.engine, state, debug); + initializeGraphs(state) { + this.graphIndex = undefined; this.playing = false; + this.reset = false; + this.decorator.setWorld(state); + this.engine.clearCustomEventListeners(); + this.engine.clearEventList(); + this.engine.clearPointerInterpolation(); + this.engine.clearVariableInterpolation(); + this.engine.clearScheduledDelays(); + this.engine.clearValueEvaluationCache(); } /** @@ -47,6 +54,7 @@ class GraphController { this.customEvents = this.decorator.loadGraph(graphIndex); this.graphIndex = graphIndex; this.playing = true; + this.reset = false; } catch (error) { console.error("Error loading graph:", error); } @@ -57,27 +65,38 @@ class GraphController { * Stops the currently playing graph. */ stopGraph() { + if (this.graphIndex === undefined) { + return; + } this.graphIndex = undefined; this.playing = false; - if (this.engine !== undefined) { - this.engine.clearCustomEventListeners(); - } + this.engine.clearCustomEventListeners(); } /** * Pauses the currently playing graph. */ pauseGraph() { - //TODO + if (this.graphIndex === undefined || !this.playing) { + return; + } + this.decorator.pauseEventQueue(); this.playing = false; } /** * Resumes the currently paused graph. */ - playGraph() { - //TODO - this.playing = true; + resumeGraph() { + if (this.graphIndex === undefined || this.playing) { + return; + } + if (this.reset) { + this.startGraph(this.graphIndex); + } else { + this.decorator.resumeEventQueue(); + this.playing = true; + } } /** @@ -87,7 +106,11 @@ class GraphController { if (this.graphIndex === undefined) { return; } - this.startGraph(this.graphIndex); + this.decorator.resetGraph(); + this.reset = true; + if (this.playing) { + this.startGraph(this.graphIndex); + } } /** @@ -96,7 +119,7 @@ class GraphController { * @param {*} data */ dispatchEvent(eventName, data) { - if (this.decorator !== undefined) { + if (this.graphIndex !== undefined) { this.decorator.dispatchCustomEvent(`KHR_INTERACTIVITY:${eventName}`, data); } } @@ -105,13 +128,10 @@ class GraphController { class SampleViewerDecorator extends interactivity.ADecorator { - constructor(behaveEngine, world, debug = false) { + constructor(behaveEngine, debug = false) { super(behaveEngine); - this.world = world; this.dispatchCustomEvent = this.dispatchCustomEvent.bind(this); - this.registerKnownPointers(); - if (debug) { this.behaveEngine.processNodeStarted = this.processNodeStarted; this.behaveEngine.processAddingNodeToQueue = this.processAddingNodeToQueue; @@ -136,6 +156,12 @@ class SampleViewerDecorator extends interactivity.ADecorator { //this.registerBehaveEngineNode("event/onHoverOut", interactivity.OnHoverOut); } + setWorld(world) { + this.resetGraph(); + this.world = world; + this.registerKnownPointers(); + } + loadGraph(graphIndex) { const graphArray = this.world?.gltf?.extensions?.KHR_interactivity.graphs; if (graphArray && graphArray.length > graphIndex) { @@ -154,6 +180,17 @@ class SampleViewerDecorator extends interactivity.ADecorator { throw new Error(`Graph with index ${graphIndex} does not exist.`); } + resetGraph() { + this.behaveEngine.loadBehaveGraph({nodes: [], types: [], events: [], declarations: [], variables: []}); + if (this.world === undefined) { + return; + } + const resetAnimatedProperty = (path, propertyName, parent) => { + parent.animatedPropertyObjects[propertyName].rest(); + }; + this.recurseAllAnimatedProperties(this.world.gltf, resetAnimatedProperty); + } + processNodeStarted(node) { console.log("Node started:", node); } @@ -237,13 +274,13 @@ class SampleViewerDecorator extends interactivity.ADecorator { } } if (value !== undefined) { - currentNode[lastPiece] = value; + currentNode.animatedPropertyObjects[lastPiece].animate(value); } return currentNode; } - registerKnownPointersHelper(gltfObject, currentPath = "") { + recurseAllAnimatedProperties(gltfObject, callable, currentPath = "") { if (gltfObject === undefined || !(gltfObject instanceof GltfObject)) { return; } @@ -251,37 +288,24 @@ class SampleViewerDecorator extends interactivity.ADecorator { if (gltfObject[property] === undefined) { continue; } - const jsonPtr = currentPath + "/" + property; - const type = this.getTypeFromValue(gltfObject[property]); - if (type === undefined) { - continue; - } - this.registerJsonPointer(jsonPtr, (path) => { - const result = this.traversePath(path); - if (result === undefined) { - return this.getDefaultValueFromType(type); - } - return result; - }, (path, value) => { - this.traversePath(path, value); - }, type, false); + callable(currentPath, property, gltfObject); } for (const key in gltfObject) { if (gltfObject[key] instanceof GltfObject) { - this.registerKnownPointersHelper(gltfObject[key], currentPath + "/" + key); + this.recurseAllAnimatedProperties(gltfObject[key], callable,currentPath + "/" + key); } else if (Array.isArray(gltfObject[key])) { if (gltfObject[key].length === 0 || !(gltfObject[key][0] instanceof GltfObject)) { continue; } for (let i = 0; i < gltfObject[key].length; i++) { - this.registerKnownPointersHelper(gltfObject[key][i], currentPath + "/" + key + "/" + i); + this.recurseAllAnimatedProperties(gltfObject[key][i], callable, currentPath + "/" + key + "/" + i); } } } for (const extensionName in gltfObject.extensions) { const extension = gltfObject.extensions[extensionName]; if (extension instanceof GltfObject) { - this.registerKnownPointersHelper(extension, currentPath + "/extensions/" + extensionName); + this.recurseAllAnimatedProperties(extension, callable, currentPath + "/extensions/" + extensionName); } } @@ -291,7 +315,23 @@ class SampleViewerDecorator extends interactivity.ADecorator { if (this.world === undefined) { return; } - this.registerKnownPointersHelper(this.world.gltf); + const registerFunction = (currentPath, propertyName, parent) => { + const jsonPtr = currentPath + "/" + propertyName; + const type = this.getTypeFromValue(parent[propertyName]); + if (type === undefined) { + return; + } + this.registerJsonPointer(jsonPtr, (path) => { + const result = this.traversePath(path); + if (result === undefined) { + return this.getDefaultValueFromType(type); + } + return result; + }, (path, value) => { + this.traversePath(path, value); + }, type, false); + }; + this.recurseAllAnimatedProperties(this.world.gltf, registerFunction); } registerJsonPointer(jsonPtr, getterCallback, setterCallback, typeName, readOnly) { From ca2a9043c78ce1d21a611be5b96e14477a53c8a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 6 Aug 2025 18:25:48 +0200 Subject: [PATCH 11/82] Implement animation nodes Not tested yet --- source/GltfView/gltf_view.js | 40 +++++++++++++--------- source/gltf/animation.js | 64 +++++++++++++++++++++++++++++++++++- source/gltf/interactivity.js | 62 +++++++++++++++++++++++++++------- 3 files changed, 137 insertions(+), 29 deletions(-) diff --git a/source/GltfView/gltf_view.js b/source/GltfView/gltf_view.js index 54317cfc..76469fa5 100644 --- a/source/GltfView/gltf_view.js +++ b/source/GltfView/gltf_view.js @@ -156,33 +156,41 @@ class GltfView _animate(state) { - if(state.gltf === undefined) + if(state.gltf === undefined || state.gltf.animations === undefined) { return; } + let disabledAnimations = []; + let enabledAnimations = []; - if(state.gltf.animations !== undefined && state.animationIndices !== undefined) + if (state.gltf?.extensions?.KHR_interactivity !== undefined && state.renderingParameters.enabledExtensions.KHR_interactivity) { + for (const animation of state.gltf.animations) { + if (animation.createdTimestamp !== undefined) { + enabledAnimations.push(animation); + } + } + } else if(state.animationIndices !== undefined) { - const disabledAnimations = state.gltf.animations.filter( (anim, index) => { + disabledAnimations = state.gltf.animations.filter( (anim, index) => { return false === state.animationIndices.includes(index); }); - - for(const disabledAnimation of disabledAnimations) - { - disabledAnimation.advance(state.gltf, undefined); - } - - const t = state.animationTimer.elapsedSec(); - - const animations = state.animationIndices.map(index => { + enabledAnimations = state.animationIndices.map(index => { return state.gltf.animations[index]; }).filter(animation => animation !== undefined); + } - for(const animation of animations) - { - animation.advance(state.gltf, t); - } + for(const disabledAnimation of disabledAnimations) + { + disabledAnimation.advance(state.gltf, undefined); + } + + const t = state.animationTimer.elapsedSec(); + + for(const animation of enabledAnimations) + { + animation.advance(state.gltf, t); } + } } diff --git a/source/gltf/animation.js b/source/gltf/animation.js index ff77723a..daf488f8 100644 --- a/source/gltf/animation.js +++ b/source/gltf/animation.js @@ -16,6 +16,15 @@ class gltfAnimation extends GltfObject this.samplers = []; this.name = ''; + // For KHR_interactivity + this.createdTimestamp = undefined; // Time in seconds after graph creation when the animation was created. Computed via animation timer. + this.startTime = 0; + this.stopTime = undefined; + this.endTime = Infinity; + this.speed = 1.0; + this.endCallback = undefined; // Callback to call when the animation ends. + this.stopCallback = undefined; // Callback to call when the animation stops. + // not gltf this.interpolators = []; this.maxTime = 0; @@ -44,6 +53,16 @@ class gltfAnimation extends GltfObject } } + reset() { + this.createdTimestamp = undefined; + this.startTime = 0; + this.stopTime = undefined; + this.endTime = Infinity; + this.speed = 1.0; + this.endCallback = undefined; + this.stopCallback = undefined; + } + // advance the animation, if totalTime is undefined, the animation is deactivated advance(gltf, totalTime) { @@ -67,6 +86,37 @@ class gltfAnimation extends GltfObject } } + let stopAnimation = false; + let endAnimation = false; + let elapsedTime = totalTime; + + + if (this.createdTimestamp !== undefined) { + if (this.createdTimestamp !== undefined) { + elapsedTime = totalTime - this.createdTimestamp; + } + elapsedTime *= this.speed; + if (this.startTime > this.endTime) { + elapsedTime *= -1; + } + elapsedTime += this.startTime; + if (this.startTime === this.endTime) { + elapsedTime = this.startTime; + endAnimation = true; + } else if (this.stopTime !== undefined) { + if ((this.startTime < this.endTime && elapsedTime >= this.stopTime && this.stopTime >= this.startTime && this.stopTime < this.endTime) + || (this.startTime > this.endTime && elapsedTime <= this.stopTime && this.stopTime <= this.startTime && this.stopTime > this.endTime)) { + elapsedTime = this.stopTime; + stopAnimation = true; + } + } else if ((this.startTime < this.endTime && elapsedTime >= this.endTime) || (this.startTime > this.endTime && elapsedTime <= this.endTime)) { + elapsedTime = this.endTime; + endAnimation = true; + } + } + + + for(let i = 0; i < this.interpolators.length; ++i) { const channel = this.channels[i]; @@ -128,7 +178,7 @@ class gltfAnimation extends GltfObject stride = animatedProperty.restValue[animatedArrayElement]?.length ?? 1; } - const interpolant = interpolator.interpolate(gltf, channel, sampler, totalTime, stride, this.maxTime); + const interpolant = interpolator.interpolate(gltf, channel, sampler, elapsedTime, stride, this.maxTime); if (interpolant === undefined) { animatedProperty.rest(); continue; @@ -156,6 +206,18 @@ class gltfAnimation extends GltfObject } } } + + if (stopAnimation) { + this.createdTimestamp = undefined; + this.stopCallback?.(); + this.reset(); + return; + } + if (endAnimation) { + this.createdTimestamp = undefined; + this.endCallback?.(); + this.reset(); + } } } diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index df1defcb..777417ce 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -335,11 +335,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { } registerJsonPointer(jsonPtr, getterCallback, setterCallback, typeName, readOnly) { - // Register a custom JSON pointer for property access - // Store or use the callbacks as needed for Sample Viewer - if (typeof this.behaveEngine.registerJsonPointer === "function") { - this.behaveEngine.registerJsonPointer(jsonPtr, getterCallback, setterCallback, typeName, readOnly); - } + this.behaveEngine.registerJsonPointer(jsonPtr, getterCallback, setterCallback, typeName, readOnly); } animateProperty(path, easingParameters, callback) { @@ -350,26 +346,68 @@ class SampleViewerDecorator extends interactivity.ADecorator { } animateCubicBezier(path, p1, p2, initialValue, targetValue, duration, valueType, callback) { - // Animate a property using a cubic bezier curve - // Implement animation logic for Sample Viewer properties - if (callback) callback(); + this.behaveEngine.clearPointerInterpolation(path); + const startTime = performance.now(); + + const action = async () => { + const elapsedDuration = (performance.now() - startTime) / 1000; + const t = Math.min(elapsedDuration / duration, 1); + const p = interactivity.cubicBezier(t, {x: 0, y:0}, {x: p1[0], y:p1[1]}, {x: p2[0], y:p2[1]}, {x: 1, y:1}); + if (valueType === "float3") { + const value = [interactivity.linearFloat(p.y, initialValue[0], targetValue[0]), interactivity.linearFloat(p.y, initialValue[1], targetValue[1]), interactivity.linearFloat(p.y, initialValue[2], targetValue[2])]; + this.behaveEngine.setPathValue(path, value); + } else if (valueType === "float4") { + if (this.isSlerpPath(path)) { + const value = interactivity.slerpFloat4(p.y, initialValue, targetValue); + this.behaveEngine.setPathValue(path, value); + } else { + const value = [interactivity.linearFloat(p.y, initialValue[0], targetValue[0]), interactivity.linearFloat(p.y, initialValue[1], targetValue[1]), interactivity.linearFloat(p.y, initialValue[2], targetValue[2]), interactivity.linearFloat(p.y, initialValue[3], targetValue[3])]; + this.behaveEngine.setPathValue(path, value); + } + } else if (valueType === "float") { + const value = [interactivity.linearFloat(p.y, initialValue[0], targetValue[0])]; + this.behaveEngine.setPathValue(path, value); + } else if (valueType == "float2") { + const value = [interactivity.linearFloat(p.y, initialValue[0], targetValue[0]), interactivity.linearFloat(p.y, initialValue[1], targetValue[1])]; + this.behaveEngine.setPathValue(path, value); + } + + if (elapsedDuration >= duration) { + this.behaveEngine.setPathValue(path, targetValue); + this.behaveEngine.clearPointerInterpolation(path); + callback(); + } + }; + + this.behaveEngine.setPointerInterpolationCallback(path, {action: action} ); } getWorld() { - // Return the world or scene context for the Sample Viewer return this.world; } stopAnimation(animationIndex) { - + const animation = this.world.gltf.animations[animationIndex]; + animation.reset(); } stopAnimationAt(animationIndex, stopTime, callback) { - // Stop animation at a specific time + const animation = this.world.gltf.animations[animationIndex]; + if (animation.createdTimestamp === undefined) { + return; + } + animation.stopTime = stopTime; + animation.stopCallback = callback; } startAnimation(animationIndex, startTime, endTime, speed, callback) { - + const animation = this.world.gltf.animations[animationIndex]; + animation.createdTimestamp = undefined; + animation.startTime = startTime; + animation.endTime = endTime; + animation.speed = speed; + animation.endCallback = callback; + animation.createdTimestamp = this.world.animationTimer.elapsedSec(); } alertParentOnSelect(selectionPoint, selectedNodeIndex, controllerIndex, selectionRayOrigin, childNodeIndex) { From 39b359c16e85bcbef111e02f6fbb461f3935546f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 7 Aug 2025 16:03:52 +0200 Subject: [PATCH 12/82] Fix issues with animation --- source/GltfView/gltf_view.js | 13 ++++++++++--- source/gltf/animation.js | 4 +--- source/gltf/interactivity.js | 23 +++++++++++++++++++---- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/source/GltfView/gltf_view.js b/source/GltfView/gltf_view.js index 76469fa5..cc79492b 100644 --- a/source/GltfView/gltf_view.js +++ b/source/GltfView/gltf_view.js @@ -164,9 +164,11 @@ class GltfView let enabledAnimations = []; if (state.gltf?.extensions?.KHR_interactivity !== undefined && state.renderingParameters.enabledExtensions.KHR_interactivity) { - for (const animation of state.gltf.animations) { - if (animation.createdTimestamp !== undefined) { - enabledAnimations.push(animation); + if (state.graphController.playing){ + for (const animation of state.gltf.animations) { + if (animation.createdTimestamp !== undefined) { + enabledAnimations.push(animation); + } } } } else if(state.animationIndices !== undefined) @@ -177,6 +179,11 @@ class GltfView enabledAnimations = state.animationIndices.map(index => { return state.gltf.animations[index]; }).filter(animation => animation !== undefined); + for (const animation of enabledAnimations) { + if (animation.createdTimestamp !== undefined) { + animation.reset(); + } + } } for(const disabledAnimation of disabledAnimations) diff --git a/source/gltf/animation.js b/source/gltf/animation.js index daf488f8..6d5f72ec 100644 --- a/source/gltf/animation.js +++ b/source/gltf/animation.js @@ -92,9 +92,7 @@ class gltfAnimation extends GltfObject if (this.createdTimestamp !== undefined) { - if (this.createdTimestamp !== undefined) { - elapsedTime = totalTime - this.createdTimestamp; - } + elapsedTime = totalTime - this.createdTimestamp; elapsedTime *= this.speed; if (this.startTime > this.endTime) { elapsedTime *= -1; diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 777417ce..f4642d4c 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -34,7 +34,7 @@ class GraphController { this.graphIndex = undefined; this.playing = false; this.reset = false; - this.decorator.setWorld(state); + this.decorator.setState(state); this.engine.clearCustomEventListeners(); this.engine.clearEventList(); this.engine.clearPointerInterpolation(); @@ -131,12 +131,14 @@ class SampleViewerDecorator extends interactivity.ADecorator { constructor(behaveEngine, debug = false) { super(behaveEngine); this.dispatchCustomEvent = this.dispatchCustomEvent.bind(this); + this.world = undefined; if (debug) { this.behaveEngine.processNodeStarted = this.processNodeStarted; this.behaveEngine.processAddingNodeToQueue = this.processAddingNodeToQueue; this.behaveEngine.processExecutingNextNode = this.processExecutingNextNode; } + this.behaveEngine.getWorld = this.getWorld; this.behaveEngine.stopAnimation = this.stopAnimation; this.behaveEngine.stopAnimationAt = this.stopAnimationAt; @@ -156,9 +158,10 @@ class SampleViewerDecorator extends interactivity.ADecorator { //this.registerBehaveEngineNode("event/onHoverOut", interactivity.OnHoverOut); } - setWorld(world) { + setState(state) { this.resetGraph(); - this.world = world; + this.world = state; + this.behaveEngine.world = state; this.registerKnownPointers(); } @@ -185,6 +188,9 @@ class SampleViewerDecorator extends interactivity.ADecorator { if (this.world === undefined) { return; } + for (const animation of this.world.gltf.animations) { + animation.reset(); + } const resetAnimatedProperty = (path, propertyName, parent) => { parent.animatedPropertyObjects[propertyName].rest(); }; @@ -332,6 +338,15 @@ class SampleViewerDecorator extends interactivity.ADecorator { }, type, false); }; this.recurseAllAnimatedProperties(this.world.gltf, registerFunction); + this.registerJsonPointer(`/extensions/KHR_interactivity/activeCamera/rotation`, (path) => { + let activeCamera = this.world.userCamera; + if (this.world.cameraIndex !== undefined && this.world.gltf.cameras.length > this.world.cameraIndex) { + activeCamera = this.world.gltf.cameras[this.world.cameraIndex]; + } + return activeCamera.getRotation(); + }, (path, value) => { + //no-op + }, "float4", true); } registerJsonPointer(jsonPtr, getterCallback, setterCallback, typeName, readOnly) { @@ -383,7 +398,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { } getWorld() { - return this.world; + return this.world?.gltf; } stopAnimation(animationIndex) { From 85acb01aa0a7a49b513a576516f86057c0579623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Mon, 11 Aug 2025 10:59:48 +0200 Subject: [PATCH 13/82] Remove unneeded code --- source/gltf/interactivity.js | 45 ------------------------------------ 1 file changed, 45 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index f4642d4c..f09878b0 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -130,7 +130,6 @@ class SampleViewerDecorator extends interactivity.ADecorator { constructor(behaveEngine, debug = false) { super(behaveEngine); - this.dispatchCustomEvent = this.dispatchCustomEvent.bind(this); this.world = undefined; if (debug) { @@ -353,50 +352,6 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.behaveEngine.registerJsonPointer(jsonPtr, getterCallback, setterCallback, typeName, readOnly); } - animateProperty(path, easingParameters, callback) { - // Animate a property at the given path using easing parameters - // Implement animation logic for Sample Viewer properties - // Example: interpolate value over time, then call callback - if (callback) callback(); - } - - animateCubicBezier(path, p1, p2, initialValue, targetValue, duration, valueType, callback) { - this.behaveEngine.clearPointerInterpolation(path); - const startTime = performance.now(); - - const action = async () => { - const elapsedDuration = (performance.now() - startTime) / 1000; - const t = Math.min(elapsedDuration / duration, 1); - const p = interactivity.cubicBezier(t, {x: 0, y:0}, {x: p1[0], y:p1[1]}, {x: p2[0], y:p2[1]}, {x: 1, y:1}); - if (valueType === "float3") { - const value = [interactivity.linearFloat(p.y, initialValue[0], targetValue[0]), interactivity.linearFloat(p.y, initialValue[1], targetValue[1]), interactivity.linearFloat(p.y, initialValue[2], targetValue[2])]; - this.behaveEngine.setPathValue(path, value); - } else if (valueType === "float4") { - if (this.isSlerpPath(path)) { - const value = interactivity.slerpFloat4(p.y, initialValue, targetValue); - this.behaveEngine.setPathValue(path, value); - } else { - const value = [interactivity.linearFloat(p.y, initialValue[0], targetValue[0]), interactivity.linearFloat(p.y, initialValue[1], targetValue[1]), interactivity.linearFloat(p.y, initialValue[2], targetValue[2]), interactivity.linearFloat(p.y, initialValue[3], targetValue[3])]; - this.behaveEngine.setPathValue(path, value); - } - } else if (valueType === "float") { - const value = [interactivity.linearFloat(p.y, initialValue[0], targetValue[0])]; - this.behaveEngine.setPathValue(path, value); - } else if (valueType == "float2") { - const value = [interactivity.linearFloat(p.y, initialValue[0], targetValue[0]), interactivity.linearFloat(p.y, initialValue[1], targetValue[1])]; - this.behaveEngine.setPathValue(path, value); - } - - if (elapsedDuration >= duration) { - this.behaveEngine.setPathValue(path, targetValue); - this.behaveEngine.clearPointerInterpolation(path); - callback(); - } - }; - - this.behaveEngine.setPointerInterpolationCallback(path, {action: action} ); - } - getWorld() { return this.world?.gltf; } From 2702673972080e66f846f716da9fcb40a9b896cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Mon, 11 Aug 2025 15:33:54 +0200 Subject: [PATCH 14/82] Support negativ time/reverse animation --- source/gltf/interpolator.js | 42 +++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/source/gltf/interpolator.js b/source/gltf/interpolator.js index f2070794..81ed05b4 100644 --- a/source/gltf/interpolator.js +++ b/source/gltf/interpolator.js @@ -1,3 +1,4 @@ +import { max } from 'rxjs/operators'; import { InterpolationModes } from './animation_sampler.js'; import { InterpolationPath } from './channel.js'; import { clamp, jsToGlSlice } from './utils.js'; @@ -102,32 +103,55 @@ class gltfInterpolator // Wrap t around, so the animation loops. // Make sure that t is never earlier than the first keyframe and never later then the last keyframe. + const isNegative = t < 0; t = t % maxTime; + if (isNegative) { + t += maxTime; + } t = clamp(t, input[0], input[input.length - 1]); - if (this.prevT > t) + if (this.prevT > t && !isNegative) { this.prevKey = 0; } + if (isNegative && this.prevT < t) + { + this.prevKey = input.length - 1; + } + this.prevT = t; // Find next keyframe: min{ t of input | t > prevKey } let nextKey = null; - for (let i = this.prevKey; i < input.length; ++i) - { - if (t <= input[i]) + if (isNegative) { + for (let i = this.prevKey; i >= 0; --i) + { + if (t >= input[i]) + { + nextKey = i; + break; + } + } + this.prevKey = clamp(nextKey + 1, nextKey, input.length - 1); + } else { + for (let i = this.prevKey; i < input.length; ++i) { - nextKey = clamp(i, 1, input.length - 1); - break; + if (t <= input[i]) + { + nextKey = clamp(i, 1, input.length - 1); + break; + } } + this.prevKey = clamp(nextKey - 1, 0, nextKey); } - this.prevKey = clamp(nextKey - 1, 0, nextKey); - const keyDelta = input[nextKey] - input[this.prevKey]; + + + const keyDelta = Math.abs(input[nextKey] - input[this.prevKey]); // Normalize t: [t0, t1] -> [0, 1] - const tn = (t - input[this.prevKey]) / keyDelta; + const tn = Math.abs(t - input[this.prevKey]) / keyDelta; if(channel.target.path === InterpolationPath.ROTATION) { From db2f205166323a5e3abfe40b96347ff97750c836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Mon, 11 Aug 2025 17:34:35 +0200 Subject: [PATCH 15/82] Add read-only pointers --- source/gltf/animation.js | 35 ++++++--- source/gltf/gltf.js | 1 + source/gltf/gltf_object.js | 1 + source/gltf/interactivity.js | 146 +++++++++++++++++++++++++++++++++-- source/gltf/interpolator.js | 3 + source/gltf/material.js | 1 + source/gltf/mesh.js | 1 + source/gltf/node.js | 7 ++ source/gltf/primitive.js | 1 + source/gltf/scene.js | 1 + source/gltf/skin.js | 1 + 11 files changed, 182 insertions(+), 16 deletions(-) diff --git a/source/gltf/animation.js b/source/gltf/animation.js index 6d5f72ec..02520b69 100644 --- a/source/gltf/animation.js +++ b/source/gltf/animation.js @@ -27,7 +27,8 @@ class gltfAnimation extends GltfObject // not gltf this.interpolators = []; - this.maxTime = 0; + this.maxTime = NaN; + this.minTime = NaN; this.disjointAnimations = []; this.errors = []; @@ -63,28 +64,40 @@ class gltfAnimation extends GltfObject this.stopCallback = undefined; } - // advance the animation, if totalTime is undefined, the animation is deactivated - advance(gltf, totalTime) + computeMinMaxTime(gltf) { - if(this.channels === undefined) - { - return; - } - - if(this.maxTime == 0) + if(isNaN(this.maxTime) || isNaN(this.minTime)) { + this.maxTime = -Infinity; + this.minTime = Infinity; for(let i = 0; i < this.channels.length; ++i) { const channel = this.channels[i]; const sampler = this.samplers[channel.sampler]; - const input = gltf.accessors[sampler.input].getDeinterlacedView(gltf); - const max = input[input.length - 1]; + const input = gltf.accessors[sampler.input]; + const max = input.max; + const min = input.min; if(max > this.maxTime) { this.maxTime = max; } + if(min < this.minTime) + { + this.minTime = min; + } } } + } + + // advance the animation, if totalTime is undefined, the animation is deactivated + advance(gltf, totalTime) + { + if(this.channels === undefined) + { + return; + } + + this.computeMinMaxTime(gltf); let stopAnimation = false; let endAnimation = false; diff --git a/source/gltf/gltf.js b/source/gltf/gltf.js index 5915a532..e8e558e2 100644 --- a/source/gltf/gltf.js +++ b/source/gltf/gltf.js @@ -47,6 +47,7 @@ const allowedExtensions = [ class glTF extends GltfObject { static animatedProperties = []; + static readOnlyAnimatedProperties = ["animations", "cameras", "materials", "meshes", "nodes", "scene", "scenes", "skins"]; constructor(file) { super(); diff --git a/source/gltf/gltf_object.js b/source/gltf/gltf_object.js index a1dbd257..eca5ac93 100644 --- a/source/gltf/gltf_object.js +++ b/source/gltf/gltf_object.js @@ -24,6 +24,7 @@ class GltfObject } static animatedProperties = undefined; + static readOnlyAnimatedProperties = []; // If an array property is defined here, the length can be queried fromJson(json) { diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index f09878b0..81627a27 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -190,7 +190,10 @@ class SampleViewerDecorator extends interactivity.ADecorator { for (const animation of this.world.gltf.animations) { animation.reset(); } - const resetAnimatedProperty = (path, propertyName, parent) => { + const resetAnimatedProperty = (path, propertyName, parent, readOnly) => { + if (readOnly) { + return; + } parent.animatedPropertyObjects[propertyName].rest(); }; this.recurseAllAnimatedProperties(this.world.gltf, resetAnimatedProperty); @@ -293,7 +296,13 @@ class SampleViewerDecorator extends interactivity.ADecorator { if (gltfObject[property] === undefined) { continue; } - callable(currentPath, property, gltfObject); + callable(currentPath, property, gltfObject, false); + } + for (const property in gltfObject.constructor.readOnlyAnimatedProperties) { + if (gltfObject[property] === undefined) { + continue; + } + callable(currentPath, property, gltfObject, true); } for (const key in gltfObject) { if (gltfObject[key] instanceof GltfObject) { @@ -320,9 +329,30 @@ class SampleViewerDecorator extends interactivity.ADecorator { if (this.world === undefined) { return; } - const registerFunction = (currentPath, propertyName, parent) => { - const jsonPtr = currentPath + "/" + propertyName; - const type = this.getTypeFromValue(parent[propertyName]); + const registerFunction = (currentPath, propertyName, parent, readOnly) => { + let jsonPtr = currentPath + "/" + propertyName; + let type = this.getTypeFromValue(parent[propertyName]); + if (readOnly) { + if (Array.isArray(parent[propertyName])) { + jsonPtr += ".length"; + type = "int"; + this.registerJsonPointer(jsonPtr, (path) => { + const result = this.traversePath(path); + if (result === undefined) { + return 0; + } + return result.length; + }, (path, value) => {}, "int", true); + return; + } + this.registerJsonPointer(jsonPtr, (path) => { + const result = this.traversePath(path); + if (result === undefined) { + return this.getDefaultValueFromType(type); + } + return result; + }, (path, value) => {}, type, true); + } if (type === undefined) { return; } @@ -337,6 +367,106 @@ class SampleViewerDecorator extends interactivity.ADecorator { }, type, false); }; this.recurseAllAnimatedProperties(this.world.gltf, registerFunction); + + this.registerJsonPointer(`/extensions/KHR_lights_punctual/lights.length`, (path) => { + const lights = this.world.gltf.extensions?.KHR_lights_punctual?.lights; + if (lights === undefined) { + return 0; + } + return lights.length; + }, (path, value) => {}, "int", true); + + const nodeCount = this.world.gltf.nodes.length; + this.registerJsonPointer(`/nodes/${nodeCount}/children/${nodeCount}`, (path) => { + return this.traversePath(path); + }, (path, value) => {}, "int", true); + this.registerJsonPointer(`/nodes/${nodeCount}/globalMatrix`, (path) => { + const node = this.traversePath(path); + if (node === undefined) { + return undefined; + } + //Should we call applyWorldTransform for all scenes here? + return node.worldTransform; // gl-matrix uses column-major order + }, (path, value) => {}, "float4x4", true); + this.registerJsonPointer(`/nodes/${nodeCount}/matrix`, (path) => { + const node = this.traversePath(path); + if (node === undefined) { + return undefined; + } + return node.getLocalTransform(); // gl-matrix uses column-major order + }, (path, value) => {}, "float4x4", true); + this.registerJsonPointer(`/nodes/${nodeCount}/parent`, (path) => { + // TODO Use implementation from gltfx demo + }, (path, value) => {}, "int", true); + this.registerJsonPointer(`/nodes/${nodeCount}/extensions/KHR_lights_punctual/light`, (path) => { + return this.traversePath(path); + }, (path, value) => {}, "int", true); + + const sceneCount = this.world.gltf.scenes.length; + this.registerJsonPointer(`/scenes/${sceneCount}/nodes/${nodeCount}`, (path) => { + return this.traversePath(path); + }, (path, value) => {}, "int", true); + + const skinCount = this.world.gltf.skins.length; + this.registerJsonPointer(`/skins/${skinCount}/joints/${nodeCount}`, (path) => { + return this.traversePath(path); + }, (path, value) => {}, "int", true); + + const animationCount = this.world.gltf.animations.length; + this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/isPlaying`, (path) => { + const pathParts = path.split('/'); + const animationIndex = parseInt(pathParts[2]); + if (isNaN(animationIndex) || animationIndex < 0 || animationIndex >= this.world.gltf.animations.length) { + return undefined; + } + const animation = this.world.gltf.animations[animationIndex]; + return animation.createdTimestamp !== undefined; + }, (path, value) => {}, "bool", true); + this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/minTime`, (path) => { + const pathParts = path.split('/'); + const animationIndex = parseInt(pathParts[2]); + if (isNaN(animationIndex) || animationIndex < 0 || animationIndex >= this.world.gltf.animations.length) { + return NaN; + } + const animation = this.world.gltf.animations[animationIndex]; + animation.computeMinMaxTime(); + return animation.minTime; + }, (path, value) => {}, "float", true); + this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/maxTime`, (path) => { + const pathParts = path.split('/'); + const animationIndex = parseInt(pathParts[2]); + if (isNaN(animationIndex) || animationIndex < 0 || animationIndex >= this.world.gltf.animations.length) { + return NaN; + } + const animation = this.world.gltf.animations[animationIndex]; + animation.computeMinMaxTime(); + return animation.maxTime; + }, (path, value) => {}, "float", true); + this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/playhead`, (path) => { + const pathParts = path.split('/'); + const animationIndex = parseInt(pathParts[2]); + if (isNaN(animationIndex) || animationIndex < 0 || animationIndex >= this.world.gltf.animations.length) { + return NaN; + } + const animation = this.world.gltf.animations[animationIndex]; + if (animation.interpolators.length === 0) { + return NaN; + } + return animation.interpolators[0].prevT; + }, (path, value) => {}, "float", true); + this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/virtualPlayhead`, (path) => { + const pathParts = path.split('/'); + const animationIndex = parseInt(pathParts[2]); + if (isNaN(animationIndex) || animationIndex < 0 || animationIndex >= this.world.gltf.animations.length) { + return NaN; + } + const animation = this.world.gltf.animations[animationIndex]; + if (animation.interpolators.length === 0) { + return NaN; + } + return animation.interpolators[0].prevRequestedT; + }, (path, value) => {}, "float", true); + this.registerJsonPointer(`/extensions/KHR_interactivity/activeCamera/rotation`, (path) => { let activeCamera = this.world.userCamera; if (this.world.cameraIndex !== undefined && this.world.gltf.cameras.length > this.world.cameraIndex) { @@ -346,6 +476,12 @@ class SampleViewerDecorator extends interactivity.ADecorator { }, (path, value) => { //no-op }, "float4", true); + + this.registerJsonPointer(`/extensions/KHR_interactivity/activeCamera/position`, (path) => { + + }, (path, value) => { + //no-op + }, "float3", true); } registerJsonPointer(jsonPtr, getterCallback, setterCallback, typeName, readOnly) { diff --git a/source/gltf/interpolator.js b/source/gltf/interpolator.js index 81ed05b4..80d21740 100644 --- a/source/gltf/interpolator.js +++ b/source/gltf/interpolator.js @@ -10,6 +10,7 @@ class gltfInterpolator { this.prevKey = 0; this.prevT = 0.0; + this.prevRequestedT = 0.0; } slerpQuat(q1, q2, t) @@ -96,6 +97,8 @@ class gltfInterpolator const input = gltf.accessors[sampler.input].getNormalizedDeinterlacedView(gltf); const output = gltf.accessors[sampler.output].getNormalizedDeinterlacedView(gltf); + this.prevRequestedT = t; + if(output.length === stride) // no interpolation for single keyFrame animations { return jsToGlSlice(output, 0, stride); diff --git a/source/gltf/material.js b/source/gltf/material.js index 7e9d1e45..122f9baa 100644 --- a/source/gltf/material.js +++ b/source/gltf/material.js @@ -6,6 +6,7 @@ import { GltfObject } from './gltf_object.js'; class gltfMaterial extends GltfObject { static animatedProperties = ["alphaCutoff", "emissiveFactor"]; + static readOnlyAnimatedProperties = ["doubleSided"]; constructor() { super(); diff --git a/source/gltf/mesh.js b/source/gltf/mesh.js index ed4f383c..d2812e35 100644 --- a/source/gltf/mesh.js +++ b/source/gltf/mesh.js @@ -5,6 +5,7 @@ import { GltfObject } from './gltf_object.js'; class gltfMesh extends GltfObject { static animatedProperties = ["weights"]; + static readOnlyAnimatedProperties = ["weights", "primitives"]; constructor() { super(); diff --git a/source/gltf/node.js b/source/gltf/node.js index 44731109..af1703a8 100644 --- a/source/gltf/node.js +++ b/source/gltf/node.js @@ -14,6 +14,13 @@ class gltfNode extends GltfObject "translation", "weights" ]; + static readOnlyAnimatedProperties = [ + "camera", + "children", + "mesh", + "skin", + "weights" + ]; constructor() { super(); diff --git a/source/gltf/primitive.js b/source/gltf/primitive.js index 99744b43..ada54e86 100644 --- a/source/gltf/primitive.js +++ b/source/gltf/primitive.js @@ -16,6 +16,7 @@ import { generateTangents } from '../libs/mikktspace.js'; class gltfPrimitive extends GltfObject { static animatedProperties = []; + static readOnlyAnimatedProperties = ["material"]; constructor() { super(); diff --git a/source/gltf/scene.js b/source/gltf/scene.js index 885c3ad7..fbf3b96b 100644 --- a/source/gltf/scene.js +++ b/source/gltf/scene.js @@ -4,6 +4,7 @@ import { GltfObject } from './gltf_object'; class gltfScene extends GltfObject { static animatedProperties = []; + static readOnlyAnimatedProperties = ["nodes"]; constructor(nodes = [], name = undefined) { super(); diff --git a/source/gltf/skin.js b/source/gltf/skin.js index d34a59b7..e00be3db 100644 --- a/source/gltf/skin.js +++ b/source/gltf/skin.js @@ -11,6 +11,7 @@ import { gltfSampler } from './sampler.js'; class gltfSkin extends GltfObject { static animatedProperties = []; + static readOnlyAnimatedProperties = ["joints", "skeleton"]; constructor() { super(); From d22309b27c6459c653770dbd1fd4ce24fd46e8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 12 Aug 2025 12:12:47 +0200 Subject: [PATCH 16/82] Register pointer for activeCamera --- source/gltf/interactivity.js | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 81627a27..29204ad1 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -469,8 +469,15 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerJsonPointer(`/extensions/KHR_interactivity/activeCamera/rotation`, (path) => { let activeCamera = this.world.userCamera; - if (this.world.cameraIndex !== undefined && this.world.gltf.cameras.length > this.world.cameraIndex) { - activeCamera = this.world.gltf.cameras[this.world.cameraIndex]; + if (this.world.cameraNodeIndex !== undefined) { + if (this.world.cameraNodeIndex < 0 || this.world.cameraNodeIndex >= this.world.gltf.nodes.length) { + return [NaN, NaN, NaN, NaN]; + } + const cameraIndex = this.world.gltf.nodes[this.world.cameraNodeIndex].camera; + if (cameraIndex === undefined) { + return [NaN, NaN, NaN, NaN]; + } + activeCamera = this.world.gltf.cameras[cameraIndex]; } return activeCamera.getRotation(); }, (path, value) => { @@ -478,7 +485,18 @@ class SampleViewerDecorator extends interactivity.ADecorator { }, "float4", true); this.registerJsonPointer(`/extensions/KHR_interactivity/activeCamera/position`, (path) => { - + let activeCamera = this.world.userCamera; + if (this.world.cameraNodeIndex !== undefined) { + if (this.world.cameraNodeIndex < 0 || this.world.cameraNodeIndex >= this.world.gltf.nodes.length) { + return [NaN, NaN, NaN]; + } + const cameraIndex = this.world.gltf.nodes[this.world.cameraNodeIndex].camera; + if (cameraIndex === undefined) { + return [NaN, NaN, NaN]; + } + activeCamera = this.world.gltf.cameras[cameraIndex]; + } + return activeCamera.getPosition(); }, (path, value) => { //no-op }, "float3", true); From df1343dca1d5b475edcf811a772afff1e927e697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 21 Aug 2025 18:12:06 +0200 Subject: [PATCH 17/82] Fix stopGraph function --- source/GltfState/gltf_state.js | 3 +++ source/gltf/interactivity.js | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/source/GltfState/gltf_state.js b/source/GltfState/gltf_state.js index caa8ad01..07599c55 100644 --- a/source/GltfState/gltf_state.js +++ b/source/GltfState/gltf_state.js @@ -66,6 +66,9 @@ class GltfState KHR_materials_dispersion: true, KHR_materials_emissive_strength: true, KHR_interactivity: true, + KHR_node_hoverability: true, + KHR_node_selectability: true, + KHR_node_visibility: true }, /** clear color expressed as list of ints in the range [0, 255] */ clearColor: [58, 64, 74, 255], diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 29204ad1..2abd184e 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -49,7 +49,7 @@ class GraphController { * @return {Array} An array of custom events defined in the graph. */ startGraph(graphIndex) { - this.engine.clearCustomEventListeners(); + this.decorator.resetGraph(); try { this.customEvents = this.decorator.loadGraph(graphIndex); this.graphIndex = graphIndex; @@ -62,15 +62,17 @@ class GraphController { } /** - * Stops the currently playing graph. + * Stops the graph engine. */ - stopGraph() { + stopGraphEngine() { if (this.graphIndex === undefined) { return; } this.graphIndex = undefined; this.playing = false; - this.engine.clearCustomEventListeners(); + this.reset = false; + this.decorator.pauseEventQueue(); + this.decorator.resetGraph(); } /** @@ -197,6 +199,12 @@ class SampleViewerDecorator extends interactivity.ADecorator { parent.animatedPropertyObjects[propertyName].rest(); }; this.recurseAllAnimatedProperties(this.world.gltf, resetAnimatedProperty); + this.behaveEngine.clearCustomEventListeners(); + this.behaveEngine.clearEventList(); + this.behaveEngine.clearPointerInterpolation(); + this.behaveEngine.clearVariableInterpolation(); + this.behaveEngine.clearScheduledDelays(); + this.behaveEngine.clearValueEvaluationCache(); } processNodeStarted(node) { From 2d869ee5d87c5b2b761d0c3c70bb16288a8bcd7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 27 Aug 2025 14:38:10 +0200 Subject: [PATCH 18/82] Fix negative start/endTime --- source/gltf/animation.js | 19 +++++++++++++++---- source/gltf/interpolator.js | 8 ++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/source/gltf/animation.js b/source/gltf/animation.js index 02520b69..49639bfc 100644 --- a/source/gltf/animation.js +++ b/source/gltf/animation.js @@ -75,8 +75,14 @@ class gltfAnimation extends GltfObject const channel = this.channels[i]; const sampler = this.samplers[channel.sampler]; const input = gltf.accessors[sampler.input]; - const max = input.max; - const min = input.min; + if (input.max === undefined || input.min === undefined || input.max.length !== 1 || input.min.length !== 1) { + console.error("Invalid input accessor for animation channel:", channel); + this.minTime = undefined; + this.maxTime = undefined; + return; + } + const max = input.max[0]; + const min = input.min[0]; if(max > this.maxTime) { this.maxTime = max; @@ -99,16 +105,21 @@ class gltfAnimation extends GltfObject this.computeMinMaxTime(gltf); + if (this.maxTime === undefined || this.minTime === undefined) { + return; + } + let stopAnimation = false; let endAnimation = false; let elapsedTime = totalTime; - + let reverse = false; if (this.createdTimestamp !== undefined) { elapsedTime = totalTime - this.createdTimestamp; elapsedTime *= this.speed; if (this.startTime > this.endTime) { elapsedTime *= -1; + reverse = true; } elapsedTime += this.startTime; if (this.startTime === this.endTime) { @@ -189,7 +200,7 @@ class gltfAnimation extends GltfObject stride = animatedProperty.restValue[animatedArrayElement]?.length ?? 1; } - const interpolant = interpolator.interpolate(gltf, channel, sampler, elapsedTime, stride, this.maxTime); + const interpolant = interpolator.interpolate(gltf, channel, sampler, elapsedTime, stride, this.maxTime, reverse); if (interpolant === undefined) { animatedProperty.rest(); continue; diff --git a/source/gltf/interpolator.js b/source/gltf/interpolator.js index 80d21740..66bb745d 100644 --- a/source/gltf/interpolator.js +++ b/source/gltf/interpolator.js @@ -87,7 +87,7 @@ class gltfInterpolator this.prevKey = 0; } - interpolate(gltf, channel, sampler, t, stride, maxTime) + interpolate(gltf, channel, sampler, t, stride, maxTime, reverse) { if(t === undefined) { @@ -113,12 +113,12 @@ class gltfInterpolator } t = clamp(t, input[0], input[input.length - 1]); - if (this.prevT > t && !isNegative) + if (this.prevT > t && !reverse) { this.prevKey = 0; } - if (isNegative && this.prevT < t) + if (reverse && this.prevT < t) { this.prevKey = input.length - 1; } @@ -127,7 +127,7 @@ class gltfInterpolator // Find next keyframe: min{ t of input | t > prevKey } let nextKey = null; - if (isNegative) { + if (reverse) { for (let i = this.prevKey; i >= 0; --i) { if (t >= input[i]) From 31ea96d723ab55aaf5bee909a5147a213cb1ca84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 27 Aug 2025 17:48:53 +0200 Subject: [PATCH 19/82] Fix undefined access --- source/gltf/interactivity.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 2abd184e..81c5f5f1 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -167,7 +167,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { } loadGraph(graphIndex) { - const graphArray = this.world?.gltf?.extensions?.KHR_interactivity.graphs; + const graphArray = this.world?.gltf?.extensions?.KHR_interactivity?.graphs; if (graphArray && graphArray.length > graphIndex) { const graphCopy = JSON.parse(JSON.stringify(graphArray[graphIndex])); let events = graphCopy.events ?? []; From 640ccb5224a87f63c5b4d03d790c14641e14d80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 27 Aug 2025 17:50:16 +0200 Subject: [PATCH 20/82] Add type checking for accessor access --- source/gltf/gltf.js | 1 + source/gltf/node.js | 21 ++++++++++++++++++--- source/gltf/primitive.js | 31 +++++++++++++++++++++++++++---- source/gltf/skin.js | 13 +++++++++---- 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/source/gltf/gltf.js b/source/gltf/gltf.js index e8e558e2..fb8bfc27 100644 --- a/source/gltf/gltf.js +++ b/source/gltf/gltf.js @@ -19,6 +19,7 @@ import { gltfVariant } from './variant.js'; import { gltfGraph } from './interactivity.js'; const allowedExtensions = [ + "KHR_accessor_float64", "KHR_animation_pointer", "KHR_draco_mesh_compression", "KHR_interactivity", diff --git a/source/gltf/node.js b/source/gltf/node.js index af1703a8..1f24d771 100644 --- a/source/gltf/node.js +++ b/source/gltf/node.js @@ -1,6 +1,7 @@ import { mat4, quat, vec3 } from 'gl-matrix'; import { jsToGl, jsToGlSlice } from './utils.js'; import { GltfObject } from './gltf_object.js'; +import { GL } from '../Renderer/webgl.js'; // contain: // transform @@ -53,17 +54,31 @@ class gltfNode extends GltfObject const translationAccessor = this.extensions?.EXT_mesh_gpu_instancing?.attributes?.TRANSLATION; let translationData = undefined; if (translationAccessor !== undefined) { - translationData = gltf.accessors[translationAccessor].getDeinterlacedView(gltf); + if (translationAccessor.componentType === GL.FLOAT) { + translationData = gltf.accessors[translationAccessor].getDeinterlacedView(gltf); + } else { + console.warn("EXT_mesh_gpu_instancing translation accessor must be a float"); + } } const rotationAccessor = this.extensions?.EXT_mesh_gpu_instancing?.attributes?.ROTATION; let rotationData = undefined; if (rotationAccessor !== undefined) { - rotationData = gltf.accessors[rotationAccessor].getDeinterlacedView(gltf); + if (rotationAccessor.componentType === GL.FLOAT || + (rotationAccessor.normalized && + (rotationAccessor.componentType === GL.BYTE || rotationAccessor.componentType === GL.SHORT))) { + rotationData = gltf.accessors[rotationAccessor].getNormalizedDeinterlacedView(gltf); + } else { + console.warn("EXT_mesh_gpu_instancing rotation accessor must be a float, byte normalized, or short normalized"); + } } const scaleAccessor = this.extensions?.EXT_mesh_gpu_instancing?.attributes?.SCALE; let scaleData = undefined; if (scaleAccessor !== undefined) { - scaleData = gltf.accessors[scaleAccessor].getDeinterlacedView(gltf); + if (scaleAccessor.componentType === GL.FLOAT) { + scaleData = gltf.accessors[scaleAccessor].getDeinterlacedView(gltf); + } else { + console.warn("EXT_mesh_gpu_instancing scale accessor must be a float"); + } } this.instanceMatrices = []; for (let i = 0; i < count; i++) { diff --git a/source/gltf/primitive.js b/source/gltf/primitive.js index ada54e86..31a86431 100644 --- a/source/gltf/primitive.js +++ b/source/gltf/primitive.js @@ -76,7 +76,7 @@ class gltfPrimitive extends GltfObject } // Generate tangents with Mikktspace which needs normals and texcoords as inputs for triangles - if (this.attributes.TANGENT === undefined && this.attributes.NORMAL && this.attributes.TEXCOORD_0 && this.mode > 3) + if (this.attributes.TANGENT === undefined && this.attributes.NORMAL !== undefined && this.attributes.TEXCOORD_0 !== undefined && this.mode > 3) { console.info("Generating tangents using the MikkTSpace algorithm."); console.time("Tangent generation"); @@ -809,9 +809,32 @@ class gltfPrimitive extends GltfObject return; } - const positions = gltf.accessors[this.attributes.POSITION].getTypedView(gltf); - const normals = gltf.accessors[this.attributes.NORMAL].getTypedView(gltf); - const texcoords = gltf.accessors[this.attributes.TEXCOORD_0].getTypedView(gltf); + let positions = gltf.accessors[this.attributes.POSITION].getNormalizedDeinterlacedView(gltf); + const normals = gltf.accessors[this.attributes.NORMAL].getNormalizedDeinterlacedView(gltf); + let texcoords = gltf.accessors[this.attributes.TEXCOORD_0].getNormalizedDeinterlacedView(gltf); + + + if (positions instanceof Float64Array) { + console.warn("Cannot generate tangents: WebGL2 does not support 64-bit float attributes."); + return; + } else if (positions instanceof Float32Array === false) { + positions = new Float32Array(positions); + } + + if (normals instanceof Float64Array) { + console.warn("Cannot generate tangents: WebGL2 does not support 64-bit float attributes."); + return; + } else if (normals instanceof Float32Array === false) { + console.warn("Cannot generate tangents: Normal attribute in wrong format"); + return; + } + + if (texcoords instanceof Float64Array) { + console.warn("Cannot generate tangents: WebGL2 does not support 64-bit float attributes."); + return; + } else if (texcoords instanceof Float32Array === false) { + texcoords = new Float32Array(texcoords); + } const tangents = generateTangents(positions, normals, texcoords); diff --git a/source/gltf/skin.js b/source/gltf/skin.js index e00be3db..23115e42 100644 --- a/source/gltf/skin.js +++ b/source/gltf/skin.js @@ -70,9 +70,14 @@ class gltfSkin extends GltfObject computeJoints(gltf, webGlContext) { - let ibmAccessor = null; + let ibmAccessorData = null; if (this.inverseBindMatrices !== undefined) { - ibmAccessor = gltf.accessors[this.inverseBindMatrices].getDeinterlacedView(gltf); + const ibmAccessor = gltf.accessors[this.inverseBindMatrices]; + if (ibmAccessor.componentType === GL.FLOAT) { + ibmAccessorData = ibmAccessor.getDeinterlacedView(gltf); + } else { + console.warn("EXT_mesh_gpu_instancing inverseBindMatrices accessor must be a float"); + } } this.jointMatrices = []; @@ -88,8 +93,8 @@ class gltfSkin extends GltfObject let jointMatrix = mat4.clone(node.worldTransform); - if (ibmAccessor !== null) { - let ibm = jsToGlSlice(ibmAccessor, i * 16, 16); + if (ibmAccessorData !== null) { + let ibm = jsToGlSlice(ibmAccessorData, i * 16, 16); mat4.mul(jointMatrix, jointMatrix, ibm); } From de1f3fbd63196fe2b05dbacc5dcef7060fd0b25f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 29 Aug 2025 14:41:48 +0200 Subject: [PATCH 21/82] Add support for 64bit floats --- source/Renderer/webgl.js | 3 +++ source/gltf/accessor.js | 12 ++++++++++++ source/gltf/interpolator.js | 36 ++++++++++++++++++++++++++++++++---- source/gltf/primitive.js | 4 ++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/source/Renderer/webgl.js b/source/Renderer/webgl.js index b1376f31..fefc0b88 100644 --- a/source/Renderer/webgl.js +++ b/source/Renderer/webgl.js @@ -151,6 +151,9 @@ class gltfWebGl if (gltfAccessor.glBuffer === undefined) { + if (gltfAccessor.componentType === 5130) { + throw new Error("64-bit float attributes are not supported in WebGL2"); + } gltfAccessor.glBuffer = this.context.createBuffer(); let data = gltfAccessor.getTypedView(gltf); diff --git a/source/gltf/accessor.js b/source/gltf/accessor.js index 22a0ea58..81e964c2 100644 --- a/source/gltf/accessor.js +++ b/source/gltf/accessor.js @@ -89,6 +89,9 @@ class gltfAccessor extends GltfObject case GL.FLOAT: this.typedView = new Float32Array(buffer.buffer, byteOffset, arrayLength); break; + case 5130: // KHR_accessor_float64 + this.typedView = new Float64Array(buffer.buffer, byteOffset, arrayLength); + break; } } else @@ -170,6 +173,10 @@ class gltfAccessor extends GltfObject this.filteredView = new Float32Array(arrayLength); func = 'getFloat32'; break; + case 5130: // KHR_accessor_float64 + this.filteredView = new Float64Array(arrayLength); + func = 'getFloat64'; + break; } for(let i = 0; i < arrayLength; ++i) @@ -297,6 +304,9 @@ class gltfAccessor extends GltfObject case GL.FLOAT: valuesTypedView = new Float32Array(valuesBuffer.buffer, valuesByteOffset, valuesArrayLength); break; + case 5130: // KHR_accessor_float64 + valuesTypedView = new Float64Array(valuesBuffer.buffer, valuesByteOffset, valuesArrayLength); + break; } // Overwrite values. @@ -347,6 +357,8 @@ class gltfAccessor extends GltfObject case GL.UNSIGNED_INT: case GL.FLOAT: return 4; + case 5130: // KHR_accessor_float64 + return 8; default: return 0; } diff --git a/source/gltf/interpolator.js b/source/gltf/interpolator.js index 66bb745d..7d6bb58e 100644 --- a/source/gltf/interpolator.js +++ b/source/gltf/interpolator.js @@ -1,4 +1,3 @@ -import { max } from 'rxjs/operators'; import { InterpolationModes } from './animation_sampler.js'; import { InterpolationPath } from './channel.js'; import { clamp, jsToGlSlice } from './utils.js'; @@ -15,6 +14,10 @@ class gltfInterpolator slerpQuat(q1, q2, t) { + if (q1 instanceof Float64Array || q2 instanceof Float64Array) + { + glMatrix.ARRAY_TYPE = Float64Array; + } const qn1 = quat.create(); const qn2 = quat.create(); @@ -26,30 +29,40 @@ class gltfInterpolator quat.slerp(quatResult, qn1, qn2, t); quat.normalize(quatResult, quatResult); + glMatrix.ARRAY_TYPE = Float32Array; + return quatResult; } step(prevKey, output, stride) { + if (output instanceof Float64Array) + { + glMatrix.ARRAY_TYPE = Float64Array; + } const result = new glMatrix.ARRAY_TYPE(stride); for(let i = 0; i < stride; ++i) { result[i] = output[prevKey * stride + i]; } - + glMatrix.ARRAY_TYPE = Float32Array; return result; } linear(prevKey, nextKey, output, t, stride) { + if (output instanceof Float64Array) + { + glMatrix.ARRAY_TYPE = Float64Array; + } const result = new glMatrix.ARRAY_TYPE(stride); for(let i = 0; i < stride; ++i) { result[i] = output[prevKey * stride + i] * (1-t) + output[nextKey * stride + i] * t; } - + glMatrix.ARRAY_TYPE = Float32Array; return result; } @@ -63,6 +76,10 @@ class gltfInterpolator const V = 1 * stride; const B = 2 * stride; + if (output instanceof Float64Array) + { + glMatrix.ARRAY_TYPE = Float64Array; + } const result = new glMatrix.ARRAY_TYPE(stride); const tSq = t ** 2; const tCub = t ** 3; @@ -79,6 +96,8 @@ class gltfInterpolator result[i] = ((2*tCub - 3*tSq + 1) * v0) + ((tCub - 2*tSq + t) * b) + ((-2*tCub + 3*tSq) * v1) + ((tCub - tSq) * a); } + glMatrix.ARRAY_TYPE = Float32Array; + return result; } @@ -101,7 +120,13 @@ class gltfInterpolator if(output.length === stride) // no interpolation for single keyFrame animations { - return jsToGlSlice(output, 0, stride); + if (output instanceof Float64Array) + { + glMatrix.ARRAY_TYPE = Float64Array; + } + const result = jsToGlSlice(output, 0, stride); + glMatrix.ARRAY_TYPE = Float32Array; + return result; } // Wrap t around, so the animation loops. @@ -197,6 +222,9 @@ class gltfInterpolator const y = output[4 * index + 1]; const z = output[4 * index + 2]; const w = output[4 * index + 3]; + if (output instanceof Float64Array) { + return new Float64Array([x, y, z, w]); + } return quat.fromValues(x, y, z, w); } } diff --git a/source/gltf/primitive.js b/source/gltf/primitive.js index 31a86431..82c101a5 100644 --- a/source/gltf/primitive.js +++ b/source/gltf/primitive.js @@ -294,6 +294,10 @@ class gltfPrimitive extends GltfObject const positionsAccessor = gltf.accessors[this.attributes.POSITION]; const positions = positionsAccessor.getNormalizedTypedView(gltf); + if (positions instanceof Float64Array) { + throw new Error("64-bit float attributes are not supported in WebGL2"); + } + if(this.indices !== undefined) { // Primitive has indices. From bf2e06e7ed2b2ce2a8415b3f423bf5cfefa3d678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 29 Aug 2025 16:35:30 +0200 Subject: [PATCH 22/82] Update Readme --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 2b128673..607e32ce 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,11 @@ Features -------- - [x] glTF 2.0 +- [KHR_accessor_float64](https://github.com/KhronosGroup/glTF/pull/2397) + - [x] Animations + - [x] KHR_animation_pointer + - [ ] Mesh Attributes not supported since WebGL2 only supports 32 bit + - [ ] Skins not supported since WebGL2 only supports 32 bit - [x] [KHR_animation_pointer](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_animation_pointer) - [x] [KHR_draco_mesh_compression](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_draco_mesh_compression) - [x] [KHR_lights_punctual](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_lights_punctual) From 81769f8c0f24671a19155697765f44a05f0280d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 29 Aug 2025 16:37:21 +0200 Subject: [PATCH 23/82] Added picking pass Changes manually copied and adjusted from the gltfx branch --- source/GltfState/gltf_state.js | 10 ++ source/Renderer/renderer.js | 151 ++++++++++++++++++++++----- source/Renderer/shaders/picking.frag | 9 ++ source/Renderer/shaders/picking.vert | 36 +++++++ source/gltf/node.js | 10 +- source/gltf/scene.js | 9 +- 6 files changed, 194 insertions(+), 31 deletions(-) create mode 100644 source/Renderer/shaders/picking.frag create mode 100644 source/Renderer/shaders/picking.vert diff --git a/source/GltfState/gltf_state.js b/source/GltfState/gltf_state.js index 07599c55..26585eb0 100644 --- a/source/GltfState/gltf_state.js +++ b/source/GltfState/gltf_state.js @@ -37,6 +37,16 @@ class GltfState /** the graph controller allows selecting and playing graphs from KHR_interactivity */ this.graphController = new GraphController(); + /** callback for selections */ + this.selectionCallback = undefined; + + /** If the renderer should compute the selection in the next frame */ + this.triggerSelection = false; + + /* screen position of the picking ray */ + this.pickingX = 0; + this.pickingY = 0; + /** parameters used to configure the rendering */ this.renderingParameters = { /** morphing between vertices */ diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index d6cf3859..55e5181c 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -1,10 +1,12 @@ -import { mat4, mat3, vec3, quat } from 'gl-matrix'; +import { mat4, mat3, vec3, quat, vec4 } from 'gl-matrix'; import { ShaderCache } from './shader_cache.js'; import { GltfState } from '../GltfState/gltf_state.js'; import { gltfWebGl, GL } from './webgl.js'; import { EnvironmentRenderer } from './environment_renderer.js'; import pbrShader from './shaders/pbr.frag'; +import pickingShader from './shaders/picking.frag'; +import pickingVertShader from './shaders/picking.vert'; import brdfShader from './shaders/brdf.glsl'; import iridescenceShader from './shaders/iridescence.glsl'; import materialInfoShader from './shaders/material_info.glsl'; @@ -38,12 +40,16 @@ class gltfRenderer this.opaqueRenderTexture = 0; this.opaqueFramebuffer = 0; this.opaqueDepthTexture = 0; + this.pickingIDTexture = 0; + this.pickingDepthTexture = 0; this.opaqueFramebufferWidth = 1024; this.opaqueFramebufferHeight = 1024; const shaderSources = new Map(); shaderSources.set("primitive.vert", primitiveShader); shaderSources.set("pbr.frag", pbrShader); + shaderSources.set("picking.frag", pickingShader); + shaderSources.set("picking.vert", pickingVertShader); shaderSources.set("material_info.glsl", materialInfoShader); shaderSources.set("brdf.glsl", brdfShader); shaderSources.set("iridescence.glsl", iridescenceShader); @@ -127,6 +133,23 @@ class gltfRenderer context.texImage2D( context.TEXTURE_2D, 0, context.DEPTH_COMPONENT24, this.opaqueFramebufferWidth, this.opaqueFramebufferHeight, 0, context.DEPTH_COMPONENT, context.UNSIGNED_INT, null); context.bindTexture(context.TEXTURE_2D, null); + this.pickingIDTexture = context.createTexture(); + context.bindTexture(context.TEXTURE_2D, this.pickingIDTexture); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.NEAREST); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); + context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, this.opaqueFramebufferWidth, this.opaqueFramebufferHeight, 0, context.RGBA, context.UNSIGNED_BYTE, null); + context.bindTexture(context.TEXTURE_2D, null); + + this.pickingDepthTexture = context.createTexture(); + context.bindTexture(context.TEXTURE_2D, this.pickingDepthTexture); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.NEAREST); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); + context.texImage2D( context.TEXTURE_2D, 0, context.DEPTH_COMPONENT16, this.opaqueFramebufferWidth, this.opaqueFramebufferHeight, 0, context.DEPTH_COMPONENT, context.UNSIGNED_SHORT, null); + context.bindTexture(context.TEXTURE_2D, null); this.colorRenderBuffer = context.createRenderbuffer(); context.bindRenderbuffer(context.RENDERBUFFER, this.colorRenderBuffer); @@ -139,6 +162,11 @@ class gltfRenderer context.DEPTH_COMPONENT24, this.opaqueFramebufferWidth, this.opaqueFramebufferHeight); + + this.pickingFramebuffer = context.createFramebuffer(); + context.bindFramebuffer(context.FRAMEBUFFER, this.pickingFramebuffer); + context.framebufferTexture2D(context.FRAMEBUFFER, context.COLOR_ATTACHMENT0, context.TEXTURE_2D, this.pickingIDTexture, 0); + context.framebufferTexture2D(context.FRAMEBUFFER, context.DEPTH_ATTACHMENT, context.TEXTURE_2D, this.pickingDepthTexture, 0); this.samples = samples; @@ -189,6 +217,15 @@ class gltfRenderer this.currentHeight = height; this.currentWidth = width; this.webGl.context.viewport(0, 0, width, height); + if (this.initialized) { + this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); + this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, this.pickingIDTexture); + this.webGl.context.texImage2D(this.webGl.context.TEXTURE_2D, 0, this.webGl.context.RGBA, this.currentWidth, this.currentHeight, 0, this.webGl.context.RGBA, this.webGl.context.UNSIGNED_BYTE, null); + this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, null); + this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, this.pickingDepthTexture); + this.webGl.context.texImage2D(this.webGl.context.TEXTURE_2D, 0, this.webGl.context.DEPTH_COMPONENT16, this.currentWidth, this.currentHeight, 0, this.webGl.context.DEPTH_COMPONENT, this.webGl.context.UNSIGNED_SHORT, null); + this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, null); + } } } @@ -206,6 +243,10 @@ class gltfRenderer this.webGl.context.clearColor(...clearColor); this.webGl.context.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, null); + this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); + this.webGl.context.clearColor(0, 0, 0, 0); + this.webGl.context.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT); + this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, null); } prepareScene(state, scene) { @@ -219,6 +260,7 @@ class gltfRenderer return {node: node, primitive: primitive, primitiveIndex: index}; })), []) .filter(({primitive}) => primitive.material !== undefined); + this.drawables = drawables; // opaque drawables don't need sorting this.opaqueDrawables = drawables @@ -346,6 +388,20 @@ class gltfRenderer instanceWorldTransforms.push(instanceOffset); } + if (state.triggerSelection) { + this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); + this.webGl.context.viewport(0, 0, this.currentWidth, this.currentHeight); + + const fragDefines = []; + this.pushFragParameterDefines(fragDefines, state); + for (const drawable of this.drawables) + { + let renderpassConfiguration = {}; + renderpassConfiguration.picking = true; + this.drawPrimitive(state, renderpassConfiguration, drawable.primitive, drawable.node, this.viewProjectionMatrix); + } + } + // If any transmissive drawables are present, render all opaque and transparent drawables into a separate framebuffer. if (this.transmissionDrawables.length > 0) { // Render transmission sample texture @@ -422,6 +478,34 @@ class gltfRenderer renderpassConfiguration.linearOutput = false; this.drawPrimitive(state, renderpassConfiguration, drawable.primitive, drawable.node, this.viewProjectionMatrix); } + + // Handle selection + if (state.triggerSelection) { + this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); + this.webGl.context.viewport(0, 0, this.currentWidth, this.currentHeight); + state.triggerSelection = false; + const pickingY = this.currentHeight - state.pickingY; + this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT0); + const pixels = new Uint8Array(4); + this.webGl.context.readPixels(state.pickingX ?? this.currentWidth / 2, pickingY ?? this.currentHeight / 2, 1, 1, this.webGl.context.RGBA, this.webGl.context.UNSIGNED_BYTE, pixels); + + let pickingResult = { + node: undefined, + }; + + for (const node of state.gltf.nodes) + { + if (node.pickingColor && vec4.equals(node.pickingColor, vec4.fromValues(pixels[0] / 255, pixels[1] / 255, pixels[2] / 255, pixels[3] / 255))) + { + pickingResult.node = node; + break; + } + } + + if (state.selectionCallback){ + state.selectionCallback(pickingResult); + } + } } // vertices with given material @@ -481,11 +565,15 @@ class gltfRenderer } this.pushFragParameterDefines(fragDefines, state); - - const fragmentShader = material.type === "SG" ? "specular_glossiness.frag" : "pbr.frag"; + + const vertexShader = renderpassConfiguration.picking ? "picking.vert" : "primitive.vert"; + let fragmentShader = material.type === "SG" ? "specular_glossiness.frag" : "pbr.frag"; + if (renderpassConfiguration.picking) { + fragmentShader = "picking.frag"; + } const fragmentHash = this.shaderCache.selectShader(fragmentShader, fragDefines); - const vertexHash = this.shaderCache.selectShader("primitive.vert", vertDefines); + const vertexHash = this.shaderCache.selectShader(vertexShader, vertDefines); if (fragmentHash && vertexHash) { @@ -499,7 +587,7 @@ class gltfRenderer this.webGl.context.useProgram(this.shader.program); - if (state.renderingParameters.usePunctual) + if (state.renderingParameters.usePunctual && !renderpassConfiguration.picking) { this.applyLights(); } @@ -510,7 +598,9 @@ class gltfRenderer this.shader.updateUniform("u_NormalMatrix", node.normalMatrix, false); this.shader.updateUniform("u_Exposure", state.renderingParameters.exposure, false); this.shader.updateUniform("u_Camera", this.currentCameraPosition, false); - + if (renderpassConfiguration.picking) { + this.shader.updateUniform("u_PickingColor", node.pickingColor, false); + } this.updateAnimationUniforms(state, node, primitive); @@ -523,7 +613,7 @@ class gltfRenderer this.webGl.context.frontFace(GL.CCW); } - if (material.doubleSided) + if (material.doubleSided || renderpassConfiguration.picking) { this.webGl.context.disable(GL.CULL_FACE); } @@ -532,7 +622,7 @@ class gltfRenderer this.webGl.context.enable(GL.CULL_FACE); } - if (material.alphaMode === 'BLEND') + if (material.alphaMode === 'BLEND' && !renderpassConfiguration.picking) { this.webGl.context.enable(GL.BLEND); this.webGl.context.blendFuncSeparate(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA, GL.ONE, GL.ONE_MINUS_SRC_ALPHA); @@ -556,6 +646,9 @@ class gltfRenderer let vertexCount = 0; for (const attribute of primitive.glAttributes) { + if (renderpassConfiguration.picking && (attribute.attribute !== "POSITION" || attribute.attribute.startsWith("JOINTS") || attribute.attribute.startsWith("WEIGHTS"))) { + continue; + } const gltfAccessor = state.gltf.accessors[attribute.accessor]; vertexCount = gltfAccessor.count; @@ -706,32 +799,33 @@ class gltfRenderer textureIndex++; } - let textureCount = textureIndex; + if (!renderpassConfiguration.picking) { + let textureCount = textureIndex; - textureCount = this.applyEnvironmentMap(state, textureCount); + textureCount = this.applyEnvironmentMap(state, textureCount); - if (state.environment !== undefined) - { - this.webGl.setTexture(this.shader.getUniformLocation("u_SheenELUT"), state.environment, state.environment.sheenELUT, textureCount++); - } + if (state.environment !== undefined) + { + this.webGl.setTexture(this.shader.getUniformLocation("u_SheenELUT"), state.environment, state.environment.sheenELUT, textureCount++); + } - if(transmissionSampleTexture !== undefined && - state.environment && - state.renderingParameters.enabledExtensions.KHR_materials_transmission) - { - this.webGl.context.activeTexture(GL.TEXTURE0 + textureCount); - this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, this.opaqueRenderTexture); - this.webGl.context.uniform1i(this.shader.getUniformLocation("u_TransmissionFramebufferSampler"), textureCount); - textureCount++; + if(transmissionSampleTexture !== undefined && + state.environment && + state.renderingParameters.enabledExtensions.KHR_materials_transmission) + { + this.webGl.context.activeTexture(GL.TEXTURE0 + textureCount); + this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, this.opaqueRenderTexture); + this.webGl.context.uniform1i(this.shader.getUniformLocation("u_TransmissionFramebufferSampler"), textureCount); + textureCount++; - this.webGl.context.uniform2i(this.shader.getUniformLocation("u_TransmissionFramebufferSize"), this.opaqueFramebufferWidth, this.opaqueFramebufferHeight); + this.webGl.context.uniform2i(this.shader.getUniformLocation("u_TransmissionFramebufferSize"), this.opaqueFramebufferWidth, this.opaqueFramebufferHeight); - this.webGl.context.uniformMatrix4fv(this.shader.getUniformLocation("u_ModelMatrix"),false, node.worldTransform); - this.webGl.context.uniformMatrix4fv(this.shader.getUniformLocation("u_ViewMatrix"),false, this.viewMatrix); - this.webGl.context.uniformMatrix4fv(this.shader.getUniformLocation("u_ProjectionMatrix"),false, this.projMatrix); + this.webGl.context.uniformMatrix4fv(this.shader.getUniformLocation("u_ModelMatrix"),false, node.worldTransform); + this.webGl.context.uniformMatrix4fv(this.shader.getUniformLocation("u_ViewMatrix"),false, this.viewMatrix); + this.webGl.context.uniformMatrix4fv(this.shader.getUniformLocation("u_ProjectionMatrix"),false, this.projMatrix); + } } - if (drawIndexed) { const indexAccessor = state.gltf.accessors[primitive.indices]; @@ -752,6 +846,9 @@ class gltfRenderer for (const attribute of primitive.glAttributes) { + if (renderpassConfiguration.picking && (attribute.attribute !== "POSITION" || attribute.attribute.startsWith("JOINTS") || attribute.attribute.startsWith("WEIGHTS"))) { + continue; + } const location = this.shader.getAttributeLocation(attribute.name); if (location === null) { diff --git a/source/Renderer/shaders/picking.frag b/source/Renderer/shaders/picking.frag new file mode 100644 index 00000000..a0abc2ca --- /dev/null +++ b/source/Renderer/shaders/picking.frag @@ -0,0 +1,9 @@ +precision highp float; + +layout(location = 0) out vec4 id_color; +uniform vec4 u_PickingColor; +in vec3 v_Position; + +void main() { + id_color = u_PickingColor; +} diff --git a/source/Renderer/shaders/picking.vert b/source/Renderer/shaders/picking.vert new file mode 100644 index 00000000..c4b5b7eb --- /dev/null +++ b/source/Renderer/shaders/picking.vert @@ -0,0 +1,36 @@ +#include + + +uniform mat4 u_ViewProjectionMatrix; +uniform mat4 u_ModelMatrix; +uniform mat4 u_NormalMatrix; + + +in vec3 a_position; +out vec3 v_Position; + + +vec4 getPosition() +{ + vec4 pos = vec4(a_position, 1.0); + +#ifdef USE_MORPHING + pos += getTargetPosition(gl_VertexID); +#endif + +#ifdef USE_SKINNING + pos = getSkinningMatrix() * pos; +#endif + + return pos; +} + + +void main() +{ + gl_PointSize = 1.0f; + vec4 pos = u_ModelMatrix * getPosition(); + v_Position = vec3(pos.xyz) / pos.w; + + gl_Position = u_ViewProjectionMatrix * pos; +} diff --git a/source/gltf/node.js b/source/gltf/node.js index 1f24d771..18ba9de2 100644 --- a/source/gltf/node.js +++ b/source/gltf/node.js @@ -1,4 +1,4 @@ -import { mat4, quat, vec3 } from 'gl-matrix'; +import { mat4, quat, vec3, vec4 } from 'gl-matrix'; import { jsToGl, jsToGlSlice } from './utils.js'; import { GltfObject } from './gltf_object.js'; import { GL } from '../Renderer/webgl.js'; @@ -22,6 +22,7 @@ class gltfNode extends GltfObject "skin", "weights" ]; + static currentPickingColor = 50; constructor() { super(); @@ -44,10 +45,17 @@ class gltfNode extends GltfObject this.light = undefined; this.instanceMatrices = undefined; this.instanceWorldTransforms = undefined; + this.pickingColor = undefined; + this.parent = undefined; } initGl(gltf, webGlContext) { + if (this.mesh !== undefined) { + const mask = 0x000000FF; + this.pickingColor = vec4.fromValues((gltfNode.currentPickingColor & mask) / 255, ((gltfNode.currentPickingColor >>> 8) & mask) / 255, ((gltfNode.currentPickingColor >>> 16) & mask) / 255, ((gltfNode.currentPickingColor >>> 24) & mask) / 255); + gltfNode.currentPickingColor += 50; + } if (this.extensions?.EXT_mesh_gpu_instancing?.attributes !== undefined) { const firstAccessor = Object.values(this.extensions?.EXT_mesh_gpu_instancing?.attributes)[0]; const count = gltf.accessors[firstAccessor].count; diff --git a/source/gltf/scene.js b/source/gltf/scene.js index fbf3b96b..8be551a2 100644 --- a/source/gltf/scene.js +++ b/source/gltf/scene.js @@ -19,8 +19,11 @@ class gltfScene extends GltfObject applyTransformHierarchy(gltf, rootTransform = mat4.create()) { - function applyTransform(gltf, node, parentTransform) + function applyTransform(gltf, node, parent, parentTransform) { + if (node.parentNode === undefined) { + node.parentNode = parent; + } mat4.multiply(node.worldTransform, parentTransform, node.getLocalTransform()); mat4.invert(node.inverseWorldTransform, node.worldTransform); mat4.transpose(node.normalMatrix, node.inverseWorldTransform); @@ -37,12 +40,12 @@ class gltfScene extends GltfObject for (const child of node.children) { - applyTransform(gltf, gltf.nodes[child], node.worldTransform); + applyTransform(gltf, gltf.nodes[child], node, node.worldTransform); } } for (const node of this.nodes) { - applyTransform(gltf, gltf.nodes[node], rootTransform); + applyTransform(gltf, gltf.nodes[node], undefined, rootTransform); } From da894f081b0eafeb0125e915c8288c6af7a86ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Mon, 1 Sep 2025 11:10:22 +0200 Subject: [PATCH 24/82] Remove warnings --- source/gltf/interpolator.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/source/gltf/interpolator.js b/source/gltf/interpolator.js index 7d6bb58e..22c0d4a6 100644 --- a/source/gltf/interpolator.js +++ b/source/gltf/interpolator.js @@ -16,7 +16,7 @@ class gltfInterpolator { if (q1 instanceof Float64Array || q2 instanceof Float64Array) { - glMatrix.ARRAY_TYPE = Float64Array; + glMatrix.setMatrixArrayType(Float64Array); } const qn1 = quat.create(); const qn2 = quat.create(); @@ -29,7 +29,7 @@ class gltfInterpolator quat.slerp(quatResult, qn1, qn2, t); quat.normalize(quatResult, quatResult); - glMatrix.ARRAY_TYPE = Float32Array; + glMatrix.setMatrixArrayType(Float32Array); return quatResult; } @@ -38,7 +38,7 @@ class gltfInterpolator { if (output instanceof Float64Array) { - glMatrix.ARRAY_TYPE = Float64Array; + glMatrix.setMatrixArrayType(Float64Array); } const result = new glMatrix.ARRAY_TYPE(stride); @@ -46,7 +46,7 @@ class gltfInterpolator { result[i] = output[prevKey * stride + i]; } - glMatrix.ARRAY_TYPE = Float32Array; + glMatrix.setMatrixArrayType(Float32Array); return result; } @@ -54,7 +54,7 @@ class gltfInterpolator { if (output instanceof Float64Array) { - glMatrix.ARRAY_TYPE = Float64Array; + glMatrix.setMatrixArrayType(Float64Array); } const result = new glMatrix.ARRAY_TYPE(stride); @@ -62,7 +62,7 @@ class gltfInterpolator { result[i] = output[prevKey * stride + i] * (1-t) + output[nextKey * stride + i] * t; } - glMatrix.ARRAY_TYPE = Float32Array; + glMatrix.setMatrixArrayType(Float32Array); return result; } @@ -78,7 +78,7 @@ class gltfInterpolator if (output instanceof Float64Array) { - glMatrix.ARRAY_TYPE = Float64Array; + glMatrix.setMatrixArrayType(Float64Array); } const result = new glMatrix.ARRAY_TYPE(stride); const tSq = t ** 2; @@ -96,7 +96,7 @@ class gltfInterpolator result[i] = ((2*tCub - 3*tSq + 1) * v0) + ((tCub - 2*tSq + t) * b) + ((-2*tCub + 3*tSq) * v1) + ((tCub - tSq) * a); } - glMatrix.ARRAY_TYPE = Float32Array; + glMatrix.setMatrixArrayType(Float32Array); return result; } @@ -122,10 +122,10 @@ class gltfInterpolator { if (output instanceof Float64Array) { - glMatrix.ARRAY_TYPE = Float64Array; + glMatrix.setMatrixArrayType(Float64Array); } const result = jsToGlSlice(output, 0, stride); - glMatrix.ARRAY_TYPE = Float32Array; + glMatrix.setMatrixArrayType(Float32Array); return result; } From 8856eb8841218ddc68a1b9d85db5488f7228a227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Mon, 1 Sep 2025 18:20:07 +0200 Subject: [PATCH 25/82] Rework reset --- package-lock.json | 8 ++++---- package.json | 2 +- source/gltf/interactivity.js | 26 ++++++++------------------ 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4e0326e4..a06334ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.1.0", "license": "Apache-2.0", "dependencies": { - "@khronosgroup/khr_interactivity_authoring_engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", + "@khronosgroup/khr-interactivity-authoring-engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", "fast-png": "^6.2.0", "gl-matrix": "^3.2.1", "globals": "^15.5.0", @@ -31,7 +31,7 @@ } }, "../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine": { - "name": "khr_interactivity_authoring_engine", + "name": "@khronosgroup/khr-interactivity-authoring-engine", "version": "0.1.0", "license": "Apache-2.0", "dependencies": { @@ -335,7 +335,7 @@ "node": ">=v12.0.0" } }, - "node_modules/@khronosgroup/khr_interactivity_authoring_engine": { + "node_modules/@khronosgroup/khr-interactivity-authoring-engine": { "resolved": "../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", "link": true }, @@ -5112,7 +5112,7 @@ "lodash": "^4.17.21" } }, - "@khronosgroup/khr_interactivity_authoring_engine": { + "@khronosgroup/khr-interactivity-authoring-engine": { "version": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", "requires": { "@types/node": "^24.1.0", diff --git a/package.json b/package.json index a25a9e9d..82ec3cce 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "globals": "^15.5.0", "jpeg-js": "^0.4.3", "json-ptr": "^3.1.0", - "@khronosgroup/khr_interactivity_authoring_engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine" + "@khronosgroup/khr-interactivity-authoring-engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine" }, "devDependencies": { "@rollup/plugin-commonjs": "^26.0.1", diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 81c5f5f1..974ad09a 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -1,5 +1,5 @@ import { GltfObject } from "./gltf_object"; -import * as interactivity from "@khronosgroup/khr_interactivity_authoring_engine"; +import * as interactivity from "@khronosgroup/khr-interactivity-authoring-engine"; class gltfGraph extends GltfObject { static animatedProperties = []; @@ -17,7 +17,6 @@ class GraphController { this.fps = fps; this.graphIndex = undefined; this.playing = false; - this.reset = false; this.customEvents = []; this.eventBus = new interactivity.DOMEventBus(); this.engine = new interactivity.BasicBehaveEngine(this.fps, this.eventBus); @@ -33,7 +32,6 @@ class GraphController { initializeGraphs(state) { this.graphIndex = undefined; this.playing = false; - this.reset = false; this.decorator.setState(state); this.engine.clearCustomEventListeners(); this.engine.clearEventList(); @@ -53,8 +51,9 @@ class GraphController { try { this.customEvents = this.decorator.loadGraph(graphIndex); this.graphIndex = graphIndex; - this.playing = true; - this.reset = false; + if (this.playing) { + this.decorator.playEventQueue(); + } } catch (error) { console.error("Error loading graph:", error); } @@ -70,7 +69,6 @@ class GraphController { } this.graphIndex = undefined; this.playing = false; - this.reset = false; this.decorator.pauseEventQueue(); this.decorator.resetGraph(); } @@ -93,12 +91,8 @@ class GraphController { if (this.graphIndex === undefined || this.playing) { return; } - if (this.reset) { - this.startGraph(this.graphIndex); - } else { - this.decorator.resumeEventQueue(); - this.playing = true; - } + this.decorator.playEventQueue(); + this.playing = true; } /** @@ -108,11 +102,7 @@ class GraphController { if (this.graphIndex === undefined) { return; } - this.decorator.resetGraph(); - this.reset = true; - if (this.playing) { - this.startGraph(this.graphIndex); - } + this.startGraph(this.graphIndex); } /** @@ -178,7 +168,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { value.type = graphCopy.types[value.type].signature; } } - this.behaveEngine.loadBehaveGraph(graphCopy); + this.behaveEngine.loadBehaveGraph(graphCopy, false); return events; } throw new Error(`Graph with index ${graphIndex} does not exist.`); From 25c72a396731aad64d0807cf0f6014cd651a7a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 2 Sep 2025 12:39:00 +0200 Subject: [PATCH 26/82] Finish read only pointers --- source/gltf/gltf.js | 23 +++++++++++++++++++ source/gltf/gltf_object.js | 1 + source/gltf/interactivity.js | 43 ++++++++++++++---------------------- source/gltf/node.js | 3 ++- source/gltf/scene.js | 9 +++----- source/gltf/utils.js | 6 +++-- 6 files changed, 50 insertions(+), 35 deletions(-) diff --git a/source/gltf/gltf.js b/source/gltf/gltf.js index fb8bfc27..60fe2d87 100644 --- a/source/gltf/gltf.js +++ b/source/gltf/gltf.js @@ -129,6 +129,29 @@ class glTF extends GltfObject } this.computeDisjointAnimations(); + this.addNodeMetaInformation(); + } + + // Adds parent and scene information to each node + addNodeMetaInformation() + { + function recurseNodes(gltf, nodeIndex, scene, parent) + { + const node = gltf.nodes[nodeIndex]; + node.scene = scene; + node.parentNode = parent; + + // recurse into children + for(const child of node.children) + { + recurseNodes(gltf, child, scene, node); + } + } + for (const scene of this.scenes) { + for (const nodeIndex of scene.nodes) { + recurseNodes(this, nodeIndex, scene, undefined); + } + } } // Computes indices of animations which are disjoint and can be played simultaneously. diff --git a/source/gltf/gltf_object.js b/source/gltf/gltf_object.js index eca5ac93..fa5f93d5 100644 --- a/source/gltf/gltf_object.js +++ b/source/gltf/gltf_object.js @@ -8,6 +8,7 @@ class GltfObject { this.extensions = undefined; this.extras = undefined; + this.gltfObjectIndex = undefined; this.animatedPropertyObjects = {}; if (this.constructor.animatedProperties === undefined) { diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 974ad09a..26e6f66a 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -324,6 +324,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { } registerKnownPointers() { + // The engine is checking if a path is valid so we do not need to handle this here + if (this.world === undefined) { return; } @@ -335,12 +337,13 @@ class SampleViewerDecorator extends interactivity.ADecorator { jsonPtr += ".length"; type = "int"; this.registerJsonPointer(jsonPtr, (path) => { - const result = this.traversePath(path); + const fixedPath = path.slice(0, -7); // Remove ".length" + const result = this.traversePath(fixedPath); if (result === undefined) { return 0; } return result.length; - }, (path, value) => {}, "int", true); + }, (path, value) => {}, type, true); return; } this.registerJsonPointer(jsonPtr, (path) => { @@ -379,22 +382,25 @@ class SampleViewerDecorator extends interactivity.ADecorator { return this.traversePath(path); }, (path, value) => {}, "int", true); this.registerJsonPointer(`/nodes/${nodeCount}/globalMatrix`, (path) => { - const node = this.traversePath(path); - if (node === undefined) { - return undefined; + const pathParts = path.split('/'); + const nodeIndex = parseInt(pathParts[2]); + const node = this.world.gltf.nodes[nodeIndex]; + if (node.scene.gltfObjectIndex !== this.world.sceneIndex) { + node.scene.applyTransformHierarchy(this.world.gltf); } - //Should we call applyWorldTransform for all scenes here? return node.worldTransform; // gl-matrix uses column-major order }, (path, value) => {}, "float4x4", true); this.registerJsonPointer(`/nodes/${nodeCount}/matrix`, (path) => { - const node = this.traversePath(path); - if (node === undefined) { - return undefined; - } + const pathParts = path.split('/'); + const nodeIndex = parseInt(pathParts[2]); + const node = this.world.gltf.nodes[nodeIndex]; return node.getLocalTransform(); // gl-matrix uses column-major order }, (path, value) => {}, "float4x4", true); this.registerJsonPointer(`/nodes/${nodeCount}/parent`, (path) => { - // TODO Use implementation from gltfx demo + const pathParts = path.split('/'); + const nodeIndex = parseInt(pathParts[2]); + const node = this.world.gltf.nodes[nodeIndex]; + return node.parentNode?.gltfObjectIndex; }, (path, value) => {}, "int", true); this.registerJsonPointer(`/nodes/${nodeCount}/extensions/KHR_lights_punctual/light`, (path) => { return this.traversePath(path); @@ -414,18 +420,12 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/isPlaying`, (path) => { const pathParts = path.split('/'); const animationIndex = parseInt(pathParts[2]); - if (isNaN(animationIndex) || animationIndex < 0 || animationIndex >= this.world.gltf.animations.length) { - return undefined; - } const animation = this.world.gltf.animations[animationIndex]; return animation.createdTimestamp !== undefined; }, (path, value) => {}, "bool", true); this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/minTime`, (path) => { const pathParts = path.split('/'); const animationIndex = parseInt(pathParts[2]); - if (isNaN(animationIndex) || animationIndex < 0 || animationIndex >= this.world.gltf.animations.length) { - return NaN; - } const animation = this.world.gltf.animations[animationIndex]; animation.computeMinMaxTime(); return animation.minTime; @@ -433,9 +433,6 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/maxTime`, (path) => { const pathParts = path.split('/'); const animationIndex = parseInt(pathParts[2]); - if (isNaN(animationIndex) || animationIndex < 0 || animationIndex >= this.world.gltf.animations.length) { - return NaN; - } const animation = this.world.gltf.animations[animationIndex]; animation.computeMinMaxTime(); return animation.maxTime; @@ -443,9 +440,6 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/playhead`, (path) => { const pathParts = path.split('/'); const animationIndex = parseInt(pathParts[2]); - if (isNaN(animationIndex) || animationIndex < 0 || animationIndex >= this.world.gltf.animations.length) { - return NaN; - } const animation = this.world.gltf.animations[animationIndex]; if (animation.interpolators.length === 0) { return NaN; @@ -455,9 +449,6 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/virtualPlayhead`, (path) => { const pathParts = path.split('/'); const animationIndex = parseInt(pathParts[2]); - if (isNaN(animationIndex) || animationIndex < 0 || animationIndex >= this.world.gltf.animations.length) { - return NaN; - } const animation = this.world.gltf.animations[animationIndex]; if (animation.interpolators.length === 0) { return NaN; diff --git a/source/gltf/node.js b/source/gltf/node.js index 18ba9de2..089d98e2 100644 --- a/source/gltf/node.js +++ b/source/gltf/node.js @@ -46,7 +46,8 @@ class gltfNode extends GltfObject this.instanceMatrices = undefined; this.instanceWorldTransforms = undefined; this.pickingColor = undefined; - this.parent = undefined; + this.parentNode = undefined; + this.scene = undefined; } initGl(gltf, webGlContext) diff --git a/source/gltf/scene.js b/source/gltf/scene.js index 8be551a2..fbf3b96b 100644 --- a/source/gltf/scene.js +++ b/source/gltf/scene.js @@ -19,11 +19,8 @@ class gltfScene extends GltfObject applyTransformHierarchy(gltf, rootTransform = mat4.create()) { - function applyTransform(gltf, node, parent, parentTransform) + function applyTransform(gltf, node, parentTransform) { - if (node.parentNode === undefined) { - node.parentNode = parent; - } mat4.multiply(node.worldTransform, parentTransform, node.getLocalTransform()); mat4.invert(node.inverseWorldTransform, node.worldTransform); mat4.transpose(node.normalMatrix, node.inverseWorldTransform); @@ -40,12 +37,12 @@ class gltfScene extends GltfObject for (const child of node.children) { - applyTransform(gltf, gltf.nodes[child], node, node.worldTransform); + applyTransform(gltf, gltf.nodes[child], node.worldTransform); } } for (const node of this.nodes) { - applyTransform(gltf, gltf.nodes[node], undefined, rootTransform); + applyTransform(gltf, gltf.nodes[node], rootTransform); } diff --git a/source/gltf/utils.js b/source/gltf/utils.js index 4b856119..e53c84c1 100644 --- a/source/gltf/utils.js +++ b/source/gltf/utils.js @@ -49,8 +49,10 @@ function objectsFromJsons(jsonObjects, GltfType) { } const objects = []; - for (const jsonObject of jsonObjects) { - objects.push(objectFromJson(jsonObject, GltfType)); + for (const [index, jsonObject] of jsonObjects.entries()) { + const object = objectFromJson(jsonObject, GltfType); + object.gltfObjectIndex = index; + objects.push(object); } return objects; } From 9cd9730ca729dbecd26d021ed6b23a05e4f71ae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 2 Sep 2025 16:09:12 +0200 Subject: [PATCH 27/82] Add KHR_node_visibility support --- README.md | 1 + source/GltfView/gltf_view.js | 2 +- source/Renderer/renderer.js | 12 +++++++----- source/gltf/gltf.js | 1 + source/gltf/node.js | 14 ++++++++++++++ source/gltf/scene.js | 5 ++++- 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 607e32ce..6f4baa31 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Features - [x] [KHR_materials_variants](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_variants) - [x] [KHR_materials_volume](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_volume) - [x] [KHR_mesh_quantization](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_mesh_quantization) +- [x] [KHR_node_visibility](https://github.com/KhronosGroup/glTF/pull/2410) - [x] [KHR_texture_basisu](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_texture_basisu) - [x] [KHR_texture_transform](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_texture_transform) - [x] [KHR_xmp_json_ld](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_xmp_json_ld) diff --git a/source/GltfView/gltf_view.js b/source/GltfView/gltf_view.js index cc79492b..c15ef546 100644 --- a/source/GltfView/gltf_view.js +++ b/source/GltfView/gltf_view.js @@ -105,7 +105,7 @@ class GltfView opaqueMaterialsCount: 0, transparentMaterialsCount: 0}; } - const nodes = scene.gatherNodes(state.gltf); + const nodes = scene.gatherNodes(state.gltf, state.renderingParameters.enabledExtensions.KHR_node_visibility); const activeMeshes = nodes.filter(node => node.mesh !== undefined).map(node => state.gltf.meshes[node.mesh]); const activePrimitives = activeMeshes .reduce((acc, mesh) => acc.concat(mesh.primitives), []) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index 55e5181c..a0a0db29 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -250,7 +250,12 @@ class gltfRenderer } prepareScene(state, scene) { - this.nodes = scene.gatherNodes(state.gltf); + + const newNodes = scene.gatherNodes(state.gltf, state.renderingParameters.enabledExtensions.KHR_node_visibility); + if (newNodes.length === this.nodes?.length && newNodes.every((element, i) => element === this.nodes[i])) { + return; + } + this.nodes = newNodes; // collect drawables by essentially zipping primitives (for geometry and material) // and nodes for the transform @@ -298,10 +303,7 @@ class gltfRenderer // render complete gltf scene with given camera drawScene(state, scene) { - if (this.preparedScene !== scene) { - this.prepareScene(state, scene); - this.preparedScene = scene; - } + this.prepareScene(state, scene); let currentCamera = undefined; diff --git a/source/gltf/gltf.js b/source/gltf/gltf.js index 60fe2d87..a1f3492e 100644 --- a/source/gltf/gltf.js +++ b/source/gltf/gltf.js @@ -39,6 +39,7 @@ const allowedExtensions = [ "KHR_materials_variants", "KHR_materials_volume", "KHR_mesh_quantization", + "KHR_node_visibility", "KHR_texture_basisu", "KHR_texture_transform", "KHR_xmp_json_ld", diff --git a/source/gltf/node.js b/source/gltf/node.js index 089d98e2..2ca360a2 100644 --- a/source/gltf/node.js +++ b/source/gltf/node.js @@ -109,6 +109,10 @@ class gltfNode extends GltfObject if (jsonNode.matrix !== undefined) { this.applyMatrix(jsonNode.matrix); } + if (jsonNode.extensions?.KHR_node_visibility !== undefined) { + this.extensions.KHR_node_visibility = new KHR_node_visibility(); + this.extensions.KHR_node_visibility.fromJson(jsonNode.extensions.KHR_node_visibility); + } } getWeights(gltf) @@ -152,4 +156,14 @@ class gltfNode extends GltfObject } } +class KHR_node_visibility extends GltfObject { + static animatedProperties = [ + "visible" + ]; + constructor() { + super(); + this.visible = true; + } +} + export { gltfNode }; diff --git a/source/gltf/scene.js b/source/gltf/scene.js index fbf3b96b..1bc9940d 100644 --- a/source/gltf/scene.js +++ b/source/gltf/scene.js @@ -63,13 +63,16 @@ class gltfScene extends GltfObject } - gatherNodes(gltf) + gatherNodes(gltf, nodeVisibilityEnabled) { const nodes = []; function gatherNode(nodeIndex) { const node = gltf.nodes[nodeIndex]; + if (nodeVisibilityEnabled && node.extensions?.KHR_node_visibility?.visible === false) { + return; + } nodes.push(node); // recurse into children From e62a6e2f750354a810c5e7d88352127131116424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 2 Sep 2025 17:09:43 +0200 Subject: [PATCH 28/82] Fix interpolant for booleans --- source/gltf/animation.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/source/gltf/animation.js b/source/gltf/animation.js index 49639bfc..e1df26e9 100644 --- a/source/gltf/animation.js +++ b/source/gltf/animation.js @@ -200,11 +200,14 @@ class gltfAnimation extends GltfObject stride = animatedProperty.restValue[animatedArrayElement]?.length ?? 1; } - const interpolant = interpolator.interpolate(gltf, channel, sampler, elapsedTime, stride, this.maxTime, reverse); + let interpolant = interpolator.interpolate(gltf, channel, sampler, elapsedTime, stride, this.maxTime, reverse); if (interpolant === undefined) { animatedProperty.rest(); continue; } + if (typeof animatedProperty.value() === "boolean") { + interpolant = interpolant[0] !== 0; + } // The interpolator will always return a `Float32Array`, even if the animated value is a scalar. // For the renderer it's not a problem because uploading a single-element array is the same as uploading a scalar to a uniform. // However, it becomes a problem if we use the animated value for further computation and assume is stays a scalar. From 064c62f8f4a05301d4ce6fcf8305f56f50bc9e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 3 Sep 2025 15:40:38 +0200 Subject: [PATCH 29/82] Add picking position --- source/Renderer/renderer.js | 29 ++++++++++++++++++++++++++++ source/Renderer/shaders/picking.frag | 2 ++ source/Renderer/webgl.js | 3 +++ 3 files changed, 34 insertions(+) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index a0a0db29..b25c808c 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -41,6 +41,7 @@ class gltfRenderer this.opaqueFramebuffer = 0; this.opaqueDepthTexture = 0; this.pickingIDTexture = 0; + this.pickingPositionTexture = 0; this.pickingDepthTexture = 0; this.opaqueFramebufferWidth = 1024; this.opaqueFramebufferHeight = 1024; @@ -142,6 +143,15 @@ class gltfRenderer context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, this.opaqueFramebufferWidth, this.opaqueFramebufferHeight, 0, context.RGBA, context.UNSIGNED_BYTE, null); context.bindTexture(context.TEXTURE_2D, null); + this.pickingPositionTexture = context.createTexture(); + context.bindTexture(context.TEXTURE_2D, this.pickingPositionTexture); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.NEAREST); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); + context.texImage2D(context.TEXTURE_2D, 0, context.RGBA32F, this.opaqueFramebufferWidth, this.opaqueFramebufferHeight, 0, context.RGBA, context.FLOAT, null); + context.bindTexture(context.TEXTURE_2D, null); + this.pickingDepthTexture = context.createTexture(); context.bindTexture(context.TEXTURE_2D, this.pickingDepthTexture); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.NEAREST); @@ -167,6 +177,10 @@ class gltfRenderer context.bindFramebuffer(context.FRAMEBUFFER, this.pickingFramebuffer); context.framebufferTexture2D(context.FRAMEBUFFER, context.COLOR_ATTACHMENT0, context.TEXTURE_2D, this.pickingIDTexture, 0); context.framebufferTexture2D(context.FRAMEBUFFER, context.DEPTH_ATTACHMENT, context.TEXTURE_2D, this.pickingDepthTexture, 0); + if (context.supports_EXT_color_buffer_float) { + context.framebufferTexture2D(context.FRAMEBUFFER, context.COLOR_ATTACHMENT1, context.TEXTURE_2D, this.pickingPositionTexture, 0); + context.drawBuffers([context.COLOR_ATTACHMENT0, context.COLOR_ATTACHMENT1]); + } this.samples = samples; @@ -225,6 +239,11 @@ class gltfRenderer this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, this.pickingDepthTexture); this.webGl.context.texImage2D(this.webGl.context.TEXTURE_2D, 0, this.webGl.context.DEPTH_COMPONENT16, this.currentWidth, this.currentHeight, 0, this.webGl.context.DEPTH_COMPONENT, this.webGl.context.UNSIGNED_SHORT, null); this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, null); + if (this.webGl.context.supports_EXT_color_buffer_float) { + this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, this.pickingPositionTexture); + this.webGl.context.texImage2D(this.webGl.context.TEXTURE_2D, 0, this.webGl.context.RGBA32F, this.currentWidth, this.currentHeight, 0, this.webGl.context.RGBA, this.webGl.context.FLOAT, null); + this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, null); + } } } } @@ -493,16 +512,26 @@ class gltfRenderer let pickingResult = { node: undefined, + position: undefined }; + let found = false; for (const node of state.gltf.nodes) { if (node.pickingColor && vec4.equals(node.pickingColor, vec4.fromValues(pixels[0] / 255, pixels[1] / 255, pixels[2] / 255, pixels[3] / 255))) { + found = true; pickingResult.node = node; break; } } + + if (found && this.webGl.context.supports_EXT_color_buffer_float) { + this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT1); + const position = new Float32Array(4); + this.webGl.context.readPixels(state.pickingX ?? this.currentWidth / 2, pickingY ?? this.currentHeight / 2, 1, 1, this.webGl.context.RGBA, this.webGl.context.FLOAT, position); + pickingResult.position = position.subarray(0, 3); + } if (state.selectionCallback){ state.selectionCallback(pickingResult); diff --git a/source/Renderer/shaders/picking.frag b/source/Renderer/shaders/picking.frag index a0abc2ca..8730e383 100644 --- a/source/Renderer/shaders/picking.frag +++ b/source/Renderer/shaders/picking.frag @@ -1,9 +1,11 @@ precision highp float; layout(location = 0) out vec4 id_color; +layout(location = 1) out vec4 position; uniform vec4 u_PickingColor; in vec3 v_Position; void main() { id_color = u_PickingColor; + position = vec4(v_Position, 1.0); } diff --git a/source/Renderer/webgl.js b/source/Renderer/webgl.js index fefc0b88..a1b5fbf9 100644 --- a/source/Renderer/webgl.js +++ b/source/Renderer/webgl.js @@ -27,6 +27,9 @@ class gltfWebGl console.warn("Anisotropic filtering is not supported"); this.context.supports_EXT_texture_filter_anisotropic = false; } + this.context.supports_EXT_color_buffer_float = this.context.getExtension("EXT_color_buffer_float") ? true : false; + this.context.supports_EXT_color_buffer_half_float = this.context.supports_EXT_color_buffer_float || + (this.context.getExtension("EXT_color_buffer_half_float") ? true : false); } setTexture(loc, gltf, textureInfo, texSlot) From 75f18bb2197fb9d1219e72cddd1a10bd70b5f558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 9 Sep 2025 11:37:20 +0200 Subject: [PATCH 30/82] Add selection to interactivity and WIP hover support --- source/GltfState/gltf_state.js | 7 +- source/Renderer/renderer.js | 6 +- source/gltf/interactivity.js | 149 +++++++++++++++++++++++++++++---- 3 files changed, 144 insertions(+), 18 deletions(-) diff --git a/source/GltfState/gltf_state.js b/source/GltfState/gltf_state.js index 26585eb0..3d0094f9 100644 --- a/source/GltfState/gltf_state.js +++ b/source/GltfState/gltf_state.js @@ -37,11 +37,14 @@ class GltfState /** the graph controller allows selecting and playing graphs from KHR_interactivity */ this.graphController = new GraphController(); - /** callback for selections */ + /** callback for selection/hover */ this.selectionCallback = undefined; + this.hoverCallback = undefined; - /** If the renderer should compute the selection in the next frame */ + /** If the renderer should compute selection/hover in the next frame */ this.triggerSelection = false; + /** enableHover is also set internally by KHR_interactivity */ + this.enableHover = false; /* screen position of the picking ray */ this.pickingX = 0; diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index b25c808c..7861b695 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -512,7 +512,8 @@ class gltfRenderer let pickingResult = { node: undefined, - position: undefined + position: undefined, + rayOrigin: this.currentCameraPosition, }; let found = false; @@ -532,6 +533,9 @@ class gltfRenderer this.webGl.context.readPixels(state.pickingX ?? this.currentWidth / 2, pickingY ?? this.currentHeight / 2, 1, 1, this.webGl.context.RGBA, this.webGl.context.FLOAT, position); pickingResult.position = position.subarray(0, 3); } + + state.graphController.receiveSelection(pickingResult); + //state.graphController.receiveHover(pickingResult); if (state.selectionCallback){ state.selectionCallback(pickingResult); diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 26e6f66a..89e06a41 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -6,6 +6,17 @@ class gltfGraph extends GltfObject { constructor() { super(); + this.hasHoverEvent = false; + } + + fromJson(json) { + super.fromJson(json); + for (const declaration of json.declarations) { + if (declaration.op === "event/onHoverIn" || declaration.op === "event/onHoverOut") { + this.hasHoverEvent = true; + break; + } + } } } @@ -23,6 +34,18 @@ class GraphController { this.decorator = new SampleViewerDecorator(this.engine, debug); } + receiveSelection(pickingResult) { + if (this.graphIndex !== undefined) { + this.decorator.receiveSelection(pickingResult); + } + } + + receiveHover(pickingResult) { + if (this.graphIndex !== undefined) { + this.decorator.receiveHover(pickingResult); + } + } + /** * Initialize the graph controller with the given state and debug flag. * This needs to be called every time a glTF assets is loaded. @@ -30,6 +53,7 @@ class GraphController { * @param {boolean} debug - Whether to enable debug mode. */ initializeGraphs(state) { + this.state = state; this.graphIndex = undefined; this.playing = false; this.decorator.setState(state); @@ -52,7 +76,10 @@ class GraphController { this.customEvents = this.decorator.loadGraph(graphIndex); this.graphIndex = graphIndex; if (this.playing) { + this.state.enableHover = this.state.gltf?.extensions?.KHR_interactivity?.graphs[this.graphIndex]?.hasHoverEvent ?? false; this.decorator.playEventQueue(); + } else { + this.state.enableHover = false; } } catch (error) { console.error("Error loading graph:", error); @@ -71,6 +98,7 @@ class GraphController { this.playing = false; this.decorator.pauseEventQueue(); this.decorator.resetGraph(); + this.state.enableHover = false; } /** @@ -82,6 +110,7 @@ class GraphController { } this.decorator.pauseEventQueue(); this.playing = false; + this.state.enableHover = false; } /** @@ -91,6 +120,7 @@ class GraphController { if (this.graphIndex === undefined || this.playing) { return; } + this.state.enableHover = this.state.gltf?.extensions?.KHR_interactivity?.graphs[this.graphIndex]?.hasHoverEvent ?? false; this.decorator.playEventQueue(); this.playing = true; } @@ -122,7 +152,9 @@ class SampleViewerDecorator extends interactivity.ADecorator { constructor(behaveEngine, debug = false) { super(behaveEngine); + this.behaveEngine = behaveEngine; this.world = undefined; + this.lastHoverNodeIndex = undefined; if (debug) { this.behaveEngine.processNodeStarted = this.processNodeStarted; @@ -139,14 +171,44 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerBehaveEngineNode("animation/start", interactivity.AnimationStart); this.registerBehaveEngineNode("animation/stopAt", interactivity.AnimationStopAt); - this.behaveEngine.alertParentOnSelect = this.alertParentOnSelect; + this.behaveEngine.alertParentOnSelect = (selectionPoint, selectedNodeIndex, controllerIndex, selectionRayOrigin, childNodeIndex) => { + const parent = this.world.gltf.nodes[childNodeIndex]?.parentNode; + const pickingResult = { + position: selectionPoint, + node: selectedNodeIndex, + parentNode: parent, + rayOrigin: selectionRayOrigin + }; + this.receiveSelection(pickingResult); + }; + + this.behaveEngine.addNodeClickedListener = (nodeIndex, callback) => { + this.clickedNodesIndices.set(nodeIndex, callback); + }; + + this.behaveEngine.receiveSelection = (pickingResult) => { + if (pickingResult.node === undefined) { + return; + } + const selectedNode = pickingResult.node; + let currentNode = pickingResult.parentNode ?? pickingResult.node; + while (currentNode !== undefined) { + const callback = this.clickedNodesIndices.get(currentNode.gltfObjectIndex); + if (callback !== undefined) { + callback(pickingResult.position, selectedNode.gltfObjectIndex, 0, pickingResult.rayOrigin); + return; + } + currentNode = currentNode.parentNode; + } + }; + this.clickedNodesIndices = new Map(); + this.behaveEngine.alertParentOnHoverIn = this.alertParentOnHoverIn; this.behaveEngine.alertParentOnHoverOut = this.alertParentOnHoverOut; - this.behaveEngine.addNodeClickedListener = this.addNodeClickedListener; - //this.registerBehaveEngineNode("event/onSelect", interactivity.OnSelect); - //this.registerBehaveEngineNode("event/onHoverIn", interactivity.OnHoverIn); - //this.registerBehaveEngineNode("event/onHoverOut", interactivity.OnHoverOut); + this.registerBehaveEngineNode("event/onSelect", interactivity.OnSelect); + this.registerBehaveEngineNode("event/onHoverIn", interactivity.OnHoverIn); + this.registerBehaveEngineNode("event/onHoverOut", interactivity.OnHoverOut); } setState(state) { @@ -156,7 +218,52 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerKnownPointers(); } + receiveSelection(pickingResult) { + this.behaveEngine.receiveSelection(pickingResult); + } + + receiveHover(pickingResult) { + const oldHoverIndicies = new Set(); + const newHoverNode = pickingResult.node; + this.firstCommonHoverNode = undefined; + if (this.lastHoverNodeIndex !== undefined && newHoverNode !== undefined) { + let currentOldHoverNode = this.world.gltf.nodes[this.lastHoverNodeIndex]; + while (currentOldHoverNode !== undefined) { + oldHoverIndicies.add(currentOldHoverNode.gltfObjectIndex); + currentOldHoverNode = currentOldHoverNode.parentNode; + } + let currentHoverNode = newHoverNode; + while (currentHoverNode !== undefined) { + if (oldHoverIndicies.has(currentHoverNode.gltfObjectIndex)) { + this.firstCommonHoverNode = currentHoverNode; + break; + } + currentHoverNode = currentHoverNode.parentNode; + } + } + let oldHoverNode = this.world.gltf.nodes[this.lastHoverNodeIndex]; + while (oldHoverNode !== undefined && oldHoverNode !== this.firstCommonHoverNode) { + const hoverInformation = this.hoveredNodesIndices.get(oldHoverNode?.gltfObjectIndex); + if (hoverInformation?.callbackHoverOut !== undefined) { + hoverInformation.callbackHoverOut(this.lastHoverNodeIndex, 0); + break; + } + oldHoverNode = oldHoverNode.parentNode; + } + let currentHoverNode = newHoverNode; + while (currentHoverNode !== undefined && currentHoverNode !== this.firstCommonHoverNode) { + const hoverInformation = this.hoveredNodesIndices.get(currentHoverNode?.gltfObjectIndex); + if (hoverInformation?.callbackHoverIn !== undefined) { + hoverInformation.callbackHoverIn(newHoverNode?.gltfObjectIndex, 0); + break; + } + currentHoverNode = currentHoverNode.parentNode; + } + + } + loadGraph(graphIndex) { + this.clickedNodesIndices.clear(); const graphArray = this.world?.gltf?.extensions?.KHR_interactivity?.graphs; if (graphArray && graphArray.length > graphIndex) { const graphCopy = JSON.parse(JSON.stringify(graphArray[graphIndex])); @@ -175,6 +282,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { } resetGraph() { + this.clickedNodesIndices.clear(); this.behaveEngine.loadBehaveGraph({nodes: [], types: [], events: [], declarations: [], variables: []}); if (this.world === undefined) { return; @@ -523,20 +631,31 @@ class SampleViewerDecorator extends interactivity.ADecorator { animation.createdTimestamp = this.world.animationTimer.elapsedSec(); } - alertParentOnSelect(selectionPoint, selectedNodeIndex, controllerIndex, selectionRayOrigin, childNodeIndex) { - - } - alertParentOnHoverIn(selectedNodeIndex, controllerIndex, childNodeIndex) { - - } - - alertParentOnHoverOut(selectedNodeIndex, controllerIndex, childNodeIndex) { + const parent = this.world.gltf.nodes[childNodeIndex]?.parentNode; + let currentHoverNode = parent; + while (currentHoverNode !== undefined && currentHoverNode !== this.firstCommonHoverNode) { + const hoverInformation = this.hoveredNodesIndices.get(currentHoverNode?.gltfObjectIndex); + if (hoverInformation?.callbackHoverIn !== undefined) { + hoverInformation.callbackHoverIn(selectedNodeIndex, controllerIndex); + break; + } + currentHoverNode = currentHoverNode.parentNode; + } } - addNodeClickedListener = (nodeIndex, callback) => { - + alertParentOnHoverOut(selectedNodeIndex, controllerIndex, childNodeIndex) { + const parent = this.world.gltf.nodes[childNodeIndex]?.parentNode; + let oldHoverNode = parent; + while (oldHoverNode !== undefined && oldHoverNode !== this.firstCommonHoverNode) { + const hoverInformation = this.hoveredNodesIndices.get(oldHoverNode?.gltfObjectIndex); + if (hoverInformation?.callbackHoverOut !== undefined) { + hoverInformation.callbackHoverOut(selectedNodeIndex, controllerIndex); + break; + } + oldHoverNode = oldHoverNode.parentNode; + } } } From 5dae8ca7cb5fc69fe25d53948024e489cd06a8a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 9 Sep 2025 18:20:34 +0200 Subject: [PATCH 31/82] Add selectability to node/renderer --- source/GltfView/gltf_view.js | 2 +- source/Renderer/renderer.js | 36 +++++++++++++++++++++++++++++++----- source/gltf/node.js | 28 ++++++++++++++++++++++++++++ source/gltf/scene.js | 33 +++++++++++++++++++++++++-------- 4 files changed, 85 insertions(+), 14 deletions(-) diff --git a/source/GltfView/gltf_view.js b/source/GltfView/gltf_view.js index c15ef546..59023c70 100644 --- a/source/GltfView/gltf_view.js +++ b/source/GltfView/gltf_view.js @@ -105,7 +105,7 @@ class GltfView opaqueMaterialsCount: 0, transparentMaterialsCount: 0}; } - const nodes = scene.gatherNodes(state.gltf, state.renderingParameters.enabledExtensions.KHR_node_visibility); + const nodes = scene.gatherNodes(state.gltf, state.renderingParameters.enabledExtensions).nodes; const activeMeshes = nodes.filter(node => node.mesh !== undefined).map(node => state.gltf.meshes[node.mesh]); const activePrimitives = activeMeshes .reduce((acc, mesh) => acc.concat(mesh.primitives), []) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index 7861b695..2ea4da82 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -270,11 +270,22 @@ class gltfRenderer prepareScene(state, scene) { - const newNodes = scene.gatherNodes(state.gltf, state.renderingParameters.enabledExtensions.KHR_node_visibility); - if (newNodes.length === this.nodes?.length && newNodes.every((element, i) => element === this.nodes[i])) { + const newNodes = scene.gatherNodes(state.gltf, state.renderingParameters.enabledExtensions); + this.selectionDrawables = newNodes.selectableNodes + .filter(node => node.mesh !== undefined) + .reduce((acc, node) => acc.concat(state.gltf.meshes[node.mesh].primitives.map( (primitive, index) => { + return {node: node, primitive: primitive, primitiveIndex: index}; + })), []); + this.hoverDrawables = newNodes.hoverableNodes + .filter(node => node.mesh !== undefined) + .reduce((acc, node) => acc.concat(state.gltf.meshes[node.mesh].primitives.map( (primitive, index) => { + return {node: node, primitive: primitive, primitiveIndex: index}; + })), []); + + if (newNodes.nodes.length === this.nodes?.length && newNodes.nodes.every((element, i) => element === this.nodes[i])) { return; } - this.nodes = newNodes; + this.nodes = newNodes.nodes; // collect drawables by essentially zipping primitives (for geometry and material) // and nodes for the transform @@ -409,13 +420,14 @@ class gltfRenderer instanceWorldTransforms.push(instanceOffset); } - if (state.triggerSelection) { + + if (state.triggerSelection && state.pickingX !== undefined && state.pickingY !== undefined) { this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); this.webGl.context.viewport(0, 0, this.currentWidth, this.currentHeight); const fragDefines = []; this.pushFragParameterDefines(fragDefines, state); - for (const drawable of this.drawables) + for (const drawable of this.selectionDrawables) { let renderpassConfiguration = {}; renderpassConfiguration.picking = true; @@ -423,6 +435,20 @@ class gltfRenderer } } + if (state.enableHover && state.pickingX !== undefined && state.pickingY !== undefined) { + /*this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); + this.webGl.context.viewport(0, 0, this.currentWidth, this.currentHeight); + + const fragDefines = []; + this.pushFragParameterDefines(fragDefines, state); + for (const drawable of this.hoverDrawables) + { + let renderpassConfiguration = {}; + renderpassConfiguration.picking = true; + this.drawPrimitive(state, renderpassConfiguration, drawable.primitive, drawable.node, this.viewProjectionMatrix); + }*/ + } + // If any transmissive drawables are present, render all opaque and transparent drawables into a separate framebuffer. if (this.transmissionDrawables.length > 0) { // Render transmission sample texture diff --git a/source/gltf/node.js b/source/gltf/node.js index 2ca360a2..05b9875c 100644 --- a/source/gltf/node.js +++ b/source/gltf/node.js @@ -113,6 +113,14 @@ class gltfNode extends GltfObject this.extensions.KHR_node_visibility = new KHR_node_visibility(); this.extensions.KHR_node_visibility.fromJson(jsonNode.extensions.KHR_node_visibility); } + if (jsonNode.extensions?.KHR_node_selectability !== undefined) { + this.extensions.KHR_node_selectability = new KHR_node_selectability(); + this.extensions.KHR_node_selectability.fromJson(jsonNode.extensions.KHR_node_selectability); + } + if (jsonNode.extensions?.KHR_node_hoverability !== undefined) { + this.extensions.KHR_node_hoverability = new KHR_node_hoverability(); + this.extensions.KHR_node_hoverability.fromJson(jsonNode.extensions.KHR_node_hoverability); + } } getWeights(gltf) @@ -166,4 +174,24 @@ class KHR_node_visibility extends GltfObject { } } +class KHR_node_selectability extends GltfObject { + static animatedProperties = [ + "selectable" + ]; + constructor() { + super(); + this.selectable = true; + } +} + +class KHR_node_hoverability extends GltfObject { + static animatedProperties = [ + "hoverable" + ]; + constructor() { + super(); + this.hoverable = true; + } +} + export { gltfNode }; diff --git a/source/gltf/scene.js b/source/gltf/scene.js index 1bc9940d..eb4c5c93 100644 --- a/source/gltf/scene.js +++ b/source/gltf/scene.js @@ -63,31 +63,48 @@ class gltfScene extends GltfObject } - gatherNodes(gltf, nodeVisibilityEnabled) + gatherNodes(gltf, enabledExtensions) { const nodes = []; + const selectableNodes = []; + const hoverableNodes = []; - function gatherNode(nodeIndex) + function gatherNode(nodeIndex, visible, selectable, hoverable) { const node = gltf.nodes[nodeIndex]; - if (nodeVisibilityEnabled && node.extensions?.KHR_node_visibility?.visible === false) { - return; + if (!enabledExtensions.KHR_node_visibility || (node.extensions?.KHR_node_visibility?.visible !== false) && visible) { + nodes.push(node); + } else { + visible = false; + } + if (!enabledExtensions.KHR_node_selectability || (node.extensions?.KHR_node_selectability?.selectable !== false) && selectable) { + selectableNodes.push(node); + } else { + selectable = false; + } + if (!enabledExtensions.KHR_node_hoverability || (node.extensions?.KHR_node_hoverability?.hoverable !== false) && hoverable) { + hoverableNodes.push(node); + } else { + hoverable = false; } - nodes.push(node); // recurse into children for(const child of node.children) { - gatherNode(child); + gatherNode(child, visible, selectable, hoverable); } } for (const node of this.nodes) { - gatherNode(node); + gatherNode(node, true, true, true); } - return nodes; + return { + nodes: nodes, + selectableNodes: selectableNodes, + hoverableNodes: hoverableNodes + }; } includesNode(gltf, nodeIndex) From ae29676d98b9736f9bf5fb77291c898dd8bfde7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 10 Sep 2025 11:07:09 +0200 Subject: [PATCH 32/82] Use 1x1 picking framebuffer --- source/Renderer/renderer.js | 34 +++++++++----------------- source/gltf/camera.js | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 23 deletions(-) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index 2ea4da82..d52ed674 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -140,7 +140,7 @@ class gltfRenderer context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); - context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, this.opaqueFramebufferWidth, this.opaqueFramebufferHeight, 0, context.RGBA, context.UNSIGNED_BYTE, null); + context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, 1, 1, 0, context.RGBA, context.UNSIGNED_BYTE, null); context.bindTexture(context.TEXTURE_2D, null); this.pickingPositionTexture = context.createTexture(); @@ -149,7 +149,7 @@ class gltfRenderer context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); - context.texImage2D(context.TEXTURE_2D, 0, context.RGBA32F, this.opaqueFramebufferWidth, this.opaqueFramebufferHeight, 0, context.RGBA, context.FLOAT, null); + context.texImage2D(context.TEXTURE_2D, 0, context.RGBA32F, 1, 1, 0, context.RGBA, context.FLOAT, null); context.bindTexture(context.TEXTURE_2D, null); this.pickingDepthTexture = context.createTexture(); @@ -158,7 +158,7 @@ class gltfRenderer context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); - context.texImage2D( context.TEXTURE_2D, 0, context.DEPTH_COMPONENT16, this.opaqueFramebufferWidth, this.opaqueFramebufferHeight, 0, context.DEPTH_COMPONENT, context.UNSIGNED_SHORT, null); + context.texImage2D( context.TEXTURE_2D, 0, context.DEPTH_COMPONENT16, 1, 1, 0, context.DEPTH_COMPONENT, context.UNSIGNED_SHORT, null); context.bindTexture(context.TEXTURE_2D, null); this.colorRenderBuffer = context.createRenderbuffer(); @@ -231,20 +231,6 @@ class gltfRenderer this.currentHeight = height; this.currentWidth = width; this.webGl.context.viewport(0, 0, width, height); - if (this.initialized) { - this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); - this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, this.pickingIDTexture); - this.webGl.context.texImage2D(this.webGl.context.TEXTURE_2D, 0, this.webGl.context.RGBA, this.currentWidth, this.currentHeight, 0, this.webGl.context.RGBA, this.webGl.context.UNSIGNED_BYTE, null); - this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, null); - this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, this.pickingDepthTexture); - this.webGl.context.texImage2D(this.webGl.context.TEXTURE_2D, 0, this.webGl.context.DEPTH_COMPONENT16, this.currentWidth, this.currentHeight, 0, this.webGl.context.DEPTH_COMPONENT, this.webGl.context.UNSIGNED_SHORT, null); - this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, null); - if (this.webGl.context.supports_EXT_color_buffer_float) { - this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, this.pickingPositionTexture); - this.webGl.context.texImage2D(this.webGl.context.TEXTURE_2D, 0, this.webGl.context.RGBA32F, this.currentWidth, this.currentHeight, 0, this.webGl.context.RGBA, this.webGl.context.FLOAT, null); - this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, null); - } - } } } @@ -420,10 +406,13 @@ class gltfRenderer instanceWorldTransforms.push(instanceOffset); } + let pickingViewProjection = undefined; if (state.triggerSelection && state.pickingX !== undefined && state.pickingY !== undefined) { + pickingViewProjection = currentCamera.getProjectionMatrixForPixel(state.pickingX - aspectOffsetX, this.currentHeight - state.pickingY - aspectOffsetY, aspectWidth, aspectHeight); + mat4.multiply(pickingViewProjection, pickingViewProjection, this.viewMatrix); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); - this.webGl.context.viewport(0, 0, this.currentWidth, this.currentHeight); + this.webGl.context.viewport(0, 0, 1, 1); const fragDefines = []; this.pushFragParameterDefines(fragDefines, state); @@ -431,7 +420,7 @@ class gltfRenderer { let renderpassConfiguration = {}; renderpassConfiguration.picking = true; - this.drawPrimitive(state, renderpassConfiguration, drawable.primitive, drawable.node, this.viewProjectionMatrix); + this.drawPrimitive(state, renderpassConfiguration, drawable.primitive, drawable.node, pickingViewProjection); } } @@ -529,12 +518,11 @@ class gltfRenderer // Handle selection if (state.triggerSelection) { this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); - this.webGl.context.viewport(0, 0, this.currentWidth, this.currentHeight); + this.webGl.context.viewport(0, 0, 1, 1); state.triggerSelection = false; - const pickingY = this.currentHeight - state.pickingY; this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT0); const pixels = new Uint8Array(4); - this.webGl.context.readPixels(state.pickingX ?? this.currentWidth / 2, pickingY ?? this.currentHeight / 2, 1, 1, this.webGl.context.RGBA, this.webGl.context.UNSIGNED_BYTE, pixels); + this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RGBA, this.webGl.context.UNSIGNED_BYTE, pixels); let pickingResult = { node: undefined, @@ -556,7 +544,7 @@ class gltfRenderer if (found && this.webGl.context.supports_EXT_color_buffer_float) { this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT1); const position = new Float32Array(4); - this.webGl.context.readPixels(state.pickingX ?? this.currentWidth / 2, pickingY ?? this.currentHeight / 2, 1, 1, this.webGl.context.RGBA, this.webGl.context.FLOAT, position); + this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RGBA, this.webGl.context.FLOAT, position); pickingResult.position = position.subarray(0, 3); } diff --git a/source/gltf/camera.js b/source/gltf/camera.js index 223c9ee0..c887683d 100644 --- a/source/gltf/camera.js +++ b/source/gltf/camera.js @@ -84,6 +84,54 @@ class gltfCamera extends GltfObject return projection; } + getProjectionMatrixForPixel(x, y, width, height) { + const projection = mat4.create(); + + if (this.type === "perspective") + { + const aspectRatio = this.perspective.aspectRatio ?? (width / height); + const top = Math.tan(this.perspective.yfov / 2) * this.perspective.znear; + const bottom = -top; + const left = bottom * aspectRatio; + const right = top * aspectRatio; + const computedWidth = Math.abs(right - left); + const computedHeight = Math.abs(top - bottom); + + const subWidth = computedWidth / width; + const subHeight = computedHeight / height; + const subLeft = left + x * subWidth; + const subBottom = bottom + y * subHeight; + + mat4.frustum( + projection, + subLeft, + subLeft + subWidth, + subBottom, + subBottom + subHeight, + this.perspective.znear, + this.perspective.zfar + ); + + + } + else if (this.type === "orthographic") + { + const subLeft = -this.orthographic.xmag + (2 * this.orthographic.xmag / width) * x; + const subRight = subLeft + (2 * this.orthographic.xmag / width); + const subBottom = -this.orthographic.ymag + (2 * this.orthographic.ymag / height) * y; + const subTop = subBottom + (2 * this.orthographic.ymag / height); + + mat4.ortho( + projection, + subLeft, subRight, subBottom, subTop, + this.orthographic.znear, + this.orthographic.zfar + ); + } + + return projection; + } + getViewMatrix(gltf) { let result = mat4.create(); From ed5a3d87614726ef756af61e07fb6812ff423798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 10 Sep 2025 16:32:51 +0200 Subject: [PATCH 33/82] Compute ray origin for orthographic camera --- source/Renderer/renderer.js | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index d52ed674..366c7b8e 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -407,9 +407,12 @@ class gltfRenderer } let pickingViewProjection = undefined; - - if (state.triggerSelection && state.pickingX !== undefined && state.pickingY !== undefined) { - pickingViewProjection = currentCamera.getProjectionMatrixForPixel(state.pickingX - aspectOffsetX, this.currentHeight - state.pickingY - aspectOffsetY, aspectWidth, aspectHeight); + + const pickingX = state.pickingX; + const pickingY = state.pickingY; + + if (state.triggerSelection && pickingX !== undefined && pickingY !== undefined) { + pickingViewProjection = currentCamera.getProjectionMatrixForPixel(pickingX - aspectOffsetX, this.currentHeight - pickingY - aspectOffsetY, aspectWidth, aspectHeight); mat4.multiply(pickingViewProjection, pickingViewProjection, this.viewMatrix); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); this.webGl.context.viewport(0, 0, 1, 1); @@ -424,8 +427,8 @@ class gltfRenderer } } - if (state.enableHover && state.pickingX !== undefined && state.pickingY !== undefined) { - /*this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); + if (state.enableHover && pickingX !== undefined && pickingY !== undefined) { + this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); this.webGl.context.viewport(0, 0, this.currentWidth, this.currentHeight); const fragDefines = []; @@ -435,7 +438,7 @@ class gltfRenderer let renderpassConfiguration = {}; renderpassConfiguration.picking = true; this.drawPrimitive(state, renderpassConfiguration, drawable.primitive, drawable.node, this.viewProjectionMatrix); - }*/ + } } // If any transmissive drawables are present, render all opaque and transparent drawables into a separate framebuffer. @@ -524,10 +527,22 @@ class gltfRenderer const pixels = new Uint8Array(4); this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RGBA, this.webGl.context.UNSIGNED_BYTE, pixels); + let rayOrigin = undefined; + if (currentCamera.type === "orthographic") { + const x = pickingX - aspectOffsetX; + const y = this.currentHeight - pickingY - aspectOffsetY; + const orthoX = -currentCamera.orthographic.xmag + (2 * currentCamera.orthographic.xmag / aspectWidth) * (x + 0.5); + const orthoY = -currentCamera.orthographic.ymag + (2 * currentCamera.orthographic.ymag / aspectHeight) * (y + 0.5); + rayOrigin = vec3.fromValues(orthoX, orthoY, -currentCamera.orthographic.znear); + vec3.transformMat4(rayOrigin, rayOrigin, currentCamera.getTransformMatrix(state.gltf)); + } else { + rayOrigin = currentCamera.getPosition(state.gltf); + } + let pickingResult = { node: undefined, position: undefined, - rayOrigin: this.currentCameraPosition, + rayOrigin: rayOrigin, }; let found = false; From 13e20fc4bf2e9f0d826eeeb7c8ae71bac1df342c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 10 Sep 2025 18:05:19 +0200 Subject: [PATCH 34/82] Add hover pass --- source/Renderer/renderer.js | 62 ++++++++++++++++++++++++++++++++++-- source/gltf/interactivity.js | 2 ++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index 366c7b8e..081080e6 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -43,6 +43,8 @@ class gltfRenderer this.pickingIDTexture = 0; this.pickingPositionTexture = 0; this.pickingDepthTexture = 0; + this.hoverIDTexture = 0; + this.hoverDepthTexture = 0; this.opaqueFramebufferWidth = 1024; this.opaqueFramebufferHeight = 1024; @@ -161,6 +163,24 @@ class gltfRenderer context.texImage2D( context.TEXTURE_2D, 0, context.DEPTH_COMPONENT16, 1, 1, 0, context.DEPTH_COMPONENT, context.UNSIGNED_SHORT, null); context.bindTexture(context.TEXTURE_2D, null); + this.hoverIDTexture = context.createTexture(); + context.bindTexture(context.TEXTURE_2D, this.hoverIDTexture); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.NEAREST); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); + context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, 1, 1, 0, context.RGBA, context.UNSIGNED_BYTE, null); + context.bindTexture(context.TEXTURE_2D, null); + + this.hoverDepthTexture = context.createTexture(); + context.bindTexture(context.TEXTURE_2D, this.hoverDepthTexture); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.NEAREST); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); + context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); + context.texImage2D( context.TEXTURE_2D, 0, context.DEPTH_COMPONENT16, 1, 1, 0, context.DEPTH_COMPONENT, context.UNSIGNED_SHORT, null); + context.bindTexture(context.TEXTURE_2D, null); + this.colorRenderBuffer = context.createRenderbuffer(); context.bindRenderbuffer(context.RENDERBUFFER, this.colorRenderBuffer); context.renderbufferStorageMultisample( context.RENDERBUFFER, samples, context.RGBA8, this.opaqueFramebufferWidth, this.opaqueFramebufferHeight); @@ -182,6 +202,11 @@ class gltfRenderer context.drawBuffers([context.COLOR_ATTACHMENT0, context.COLOR_ATTACHMENT1]); } + this.hoverFramebuffer = context.createFramebuffer(); + context.bindFramebuffer(context.FRAMEBUFFER, this.hoverFramebuffer); + context.framebufferTexture2D(context.FRAMEBUFFER, context.COLOR_ATTACHMENT0, context.TEXTURE_2D, this.hoverIDTexture, 0); + context.framebufferTexture2D(context.FRAMEBUFFER, context.DEPTH_ATTACHMENT, context.TEXTURE_2D, this.hoverDepthTexture, 0); + this.samples = samples; this.opaqueFramebufferMSAA = context.createFramebuffer(); @@ -252,6 +277,10 @@ class gltfRenderer this.webGl.context.clearColor(0, 0, 0, 0); this.webGl.context.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, null); + this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); + this.webGl.context.clearColor(0, 0, 0, 0); + this.webGl.context.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT); + this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, null); } prepareScene(state, scene) { @@ -428,8 +457,12 @@ class gltfRenderer } if (state.enableHover && pickingX !== undefined && pickingY !== undefined) { + if (pickingViewProjection === undefined) { + pickingViewProjection = currentCamera.getProjectionMatrixForPixel(pickingX - aspectOffsetX, this.currentHeight - pickingY - aspectOffsetY, aspectWidth, aspectHeight); + mat4.multiply(pickingViewProjection, pickingViewProjection, this.viewMatrix); + } this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); - this.webGl.context.viewport(0, 0, this.currentWidth, this.currentHeight); + this.webGl.context.viewport(0, 0, 1, 1); const fragDefines = []; this.pushFragParameterDefines(fragDefines, state); @@ -437,7 +470,7 @@ class gltfRenderer { let renderpassConfiguration = {}; renderpassConfiguration.picking = true; - this.drawPrimitive(state, renderpassConfiguration, drawable.primitive, drawable.node, this.viewProjectionMatrix); + this.drawPrimitive(state, renderpassConfiguration, drawable.primitive, drawable.node, pickingViewProjection); } } @@ -564,12 +597,35 @@ class gltfRenderer } state.graphController.receiveSelection(pickingResult); - //state.graphController.receiveHover(pickingResult); if (state.selectionCallback){ state.selectionCallback(pickingResult); } } + + if (state.enableHover) { + this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); + this.webGl.context.viewport(0, 0, 1, 1); + this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT0); + const pixels = new Uint8Array(4); + this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RGBA, this.webGl.context.UNSIGNED_BYTE, pixels); + + let pickingResult = { + node: undefined, + }; + for (const node of state.gltf.nodes) + { + if (node.pickingColor && vec4.equals(node.pickingColor, vec4.fromValues(pixels[0] / 255, pixels[1] / 255, pixels[2] / 255, pixels[3] / 255))) + { + pickingResult.node = node; + break; + } + } + state.graphController.receiveHover(pickingResult); + if (state.hoverCallback){ + state.hoverCallback(pickingResult); + } + } } // vertices with given material diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 89e06a41..aa22eed9 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -260,6 +260,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { currentHoverNode = currentHoverNode.parentNode; } + this.lastHoverNodeIndex = newHoverNode?.gltfObjectIndex; + } loadGraph(graphIndex) { From 47ef0422702ba240b7afe7522f696481ebe7e58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 11 Sep 2025 14:22:50 +0200 Subject: [PATCH 35/82] Fix hover callbacks --- source/gltf/interactivity.js | 57 ++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index aa22eed9..a33eb015 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -203,8 +203,31 @@ class SampleViewerDecorator extends interactivity.ADecorator { }; this.clickedNodesIndices = new Map(); - this.behaveEngine.alertParentOnHoverIn = this.alertParentOnHoverIn; - this.behaveEngine.alertParentOnHoverOut = this.alertParentOnHoverOut; + this.behaveEngine.alertParentOnHoverIn = (selectedNodeIndex, controllerIndex, childNodeIndex) => { + const parent = this.world.gltf.nodes[childNodeIndex]?.parentNode; + let currentHoverNode = parent; + while (currentHoverNode !== undefined && currentHoverNode !== this.firstCommonHoverNode) { + const hoverInformation = this.hoveredNodesIndices.get(currentHoverNode?.gltfObjectIndex); + if (hoverInformation?.callbackHoverIn !== undefined) { + hoverInformation.callbackHoverIn(selectedNodeIndex, controllerIndex); + break; + } + currentHoverNode = currentHoverNode.parentNode; + } + }; + + this.behaveEngine.alertParentOnHoverOut = (selectedNodeIndex, controllerIndex, childNodeIndex) => { + const parent = this.world.gltf.nodes[childNodeIndex]?.parentNode; + let oldHoverNode = parent; + while (oldHoverNode !== undefined && oldHoverNode !== this.firstCommonHoverNode) { + const hoverInformation = this.hoveredNodesIndices.get(oldHoverNode?.gltfObjectIndex); + if (hoverInformation?.callbackHoverOut !== undefined) { + hoverInformation.callbackHoverOut(selectedNodeIndex, controllerIndex); + break; + } + oldHoverNode = oldHoverNode.parentNode; + } + }; this.registerBehaveEngineNode("event/onSelect", interactivity.OnSelect); this.registerBehaveEngineNode("event/onHoverIn", interactivity.OnHoverIn); @@ -223,6 +246,9 @@ class SampleViewerDecorator extends interactivity.ADecorator { } receiveHover(pickingResult) { + if (pickingResult.node?.gltfObjectIndex === this.lastHoverNodeIndex) { + return; + } const oldHoverIndicies = new Set(); const newHoverNode = pickingResult.node; this.firstCommonHoverNode = undefined; @@ -632,33 +658,6 @@ class SampleViewerDecorator extends interactivity.ADecorator { animation.endCallback = callback; animation.createdTimestamp = this.world.animationTimer.elapsedSec(); } - - alertParentOnHoverIn(selectedNodeIndex, controllerIndex, childNodeIndex) { - const parent = this.world.gltf.nodes[childNodeIndex]?.parentNode; - let currentHoverNode = parent; - while (currentHoverNode !== undefined && currentHoverNode !== this.firstCommonHoverNode) { - const hoverInformation = this.hoveredNodesIndices.get(currentHoverNode?.gltfObjectIndex); - if (hoverInformation?.callbackHoverIn !== undefined) { - hoverInformation.callbackHoverIn(selectedNodeIndex, controllerIndex); - break; - } - currentHoverNode = currentHoverNode.parentNode; - } - - } - - alertParentOnHoverOut(selectedNodeIndex, controllerIndex, childNodeIndex) { - const parent = this.world.gltf.nodes[childNodeIndex]?.parentNode; - let oldHoverNode = parent; - while (oldHoverNode !== undefined && oldHoverNode !== this.firstCommonHoverNode) { - const hoverInformation = this.hoveredNodesIndices.get(oldHoverNode?.gltfObjectIndex); - if (hoverInformation?.callbackHoverOut !== undefined) { - hoverInformation.callbackHoverOut(selectedNodeIndex, controllerIndex); - break; - } - oldHoverNode = oldHoverNode.parentNode; - } - } } export { gltfGraph, GraphController }; \ No newline at end of file From e5cca9ea199ba9557b426b61cb14217523e7768c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 11 Sep 2025 17:58:17 +0200 Subject: [PATCH 36/82] moved logic into engine --- source/gltf/interactivity.js | 115 +++++------------------------------ 1 file changed, 14 insertions(+), 101 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index a33eb015..f3e5094c 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -166,69 +166,12 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.behaveEngine.stopAnimation = this.stopAnimation; this.behaveEngine.stopAnimationAt = this.stopAnimationAt; this.behaveEngine.startAnimation = this.startAnimation; + this.behaveEngine.getParentNodeIndex = this.getParentNodeIndex; this.registerBehaveEngineNode("animation/stop", interactivity.AnimationStop); this.registerBehaveEngineNode("animation/start", interactivity.AnimationStart); this.registerBehaveEngineNode("animation/stopAt", interactivity.AnimationStopAt); - this.behaveEngine.alertParentOnSelect = (selectionPoint, selectedNodeIndex, controllerIndex, selectionRayOrigin, childNodeIndex) => { - const parent = this.world.gltf.nodes[childNodeIndex]?.parentNode; - const pickingResult = { - position: selectionPoint, - node: selectedNodeIndex, - parentNode: parent, - rayOrigin: selectionRayOrigin - }; - this.receiveSelection(pickingResult); - }; - - this.behaveEngine.addNodeClickedListener = (nodeIndex, callback) => { - this.clickedNodesIndices.set(nodeIndex, callback); - }; - - this.behaveEngine.receiveSelection = (pickingResult) => { - if (pickingResult.node === undefined) { - return; - } - const selectedNode = pickingResult.node; - let currentNode = pickingResult.parentNode ?? pickingResult.node; - while (currentNode !== undefined) { - const callback = this.clickedNodesIndices.get(currentNode.gltfObjectIndex); - if (callback !== undefined) { - callback(pickingResult.position, selectedNode.gltfObjectIndex, 0, pickingResult.rayOrigin); - return; - } - currentNode = currentNode.parentNode; - } - }; - this.clickedNodesIndices = new Map(); - - this.behaveEngine.alertParentOnHoverIn = (selectedNodeIndex, controllerIndex, childNodeIndex) => { - const parent = this.world.gltf.nodes[childNodeIndex]?.parentNode; - let currentHoverNode = parent; - while (currentHoverNode !== undefined && currentHoverNode !== this.firstCommonHoverNode) { - const hoverInformation = this.hoveredNodesIndices.get(currentHoverNode?.gltfObjectIndex); - if (hoverInformation?.callbackHoverIn !== undefined) { - hoverInformation.callbackHoverIn(selectedNodeIndex, controllerIndex); - break; - } - currentHoverNode = currentHoverNode.parentNode; - } - }; - - this.behaveEngine.alertParentOnHoverOut = (selectedNodeIndex, controllerIndex, childNodeIndex) => { - const parent = this.world.gltf.nodes[childNodeIndex]?.parentNode; - let oldHoverNode = parent; - while (oldHoverNode !== undefined && oldHoverNode !== this.firstCommonHoverNode) { - const hoverInformation = this.hoveredNodesIndices.get(oldHoverNode?.gltfObjectIndex); - if (hoverInformation?.callbackHoverOut !== undefined) { - hoverInformation.callbackHoverOut(selectedNodeIndex, controllerIndex); - break; - } - oldHoverNode = oldHoverNode.parentNode; - } - }; - this.registerBehaveEngineNode("event/onSelect", interactivity.OnSelect); this.registerBehaveEngineNode("event/onHoverIn", interactivity.OnHoverIn); this.registerBehaveEngineNode("event/onHoverOut", interactivity.OnHoverOut); @@ -242,56 +185,27 @@ class SampleViewerDecorator extends interactivity.ADecorator { } receiveSelection(pickingResult) { - this.behaveEngine.receiveSelection(pickingResult); + if (pickingResult.node) { + this.select(pickingResult.node?.gltfObjectIndex, 0, pickingResult.position, pickingResult.rayOrigin); + } } receiveHover(pickingResult) { - if (pickingResult.node?.gltfObjectIndex === this.lastHoverNodeIndex) { - return; - } - const oldHoverIndicies = new Set(); - const newHoverNode = pickingResult.node; - this.firstCommonHoverNode = undefined; - if (this.lastHoverNodeIndex !== undefined && newHoverNode !== undefined) { - let currentOldHoverNode = this.world.gltf.nodes[this.lastHoverNodeIndex]; - while (currentOldHoverNode !== undefined) { - oldHoverIndicies.add(currentOldHoverNode.gltfObjectIndex); - currentOldHoverNode = currentOldHoverNode.parentNode; - } - let currentHoverNode = newHoverNode; - while (currentHoverNode !== undefined) { - if (oldHoverIndicies.has(currentHoverNode.gltfObjectIndex)) { - this.firstCommonHoverNode = currentHoverNode; - break; - } - currentHoverNode = currentHoverNode.parentNode; - } - } - let oldHoverNode = this.world.gltf.nodes[this.lastHoverNodeIndex]; - while (oldHoverNode !== undefined && oldHoverNode !== this.firstCommonHoverNode) { - const hoverInformation = this.hoveredNodesIndices.get(oldHoverNode?.gltfObjectIndex); - if (hoverInformation?.callbackHoverOut !== undefined) { - hoverInformation.callbackHoverOut(this.lastHoverNodeIndex, 0); - break; - } - oldHoverNode = oldHoverNode.parentNode; + this.hoverOn(pickingResult.node?.gltfObjectIndex, 0); + } + + getParentNodeIndex(nodeIndex) { + if (this.world === undefined || this.world.gltf === undefined) { + return undefined; } - let currentHoverNode = newHoverNode; - while (currentHoverNode !== undefined && currentHoverNode !== this.firstCommonHoverNode) { - const hoverInformation = this.hoveredNodesIndices.get(currentHoverNode?.gltfObjectIndex); - if (hoverInformation?.callbackHoverIn !== undefined) { - hoverInformation.callbackHoverIn(newHoverNode?.gltfObjectIndex, 0); - break; - } - currentHoverNode = currentHoverNode.parentNode; + const node = this.world.gltf.nodes[nodeIndex]; + if (node === undefined || node.parentNode === undefined) { + return undefined; } - - this.lastHoverNodeIndex = newHoverNode?.gltfObjectIndex; - + return node.parentNode.gltfObjectIndex; } loadGraph(graphIndex) { - this.clickedNodesIndices.clear(); const graphArray = this.world?.gltf?.extensions?.KHR_interactivity?.graphs; if (graphArray && graphArray.length > graphIndex) { const graphCopy = JSON.parse(JSON.stringify(graphArray[graphIndex])); @@ -310,7 +224,6 @@ class SampleViewerDecorator extends interactivity.ADecorator { } resetGraph() { - this.clickedNodesIndices.clear(); this.behaveEngine.loadBehaveGraph({nodes: [], types: [], events: [], declarations: [], variables: []}); if (this.world === undefined) { return; From fa001c9e842db1987e429976ff972ea4ce783bc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Mon, 15 Sep 2025 15:06:49 +0200 Subject: [PATCH 37/82] Calculate rayPosition from depth values to remove float texture requirement --- source/Renderer/renderer.js | 40 ++++++++++++++++------------ source/Renderer/shaders/picking.frag | 5 ++-- source/Renderer/shaders/picking.vert | 2 -- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index 081080e6..f3f4e010 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -151,7 +151,7 @@ class gltfRenderer context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); - context.texImage2D(context.TEXTURE_2D, 0, context.RGBA32F, 1, 1, 0, context.RGBA, context.FLOAT, null); + context.texImage2D(context.TEXTURE_2D, 0, context.R32UI, 1, 1, 0, context.RED_INTEGER, context.UNSIGNED_INT, null); context.bindTexture(context.TEXTURE_2D, null); this.pickingDepthTexture = context.createTexture(); @@ -197,10 +197,8 @@ class gltfRenderer context.bindFramebuffer(context.FRAMEBUFFER, this.pickingFramebuffer); context.framebufferTexture2D(context.FRAMEBUFFER, context.COLOR_ATTACHMENT0, context.TEXTURE_2D, this.pickingIDTexture, 0); context.framebufferTexture2D(context.FRAMEBUFFER, context.DEPTH_ATTACHMENT, context.TEXTURE_2D, this.pickingDepthTexture, 0); - if (context.supports_EXT_color_buffer_float) { - context.framebufferTexture2D(context.FRAMEBUFFER, context.COLOR_ATTACHMENT1, context.TEXTURE_2D, this.pickingPositionTexture, 0); - context.drawBuffers([context.COLOR_ATTACHMENT0, context.COLOR_ATTACHMENT1]); - } + context.framebufferTexture2D(context.FRAMEBUFFER, context.COLOR_ATTACHMENT1, context.TEXTURE_2D, this.pickingPositionTexture, 0); + context.drawBuffers([context.COLOR_ATTACHMENT0, context.COLOR_ATTACHMENT1]); this.hoverFramebuffer = context.createFramebuffer(); context.bindFramebuffer(context.FRAMEBUFFER, this.hoverFramebuffer); @@ -274,8 +272,9 @@ class gltfRenderer this.webGl.context.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, null); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); - this.webGl.context.clearColor(0, 0, 0, 0); - this.webGl.context.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT); + this.webGl.context.clearBufferfv(GL.COLOR, 0, new Float32Array([0, 0, 0, 0])); + this.webGl.context.clearBufferuiv(GL.COLOR, 1, new Uint32Array([0, 0, 0, 0])); + this.webGl.context.clear(GL.DEPTH_BUFFER_BIT); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, null); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); this.webGl.context.clearColor(0, 0, 0, 0); @@ -435,14 +434,15 @@ class gltfRenderer instanceWorldTransforms.push(instanceOffset); } - let pickingViewProjection = undefined; + let pickingProjection = undefined; + let pickingViewProjection = mat4.create(); const pickingX = state.pickingX; const pickingY = state.pickingY; if (state.triggerSelection && pickingX !== undefined && pickingY !== undefined) { - pickingViewProjection = currentCamera.getProjectionMatrixForPixel(pickingX - aspectOffsetX, this.currentHeight - pickingY - aspectOffsetY, aspectWidth, aspectHeight); - mat4.multiply(pickingViewProjection, pickingViewProjection, this.viewMatrix); + pickingProjection = currentCamera.getProjectionMatrixForPixel(pickingX - aspectOffsetX, this.currentHeight - pickingY - aspectOffsetY, aspectWidth, aspectHeight); + mat4.multiply(pickingViewProjection, pickingProjection, this.viewMatrix); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); this.webGl.context.viewport(0, 0, 1, 1); @@ -457,9 +457,9 @@ class gltfRenderer } if (state.enableHover && pickingX !== undefined && pickingY !== undefined) { - if (pickingViewProjection === undefined) { - pickingViewProjection = currentCamera.getProjectionMatrixForPixel(pickingX - aspectOffsetX, this.currentHeight - pickingY - aspectOffsetY, aspectWidth, aspectHeight); - mat4.multiply(pickingViewProjection, pickingViewProjection, this.viewMatrix); + if (pickingProjection === undefined) { + pickingProjection = currentCamera.getProjectionMatrixForPixel(pickingX - aspectOffsetX, this.currentHeight - pickingY - aspectOffsetY, aspectWidth, aspectHeight); + mat4.multiply(pickingViewProjection, pickingProjection, this.viewMatrix); } this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); this.webGl.context.viewport(0, 0, 1, 1); @@ -589,11 +589,17 @@ class gltfRenderer } } - if (found && this.webGl.context.supports_EXT_color_buffer_float) { + if (found) { + // WebGL does not allow reading from depth buffer this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT1); - const position = new Float32Array(4); - this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RGBA, this.webGl.context.FLOAT, position); - pickingResult.position = position.subarray(0, 3); + const position = new Uint32Array(1); + this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RED_INTEGER, this.webGl.context.UNSIGNED_INT, position); + const z = position[0] / 4294967295 * 2.0 - 1.0; + const clipSpacePosition = vec4.fromValues(0, 0, z, 1); + vec4.transformMat4(clipSpacePosition, clipSpacePosition, mat4.invert(mat4.create(), pickingProjection)); + vec4.divide(clipSpacePosition, clipSpacePosition, vec4.fromValues(clipSpacePosition[3], clipSpacePosition[3], clipSpacePosition[3], clipSpacePosition[3])); + const worldPos = vec4.transformMat4(vec4.create(), clipSpacePosition, mat4.invert(mat4.create(), this.viewMatrix)); + pickingResult.position = vec3.fromValues(worldPos[0], worldPos[1], worldPos[2]); } state.graphController.receiveSelection(pickingResult); diff --git a/source/Renderer/shaders/picking.frag b/source/Renderer/shaders/picking.frag index 8730e383..287244af 100644 --- a/source/Renderer/shaders/picking.frag +++ b/source/Renderer/shaders/picking.frag @@ -1,11 +1,12 @@ precision highp float; layout(location = 0) out vec4 id_color; -layout(location = 1) out vec4 position; +layout(location = 1) out uint position; + uniform vec4 u_PickingColor; in vec3 v_Position; void main() { id_color = u_PickingColor; - position = vec4(v_Position, 1.0); + position = uint(gl_FragCoord.z * 4294967295.0); // mapping [0, 1] to uint } diff --git a/source/Renderer/shaders/picking.vert b/source/Renderer/shaders/picking.vert index c4b5b7eb..7939c0ad 100644 --- a/source/Renderer/shaders/picking.vert +++ b/source/Renderer/shaders/picking.vert @@ -7,7 +7,6 @@ uniform mat4 u_NormalMatrix; in vec3 a_position; -out vec3 v_Position; vec4 getPosition() @@ -30,7 +29,6 @@ void main() { gl_PointSize = 1.0f; vec4 pos = u_ModelMatrix * getPosition(); - v_Position = vec3(pos.xyz) / pos.w; gl_Position = u_ViewProjectionMatrix * pos; } From 73b086255912e13dddcd708efbdf3130bdd27c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 16 Sep 2025 11:25:16 +0200 Subject: [PATCH 38/82] Increase depth precision --- source/Renderer/renderer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index f3f4e010..4c1aa3a0 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -160,7 +160,7 @@ class gltfRenderer context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); - context.texImage2D( context.TEXTURE_2D, 0, context.DEPTH_COMPONENT16, 1, 1, 0, context.DEPTH_COMPONENT, context.UNSIGNED_SHORT, null); + context.texImage2D( context.TEXTURE_2D, 0, context.DEPTH_COMPONENT24, 1, 1, 0, context.DEPTH_COMPONENT, context.UNSIGNED_INT, null); context.bindTexture(context.TEXTURE_2D, null); this.hoverIDTexture = context.createTexture(); @@ -178,7 +178,7 @@ class gltfRenderer context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); - context.texImage2D( context.TEXTURE_2D, 0, context.DEPTH_COMPONENT16, 1, 1, 0, context.DEPTH_COMPONENT, context.UNSIGNED_SHORT, null); + context.texImage2D( context.TEXTURE_2D, 0, context.DEPTH_COMPONENT24, 1, 1, 0, context.DEPTH_COMPONENT, context.UNSIGNED_INT, null); context.bindTexture(context.TEXTURE_2D, null); this.colorRenderBuffer = context.createRenderbuffer(); @@ -274,7 +274,7 @@ class gltfRenderer this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); this.webGl.context.clearBufferfv(GL.COLOR, 0, new Float32Array([0, 0, 0, 0])); this.webGl.context.clearBufferuiv(GL.COLOR, 1, new Uint32Array([0, 0, 0, 0])); - this.webGl.context.clear(GL.DEPTH_BUFFER_BIT); + this.webGl.context.clearBufferfv(GL.DEPTH, 0, new Float32Array([1.0])); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, null); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); this.webGl.context.clearColor(0, 0, 0, 0); From e7323b70eb7062f7dd1209961d1855baa5808f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 16 Sep 2025 11:26:03 +0200 Subject: [PATCH 39/82] Convert arrays to matricies --- source/gltf/interactivity.js | 46 +++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index a33eb015..8e8d8461 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -234,6 +234,14 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerBehaveEngineNode("event/onHoverOut", interactivity.OnHoverOut); } + convertArrayToMatrix(array, width) { + const matrix = []; + for (let i = 0; i < array.length; i += width) { + matrix.push(array.slice(i, i + width)); + } + return matrix; + } + setState(state) { this.resetGraph(); this.world = state; @@ -379,18 +387,19 @@ class SampleViewerDecorator extends interactivity.ADecorator { return [NaN, NaN]; case "float3": return [NaN, NaN, NaN]; - case "float2x2": case "float4": return [NaN, NaN, NaN, NaN]; + case "float2x2": + return [[NaN, NaN], [NaN, NaN]]; case "float3x3": - return [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]; + return [[NaN, NaN, NaN], [NaN, NaN, NaN], [NaN, NaN, NaN]]; case "float4x4": - return [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]; + return [[NaN, NaN, NaN, NaN], [NaN, NaN, NaN, NaN], [NaN, NaN, NaN, NaN], [NaN, NaN, NaN, NaN]]; } return undefined; } - traversePath(path, value = undefined) { + traversePath(path, type, value = undefined) { const pathPieces = path.split('/'); pathPieces.shift(); // Remove first empty piece from split const lastPiece = pathPieces[pathPieces.length - 1]; @@ -415,6 +424,15 @@ class SampleViewerDecorator extends interactivity.ADecorator { return undefined; } } + if (type === "float2x2" || type === "float3x3" || type === "float4x4") { + if (value !== undefined) { + value = value.flat(); + } else { + const width = parseInt(type.charAt(5)); + currentNode = this.convertArrayToMatrix(currentNode, width); + } + } + if (value !== undefined) { currentNode.animatedPropertyObjects[lastPiece].animate(value); } @@ -474,7 +492,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { type = "int"; this.registerJsonPointer(jsonPtr, (path) => { const fixedPath = path.slice(0, -7); // Remove ".length" - const result = this.traversePath(fixedPath); + const result = this.traversePath(fixedPath, type); if (result === undefined) { return 0; } @@ -483,7 +501,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { return; } this.registerJsonPointer(jsonPtr, (path) => { - const result = this.traversePath(path); + const result = this.traversePath(path, type); if (result === undefined) { return this.getDefaultValueFromType(type); } @@ -494,13 +512,13 @@ class SampleViewerDecorator extends interactivity.ADecorator { return; } this.registerJsonPointer(jsonPtr, (path) => { - const result = this.traversePath(path); + const result = this.traversePath(path, type); if (result === undefined) { return this.getDefaultValueFromType(type); } return result; }, (path, value) => { - this.traversePath(path, value); + this.traversePath(path, type, value); }, type, false); }; this.recurseAllAnimatedProperties(this.world.gltf, registerFunction); @@ -515,7 +533,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { const nodeCount = this.world.gltf.nodes.length; this.registerJsonPointer(`/nodes/${nodeCount}/children/${nodeCount}`, (path) => { - return this.traversePath(path); + return this.traversePath(path, "int"); }, (path, value) => {}, "int", true); this.registerJsonPointer(`/nodes/${nodeCount}/globalMatrix`, (path) => { const pathParts = path.split('/'); @@ -524,13 +542,13 @@ class SampleViewerDecorator extends interactivity.ADecorator { if (node.scene.gltfObjectIndex !== this.world.sceneIndex) { node.scene.applyTransformHierarchy(this.world.gltf); } - return node.worldTransform; // gl-matrix uses column-major order + return this.convertArrayToMatrix(node.worldTransform, 4); // gl-matrix uses column-major order }, (path, value) => {}, "float4x4", true); this.registerJsonPointer(`/nodes/${nodeCount}/matrix`, (path) => { const pathParts = path.split('/'); const nodeIndex = parseInt(pathParts[2]); const node = this.world.gltf.nodes[nodeIndex]; - return node.getLocalTransform(); // gl-matrix uses column-major order + return this.convertArrayToMatrix(node.getLocalTransform(), 4); // gl-matrix uses column-major order }, (path, value) => {}, "float4x4", true); this.registerJsonPointer(`/nodes/${nodeCount}/parent`, (path) => { const pathParts = path.split('/'); @@ -539,17 +557,17 @@ class SampleViewerDecorator extends interactivity.ADecorator { return node.parentNode?.gltfObjectIndex; }, (path, value) => {}, "int", true); this.registerJsonPointer(`/nodes/${nodeCount}/extensions/KHR_lights_punctual/light`, (path) => { - return this.traversePath(path); + return this.traversePath(path, "int"); }, (path, value) => {}, "int", true); const sceneCount = this.world.gltf.scenes.length; this.registerJsonPointer(`/scenes/${sceneCount}/nodes/${nodeCount}`, (path) => { - return this.traversePath(path); + return this.traversePath(path, "int"); }, (path, value) => {}, "int", true); const skinCount = this.world.gltf.skins.length; this.registerJsonPointer(`/skins/${skinCount}/joints/${nodeCount}`, (path) => { - return this.traversePath(path); + return this.traversePath(path, "int"); }, (path, value) => {}, "int", true); const animationCount = this.world.gltf.animations.length; From c8697ac2032062621cff9039dee51fdf6c89b6a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 16 Sep 2025 11:49:49 +0200 Subject: [PATCH 40/82] Use uint for picking color --- source/Renderer/renderer.js | 22 +++++++++++----------- source/Renderer/shader.js | 15 +++++++++++++++ source/Renderer/shaders/picking.frag | 5 ++--- source/gltf/node.js | 7 +++---- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index 4c1aa3a0..80f5d929 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -142,7 +142,7 @@ class gltfRenderer context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); - context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, 1, 1, 0, context.RGBA, context.UNSIGNED_BYTE, null); + context.texImage2D(context.TEXTURE_2D, 0, context.R32UI, 1, 1, 0, context.RED_INTEGER, context.UNSIGNED_INT, null); context.bindTexture(context.TEXTURE_2D, null); this.pickingPositionTexture = context.createTexture(); @@ -169,7 +169,7 @@ class gltfRenderer context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE); context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.NEAREST); - context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, 1, 1, 0, context.RGBA, context.UNSIGNED_BYTE, null); + context.texImage2D(context.TEXTURE_2D, 0, context.R32UI, 1, 1, 0, context.RED_INTEGER, context.UNSIGNED_INT, null); context.bindTexture(context.TEXTURE_2D, null); this.hoverDepthTexture = context.createTexture(); @@ -272,13 +272,13 @@ class gltfRenderer this.webGl.context.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, null); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); - this.webGl.context.clearBufferfv(GL.COLOR, 0, new Float32Array([0, 0, 0, 0])); + this.webGl.context.clearBufferuiv(GL.COLOR, 0, new Uint32Array([0, 0, 0, 0])); this.webGl.context.clearBufferuiv(GL.COLOR, 1, new Uint32Array([0, 0, 0, 0])); this.webGl.context.clearBufferfv(GL.DEPTH, 0, new Float32Array([1.0])); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, null); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); - this.webGl.context.clearColor(0, 0, 0, 0); - this.webGl.context.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT); + this.webGl.context.clearBufferuiv(GL.COLOR, 0, new Uint32Array([0, 0, 0, 0])); + this.webGl.context.clearBufferfv(GL.DEPTH, 0, new Float32Array([1.0])); this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, null); } @@ -557,8 +557,8 @@ class gltfRenderer this.webGl.context.viewport(0, 0, 1, 1); state.triggerSelection = false; this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT0); - const pixels = new Uint8Array(4); - this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RGBA, this.webGl.context.UNSIGNED_BYTE, pixels); + const pixels = new Uint32Array(1); + this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RED_INTEGER, this.webGl.context.UNSIGNED_INT, pixels); let rayOrigin = undefined; if (currentCamera.type === "orthographic") { @@ -581,7 +581,7 @@ class gltfRenderer let found = false; for (const node of state.gltf.nodes) { - if (node.pickingColor && vec4.equals(node.pickingColor, vec4.fromValues(pixels[0] / 255, pixels[1] / 255, pixels[2] / 255, pixels[3] / 255))) + if (node.pickingColor === pixels[0]) { found = true; pickingResult.node = node; @@ -613,15 +613,15 @@ class gltfRenderer this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); this.webGl.context.viewport(0, 0, 1, 1); this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT0); - const pixels = new Uint8Array(4); - this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RGBA, this.webGl.context.UNSIGNED_BYTE, pixels); + const pixels = new Uint32Array(1); + this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RED_INTEGER, this.webGl.context.UNSIGNED_INT, pixels); let pickingResult = { node: undefined, }; for (const node of state.gltf.nodes) { - if (node.pickingColor && vec4.equals(node.pickingColor, vec4.fromValues(pixels[0] / 255, pixels[1] / 255, pixels[2] / 255, pixels[3] / 255))) + if (node.pickingColor === pixels[0]) { pickingResult.node = node; break; diff --git a/source/Renderer/shader.js b/source/Renderer/shader.js index 2b7331e7..2e64ac47 100644 --- a/source/Renderer/shader.js +++ b/source/Renderer/shader.js @@ -177,6 +177,21 @@ class gltfShader case GL.INT_VEC3: this.gl.context.uniform3iv(uniform.loc, value); break; case GL.INT_VEC4: this.gl.context.uniform4iv(uniform.loc, value); break; + case GL.UNSIGNED_INT: + { + if(Array.isArray(value) || value instanceof Uint32Array || value instanceof Int32Array) + { + this.gl.context.uniform1uiv(uniform.loc, value); + }else{ + this.gl.context.uniform1ui(uniform.loc, value); + } + break; + } + + case GL.UNSIGNED_INT_VEC2: this.gl.context.uniform2uiv(uniform.loc, value); break; + case GL.UNSIGNED_INT_VEC3: this.gl.context.uniform3uiv(uniform.loc, value); break; + case GL.UNSIGNED_INT_VEC4: this.gl.context.uniform4uiv(uniform.loc, value); break; + case GL.FLOAT_MAT2: this.gl.context.uniformMatrix2fv(uniform.loc, false, value); break; case GL.FLOAT_MAT3: this.gl.context.uniformMatrix3fv(uniform.loc, false, value); break; case GL.FLOAT_MAT4: this.gl.context.uniformMatrix4fv(uniform.loc, false, value); break; diff --git a/source/Renderer/shaders/picking.frag b/source/Renderer/shaders/picking.frag index 287244af..b7e358b9 100644 --- a/source/Renderer/shaders/picking.frag +++ b/source/Renderer/shaders/picking.frag @@ -1,10 +1,9 @@ precision highp float; -layout(location = 0) out vec4 id_color; +layout(location = 0) out uint id_color; layout(location = 1) out uint position; -uniform vec4 u_PickingColor; -in vec3 v_Position; +uniform uint u_PickingColor; void main() { id_color = u_PickingColor; diff --git a/source/gltf/node.js b/source/gltf/node.js index 05b9875c..8ebafd75 100644 --- a/source/gltf/node.js +++ b/source/gltf/node.js @@ -22,7 +22,7 @@ class gltfNode extends GltfObject "skin", "weights" ]; - static currentPickingColor = 50; + static currentPickingColor = 1; constructor() { super(); @@ -53,9 +53,8 @@ class gltfNode extends GltfObject initGl(gltf, webGlContext) { if (this.mesh !== undefined) { - const mask = 0x000000FF; - this.pickingColor = vec4.fromValues((gltfNode.currentPickingColor & mask) / 255, ((gltfNode.currentPickingColor >>> 8) & mask) / 255, ((gltfNode.currentPickingColor >>> 16) & mask) / 255, ((gltfNode.currentPickingColor >>> 24) & mask) / 255); - gltfNode.currentPickingColor += 50; + this.pickingColor = gltfNode.currentPickingColor; + gltfNode.currentPickingColor += 1; } if (this.extensions?.EXT_mesh_gpu_instancing?.attributes !== undefined) { const firstAccessor = Object.values(this.extensions?.EXT_mesh_gpu_instancing?.attributes)[0]; From 9ced0209fec1e60a209d16b24fef7e31813fe374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 16 Sep 2025 12:22:02 +0200 Subject: [PATCH 41/82] Fix type detection --- source/gltf/interactivity.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index ba8699a7..7b51cb34 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -47,10 +47,9 @@ class GraphController { } /** - * Initialize the graph controller with the given state and debug flag. + * Initialize the graph controller with the given state. * This needs to be called every time a glTF assets is loaded. * @param {GltfState} state - The state of the application. - * @param {boolean} debug - Whether to enable debug mode. */ initializeGraphs(state) { this.state = state; @@ -267,10 +266,10 @@ class SampleViewerDecorator extends interactivity.ADecorator { } getTypeFromValue(value) { - if (value instanceof Number) { + if (typeof value === "number") { return "float"; } - if (value instanceof Boolean) { + if (typeof value === "boolean") { return "bool"; } if (value.length === 2) { From 820337414b5936517b89a67449ea7b2bf1a7444d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 16 Sep 2025 12:29:03 +0200 Subject: [PATCH 42/82] Create copy of event data --- source/gltf/interactivity.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 7b51cb34..c463a85c 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -141,7 +141,8 @@ class GraphController { */ dispatchEvent(eventName, data) { if (this.graphIndex !== undefined) { - this.decorator.dispatchCustomEvent(`KHR_INTERACTIVITY:${eventName}`, data); + const dataCopy = JSON.parse(JSON.stringify(data)); + this.decorator.dispatchCustomEvent(`KHR_INTERACTIVITY:${eventName}`, dataCopy); } } From 74d2b501ee4d498ada0b909c0d655fb7e7a0ea1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 16 Sep 2025 12:49:59 +0200 Subject: [PATCH 43/82] Clone values in pointer get/set --- source/gltf/interactivity.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index c463a85c..a57ad7ae 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -344,6 +344,12 @@ class SampleViewerDecorator extends interactivity.ADecorator { const width = parseInt(type.charAt(5)); currentNode = this.convertArrayToMatrix(currentNode, width); } + } else if (type === "float2" || type === "float3" || type === "float4") { + if (value !== undefined) { + value = value.slice(0); //clone array + } else { + currentNode = currentNode.slice(0); //clone array + } } if (value !== undefined) { @@ -535,7 +541,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { } activeCamera = this.world.gltf.cameras[cameraIndex]; } - return activeCamera.getRotation(); + return activeCamera.getRotation().slice(0); }, (path, value) => { //no-op }, "float4", true); @@ -552,7 +558,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { } activeCamera = this.world.gltf.cameras[cameraIndex]; } - return activeCamera.getPosition(); + return activeCamera.getPosition().slice(0); }, (path, value) => { //no-op }, "float3", true); From 191e46f96e09b2460aae02947f980f9bb1b9bba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 17 Sep 2025 16:12:44 +0200 Subject: [PATCH 44/82] Make graphController call generic --- source/gltf/interactivity.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index a57ad7ae..24b3f5cf 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -142,7 +142,7 @@ class GraphController { dispatchEvent(eventName, data) { if (this.graphIndex !== undefined) { const dataCopy = JSON.parse(JSON.stringify(data)); - this.decorator.dispatchCustomEvent(`KHR_INTERACTIVITY:${eventName}`, dataCopy); + this.decorator.dispatchCustomEvent(eventName, dataCopy); } } @@ -177,6 +177,10 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerBehaveEngineNode("event/onHoverOut", interactivity.OnHoverOut); } + dispatchCustomEvent(eventName, data) { + this.behaveEngine.dispatchCustomEvent(`KHR_INTERACTIVITY:${eventName}`, data); + } + convertArrayToMatrix(array, width) { const matrix = []; for (let i = 0; i < array.length; i += width) { From 8b59e869b7a861e618ca6fbf989c02b12cdfa076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 16 Oct 2025 16:41:51 +0200 Subject: [PATCH 45/82] Fix merge issue --- source/Renderer/renderer.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index 479a8777..fb186c88 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -1044,22 +1044,6 @@ class gltfRenderer this.webGl.setTexture(this.shader.getUniformLocation("u_SheenELUT"), state.environment, state.environment.sheenELUT, textureCount++); } - if(transmissionSampleTexture !== undefined && - state.environment && - state.renderingParameters.enabledExtensions.KHR_materials_transmission) - { - this.webGl.context.activeTexture(GL.TEXTURE0 + textureCount); - this.webGl.context.bindTexture(this.webGl.context.TEXTURE_2D, this.opaqueRenderTexture); - this.webGl.context.uniform1i(this.shader.getUniformLocation("u_TransmissionFramebufferSampler"), textureCount); - textureCount++; - - this.webGl.context.uniform2i(this.shader.getUniformLocation("u_TransmissionFramebufferSize"), this.opaqueFramebufferWidth, this.opaqueFramebufferHeight); - - this.webGl.context.uniformMatrix4fv(this.shader.getUniformLocation("u_ModelMatrix"),false, node.worldTransform); - this.webGl.context.uniformMatrix4fv(this.shader.getUniformLocation("u_ViewMatrix"),false, this.viewMatrix); - this.webGl.context.uniformMatrix4fv(this.shader.getUniformLocation("u_ProjectionMatrix"),false, this.projMatrix); - } - if (material.hasVolumeScatter && sampledTextures?.scatterSampleTexture !== undefined) { this.webGl.context.activeTexture(GL.TEXTURE0 + textureCount); From b2e6daf4613826b7b60e29979695aae72d12fdce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 16 Oct 2025 18:45:35 +0200 Subject: [PATCH 46/82] Add custom event listeners --- source/gltf/interactivity.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 24b3f5cf..e268abaa 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -56,7 +56,6 @@ class GraphController { this.graphIndex = undefined; this.playing = false; this.decorator.setState(state); - this.engine.clearCustomEventListeners(); this.engine.clearEventList(); this.engine.clearPointerInterpolation(); this.engine.clearVariableInterpolation(); @@ -146,6 +145,23 @@ class GraphController { } } + /** + * Adds a custom event listener to the decorator. + * Khronos test assets use test/onStart, test/onFail and test/onSuccess. + * @param {string} eventName + * @param {function(CustomEvent)} callback + */ + addCustomEventListener(eventName, callback) { + this.decorator.addCustomEventListener(eventName, callback); + } + + /** + * Clears all custom event listeners from the decorator. + */ + clearCustomEventListeners() { + this.decorator.clearCustomEventListeners(); + } + } class SampleViewerDecorator extends interactivity.ADecorator { @@ -181,6 +197,10 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.behaveEngine.dispatchCustomEvent(`KHR_INTERACTIVITY:${eventName}`, data); } + addCustomEventListener(eventName, callback) { + this.behaveEngine.addCustomEventListener(`KHR_INTERACTIVITY:${eventName}`, callback); + } + convertArrayToMatrix(array, width) { const matrix = []; for (let i = 0; i < array.length; i += width) { @@ -250,7 +270,6 @@ class SampleViewerDecorator extends interactivity.ADecorator { parent.animatedPropertyObjects[propertyName].rest(); }; this.recurseAllAnimatedProperties(this.world.gltf, resetAnimatedProperty); - this.behaveEngine.clearCustomEventListeners(); this.behaveEngine.clearEventList(); this.behaveEngine.clearPointerInterpolation(); this.behaveEngine.clearVariableInterpolation(); From 061f0c3264f1a207998b83f424d20ce36efe73e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 16 Oct 2025 18:57:17 +0200 Subject: [PATCH 47/82] WIP add interactivity tests --- .gitignore | 8 + .npmignore | 2 + package-lock.json | 297 +++++++++++++++++-------------- package.json | 11 +- playwright.config.js | 95 ++++++++++ rollup.config.js | 3 +- tests/baseTestConfig.ts | 11 ++ tests/downloadAssets.spec.ts | 67 +++++++ tests/interactivityTests.spec.ts | 94 ++++++++++ tests/testApp/index.html | 20 +++ tests/testApp/main.js | 20 +++ 11 files changed, 493 insertions(+), 135 deletions(-) create mode 100644 playwright.config.js create mode 100644 tests/baseTestConfig.ts create mode 100644 tests/downloadAssets.spec.ts create mode 100644 tests/interactivityTests.spec.ts create mode 100644 tests/testApp/index.html create mode 100644 tests/testApp/main.js diff --git a/.gitignore b/.gitignore index 1698cee3..03363a73 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,11 @@ desktop.ini .zed .idea .project + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ +/tests/testAssetDownloads diff --git a/.npmignore b/.npmignore index c7e90d7e..a510f369 100644 --- a/.npmignore +++ b/.npmignore @@ -12,6 +12,8 @@ documentation/ .gitattributes .gitmodules .eslintrc.json +dist/main.js +dist/index.html # files types to ignore rollup.config.js diff --git a/package-lock.json b/package-lock.json index a06334ff..de7c3727 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,11 @@ "json-ptr": "^3.1.0" }, "devDependencies": { + "@playwright/test": "^1.56.0", "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-wasm": "^6.2.2", + "@types/node": "^24.7.2", "concurrently": "^8.2.2", "eslint": "^9.5.0", "jsdoc-to-markdown": "^8.0.1", @@ -27,7 +29,7 @@ "rollup-plugin-copy": "^3.5.0", "rollup-plugin-glslify": "^1.3.1", "rollup-plugin-license": "^3.5.2", - "serve": "^14.2.4" + "serve": "^14.2.5" } }, "../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine": { @@ -384,6 +386,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", + "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", + "dev": true, + "dependencies": { + "playwright": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/plugin-commonjs": { "version": "26.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-26.0.3.tgz", @@ -743,12 +760,12 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.7.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz", - "integrity": "sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==", + "version": "24.7.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", + "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", "dev": true, "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~7.14.0" } }, "node_modules/@types/pako": { @@ -768,19 +785,6 @@ "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", "dev": true }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -1388,23 +1392,32 @@ } }, "node_modules/compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "dev": true, "dependencies": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", + "bytes": "3.1.2", + "compressible": "~2.0.18", "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", "vary": "~1.1.2" }, "engines": { "node": ">= 0.8.0" } }, + "node_modules/compression/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/compression/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1420,12 +1433,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, - "node_modules/compression/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3177,30 +3184,9 @@ } }, "node_modules/mime-db": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", - "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, "engines": { "node": ">= 0.6" @@ -3291,9 +3277,9 @@ "dev": true }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "dev": true, "engines": { "node": ">= 0.6" @@ -3333,9 +3319,9 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "dev": true, "engines": { "node": ">= 0.8" @@ -3529,6 +3515,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", + "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", + "dev": true, + "dependencies": { + "playwright-core": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", + "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3952,9 +3982,9 @@ ] }, "node_modules/serve": { - "version": "14.2.4", - "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.4.tgz", - "integrity": "sha512-qy1S34PJ/fcY8gjVGszDB3EXiPSk5FKhUa7tQe0UPRddxRidc2V6cNHPNewbE1D7MAkgLuWEt3Vw56vYy73tzQ==", + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.5.tgz", + "integrity": "sha512-Qn/qMkzCcMFVPb60E/hQy+iRLpiU8PamOfOSYoAHmmF+fFFmpPpqa6Oci2iWYpTdOUM3VF+TINud7CfbQnsZbA==", "dev": true, "dependencies": { "@zeit/schemas": "2.36.0", @@ -3964,7 +3994,7 @@ "chalk": "5.0.1", "chalk-template": "0.4.0", "clipboardy": "3.0.0", - "compression": "1.7.4", + "compression": "1.8.1", "is-port-reachable": "4.0.0", "serve-handler": "6.1.6", "update-check": "1.5.4" @@ -4591,9 +4621,9 @@ "dev": true }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", "dev": true }, "node_modules/universalify": { @@ -5153,6 +5183,15 @@ "dev": true, "optional": true }, + "@playwright/test": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", + "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", + "dev": true, + "requires": { + "playwright": "1.56.0" + } + }, "@rollup/plugin-commonjs": { "version": "26.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-26.0.3.tgz", @@ -5372,12 +5411,12 @@ "dev": true }, "@types/node": { - "version": "22.7.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz", - "integrity": "sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==", + "version": "24.7.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", + "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", "dev": true, "requires": { - "undici-types": "~6.19.2" + "undici-types": "~7.14.0" } }, "@types/pako": { @@ -5397,16 +5436,6 @@ "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", "dev": true }, - "accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "requires": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - } - }, "acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -5871,20 +5900,26 @@ } }, "compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "dev": true, "requires": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", + "bytes": "3.1.2", + "compressible": "~2.0.18", "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", "vary": "~1.1.2" }, "dependencies": { + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -5899,12 +5934,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true } } }, @@ -7288,28 +7317,11 @@ } }, "mime-db": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", - "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "requires": { - "mime-db": "1.52.0" - }, - "dependencies": { - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true - } - } - }, "mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -7374,9 +7386,9 @@ "dev": true }, "negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "dev": true }, "neo-async": { @@ -7407,9 +7419,9 @@ "dev": true }, "on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "dev": true }, "once": { @@ -7546,6 +7558,31 @@ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, + "playwright": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", + "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.56.0" + }, + "dependencies": { + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + } + } + }, + "playwright-core": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", + "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", + "dev": true + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7854,9 +7891,9 @@ "dev": true }, "serve": { - "version": "14.2.4", - "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.4.tgz", - "integrity": "sha512-qy1S34PJ/fcY8gjVGszDB3EXiPSk5FKhUa7tQe0UPRddxRidc2V6cNHPNewbE1D7MAkgLuWEt3Vw56vYy73tzQ==", + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.5.tgz", + "integrity": "sha512-Qn/qMkzCcMFVPb60E/hQy+iRLpiU8PamOfOSYoAHmmF+fFFmpPpqa6Oci2iWYpTdOUM3VF+TINud7CfbQnsZbA==", "dev": true, "requires": { "@zeit/schemas": "2.36.0", @@ -7866,7 +7903,7 @@ "chalk": "5.0.1", "chalk-template": "0.4.0", "clipboardy": "3.0.0", - "compression": "1.7.4", + "compression": "1.8.1", "is-port-reachable": "4.0.0", "serve-handler": "6.1.6", "update-check": "1.5.4" @@ -8355,9 +8392,9 @@ "dev": true }, "undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", "dev": true }, "universalify": { diff --git a/package.json b/package.json index 82ec3cce..3da96765 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,10 @@ "scripts": { "build": "rollup -c", "watch": "rollup -cw", + "testApp": "npm run build & npx serve ./dist", + "test": "npx playwright test", "prepublishOnly": "npm run build && npm run build_docs", "build_docs": "jsdoc2md source/gltf-sample-renderer.js source/GltfView/gltf_view.js source/GltfState/gltf_state.js source/ResourceLoader/resource_loader.js source/gltf/user_camera.js > API.md", - "test": "echo \"Error: no test specified\" && exit 1", "lint": "eslint source/**/*.js", "lint:fix": "eslint --fix source/**/*.js" }, @@ -26,17 +27,19 @@ "author": "Khronos Group Inc.", "license": "Apache-2.0", "dependencies": { + "@khronosgroup/khr-interactivity-authoring-engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", "fast-png": "^6.2.0", "gl-matrix": "^3.2.1", "globals": "^15.5.0", "jpeg-js": "^0.4.3", - "json-ptr": "^3.1.0", - "@khronosgroup/khr-interactivity-authoring-engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine" + "json-ptr": "^3.1.0" }, "devDependencies": { + "@playwright/test": "^1.56.0", "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-wasm": "^6.2.2", + "@types/node": "^24.7.2", "concurrently": "^8.2.2", "eslint": "^9.5.0", "jsdoc-to-markdown": "^8.0.1", @@ -44,7 +47,7 @@ "rollup-plugin-copy": "^3.5.0", "rollup-plugin-glslify": "^1.3.1", "rollup-plugin-license": "^3.5.2", - "serve": "^14.2.4" + "serve": "^14.2.5" }, "bugs": { "url": "https://github.com/KhronosGroup/glTF-Sample-Renderer/issues" diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000..ea977c48 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,95 @@ +// @ts-check +import { defineConfig, devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: "./tests", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "download", + testMatch: "**/downloadAssets.spec.ts", + use: { downloadFolder: "interactivity", testRepoURL: "https://raw.githubusercontent.com/KhronosGroup/glTF-Test-Assets-Interactivity/refs/heads/main/Tests/Interactivity/test-index.json" }, + }, + { + name: "download math", + testMatch: "**/downloadAssets.spec.ts", + use: { downloadFolder: "interactivity_math", testRepoURL: "https://raw.githubusercontent.com/KhronosGroup/glTF-Test-Assets-Interactivity/refs/heads/main/Tests/Interactivity/mathtests-index.json" }, + }, + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + dependencies: ["download", "download math"], + testIgnore: /.*\.downloadAssets\.spec\.ts/, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + dependencies: ["download"], + testIgnore: /.*\.downloadAssets\.spec\.ts/, + }, + + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + dependencies: ["download"], + testIgnore: /.*\.downloadAssets\.spec\.ts/, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run testApp', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/rollup.config.js b/rollup.config.js index d422dc46..61ea8a4f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -38,7 +38,8 @@ export default { "assets/images/lut_sheen_E.png", ], dest: "dist/assets" }, - { src: ["source/libs/*", "!source/libs/hdrpng.js"], dest: "dist/libs" } + { src: ["source/libs/*", "!source/libs/hdrpng.js"], dest: "dist/libs" }, + { src: "tests/testApp/*", dest: "dist"} ] }), commonjs(), diff --git a/tests/baseTestConfig.ts b/tests/baseTestConfig.ts new file mode 100644 index 00000000..230f60e2 --- /dev/null +++ b/tests/baseTestConfig.ts @@ -0,0 +1,11 @@ +import {test as base} from "@playwright/test"; + +export type TestOptions = { + testRepoURL: string; + downloadFolder: string; +} + +export const test = base.extend({ + testRepoURL: ["test", {option: true}], + downloadFolder: ["testAssetDownload", {option: true}], +}); diff --git a/tests/downloadAssets.spec.ts b/tests/downloadAssets.spec.ts new file mode 100644 index 00000000..1d92edf9 --- /dev/null +++ b/tests/downloadAssets.spec.ts @@ -0,0 +1,67 @@ +import { expect } from "@playwright/test"; +import { test } from "./baseTestConfig"; +import fs from "fs"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +test("download assets", async ({ testRepoURL, downloadFolder }) => { + if (fs.existsSync(`${__dirname}/testAssetDownloads/${downloadFolder}`) && process.env.REDOWNLOAD_ASSETS !== "true") { + console.log(`Assets already downloaded in testAssetDownloads/${downloadFolder}, skipping download. Set REDOWNLOAD_ASSETS=true to force re-download.`); + return; + } + console.log(`Downloading assets to testAssetDownloads/${downloadFolder}`); + const response = await fetch(testRepoURL); + expect(response.ok).toBeTruthy(); + const data = await response.json(); + const parentUrl = testRepoURL.substring(0, testRepoURL.lastIndexOf("/")); + for (const asset of data) { + if (asset.name === "math/E") { + asset.name = "math/e"; + asset.variants = {"glTF-Binary": "e.glb"}; + } + if (asset.name === "math/Inf") { + asset.name = "math/inf"; + asset.variants = {"glTF-Binary": "inf.glb"}; + } + if (asset.name === "math/isInf") { + asset.name = "math/isinf"; + asset.variants = {"glTF-Binary": "isinf.glb"}; + } + if (asset.name === "math/isNaN") { + asset.name = "math/isnan"; + asset.variants = {"glTF-Binary": "isnan.glb"}; + } + if (asset.name === "math/matMul") { + asset.name = "math/matmul"; + asset.variants = {"glTF-Binary": "matmul.glb"}; + } + if (asset.name === "math/NaN") { + asset.name = "math/nan"; + asset.variants = {"glTF-Binary": "nan.glb"}; + } + if (asset.name === "math/Pi") { + asset.name = "math/pi"; + asset.variants = {"glTF-Binary": "pi.glb"}; + } + if (asset.name === "math/rotate2D") { + asset.name = "math/rotate2d"; + asset.variants = {"glTF-Binary": "rotate2d.glb"}; + } + if (asset.name === "math/rotate3D") { + asset.name = "math/rotate3d"; + asset.variants = {"glTF-Binary": "rotate3d.glb"}; + } + const path = `${asset.name}/glTF-Binary/${asset.variants?.["glTF-Binary"]}`; + const assetResponse = await fetch(`${parentUrl}/${path}`); + console.log(`Downloading ${parentUrl}/${path}`); + expect(assetResponse.ok).toBeTruthy(); + const arrayBuffer = await assetResponse.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + fs.mkdirSync(`${__dirname}/testAssetDownloads/${downloadFolder}/${asset.name}/glTF-Binary`, { recursive: true }); + fs.writeFileSync(`${__dirname}/testAssetDownloads/${downloadFolder}/${path}`, buffer); + asset.path = `${__dirname}/testAssetDownloads/${downloadFolder}/${path}`; + } + fs.writeFileSync(`${__dirname}/testAssetDownloads/${downloadFolder}/test-index.json`, JSON.stringify(data)); +}); diff --git a/tests/interactivityTests.spec.ts b/tests/interactivityTests.spec.ts new file mode 100644 index 00000000..45fb4a83 --- /dev/null +++ b/tests/interactivityTests.spec.ts @@ -0,0 +1,94 @@ +import { expect } from "@playwright/test"; +import { test } from "./baseTestConfig"; +import { ResourceLoader } from "../source/ResourceLoader/resource_loader"; +import { GltfState } from "../source/GltfState/gltf_state"; +import { GltfView } from "../source/GltfView/gltf_view"; +import fs from "fs"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +declare global { + interface Window { + resourceLoader: ResourceLoader; + state: GltfState; + view: GltfView; + TEST_TIME: number; + TEST_RESULT: boolean; + passTestData: (input: number | boolean) => void; + } +} + +const directories = fs.readdirSync(`${__dirname}/testAssetDownloads`); +for (const dir of directories) { + const configFile = `${__dirname}/testAssetDownloads/${dir}/test-index.json`; + if (fs.existsSync(configFile)) { + const fileContents = fs.readFileSync(configFile, "utf-8"); + const testAssets = JSON.parse(fileContents); + for (const asset of testAssets) { + if (asset.path) { + const path = asset.path; + const file = new Uint8Array(fs.readFileSync(path)); + test(`Testing asset ${path}`, async ({ page }) => { + await page.goto(""); + let testDuration : number | undefined = undefined; + let testResult : boolean | undefined = undefined; + const fun = (input: number | boolean) => { + if (typeof input === "number") { + testDuration = input; + } else if (typeof input === "boolean") { + testResult = input; + } + } + await page.exposeFunction("passTestData", fun); + const success = await page.evaluate(async (file) => { + const resourceLoader = window.resourceLoader as ResourceLoader; + const state = window.state as GltfState; + const glTF = await resourceLoader.loadGltf(file.buffer); + state.gltf = glTF; + const defaultScene = state.gltf.scene; + state.sceneIndex = defaultScene === undefined ? 0 : defaultScene; + state.cameraNodeIndex = undefined; + state.graphController.addCustomEventListener("test/onStart", (event) => { + window.passTestData(event.detail.expectedDuration); + window.TEST_TIME = event.detail.expectedDuration; + }); + state.graphController.addCustomEventListener("test/onSuccess", () => { + window.passTestData(true); + window.TEST_RESULT = true; + }); + state.graphController.addCustomEventListener("test/onFailed", () => { + window.passTestData(false); + window.TEST_RESULT = false; + }); + state.animationTimer.start(); + if (state.gltf?.extensions?.KHR_interactivity?.graphs !== undefined) { + state.graphController.initializeGraphs(state); + const graphIndex = state.gltf.extensions.KHR_interactivity.graph ?? 0; + state.graphController.startGraph(graphIndex); + state.graphController.resumeGraph(); + } else { + state.graphController.stopGraphEngine(); + } + return true; + }, file); + expect(success).toBeTruthy(); + await page.waitForFunction(() => { + return window.TEST_TIME !== undefined; + }, {timeout: 2000}); + if (testDuration! > 0) { + console.log("Test duration (s): ", testDuration); + } + await page.waitForFunction(() => { + return window.TEST_RESULT !== undefined; + }, {timeout: testDuration! * 1000 + 1000}); + if (testResult === false) { + console.log(await page.consoleMessages()); + } + expect(testResult).toBe(true); + }); + } + } + } +} diff --git a/tests/testApp/index.html b/tests/testApp/index.html new file mode 100644 index 00000000..d36b625c --- /dev/null +++ b/tests/testApp/index.html @@ -0,0 +1,20 @@ + + + + + glTF Sample Renderer Test Canvas + + + + + + + + + No Canvas! + + + + + + diff --git a/tests/testApp/main.js b/tests/testApp/main.js new file mode 100644 index 00000000..d183662f --- /dev/null +++ b/tests/testApp/main.js @@ -0,0 +1,20 @@ +import {GltfView} from "./gltf-viewer.module.js"; + + +const canvas = document.getElementById("canvas"); +const context = canvas.getContext("webgl2", {antialias: true}); +const view = new GltfView(context); +const resourceLoader = view.createResourceLoader(); +const state = view.createState(); + +const update = () => { + view.renderFrame(state, canvas.width, canvas.height); + window.requestAnimationFrame(update); +}; + +// After this start executing animation loop. +window.requestAnimationFrame(update); + +globalThis.resourceLoader = resourceLoader; +globalThis.state = state; +globalThis.view = view; From 4a9668f34f7f27eb6d19a30717e9472cce76a9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 17 Oct 2025 14:28:59 +0200 Subject: [PATCH 48/82] Use shorter test names --- tests/interactivityTests.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/interactivityTests.spec.ts b/tests/interactivityTests.spec.ts index 45fb4a83..2aa61033 100644 --- a/tests/interactivityTests.spec.ts +++ b/tests/interactivityTests.spec.ts @@ -30,7 +30,8 @@ for (const dir of directories) { if (asset.path) { const path = asset.path; const file = new Uint8Array(fs.readFileSync(path)); - test(`Testing asset ${path}`, async ({ page }) => { + const testName = path.substring(path.lastIndexOf("/testAssetDownloads/") + 19); + test(`Testing asset ${testName}`, async ({ page }) => { await page.goto(""); let testDuration : number | undefined = undefined; let testResult : boolean | undefined = undefined; From e44f2925d67995c944811c11fd19d5fa4e11be5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 17 Oct 2025 14:48:49 +0200 Subject: [PATCH 49/82] Always recalculate globalMatrix --- source/gltf/interactivity.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index e268abaa..76d5c636 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -480,10 +480,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerJsonPointer(`/nodes/${nodeCount}/globalMatrix`, (path) => { const pathParts = path.split('/'); const nodeIndex = parseInt(pathParts[2]); - const node = this.world.gltf.nodes[nodeIndex]; - if (node.scene.gltfObjectIndex !== this.world.sceneIndex) { - node.scene.applyTransformHierarchy(this.world.gltf); - } + const node = this.world.gltf.nodes[nodeIndex]; + node.scene.applyTransformHierarchy(this.world.gltf); return this.convertArrayToMatrix(node.worldTransform, 4); // gl-matrix uses column-major order }, (path, value) => {}, "float4x4", true); this.registerJsonPointer(`/nodes/${nodeCount}/matrix`, (path) => { From 97a28f3cc650be65d7ecaac093b560a6d79cf82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 21 Oct 2025 14:45:09 +0200 Subject: [PATCH 50/82] Fix test ignore --- playwright.config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/playwright.config.js b/playwright.config.js index ea977c48..8e3e35d2 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -49,20 +49,20 @@ export default defineConfig({ name: "chromium", use: { ...devices["Desktop Chrome"] }, dependencies: ["download", "download math"], - testIgnore: /.*\.downloadAssets\.spec\.ts/, + testIgnore: "**/downloadAssets.spec.ts", }, { name: "firefox", use: { ...devices["Desktop Firefox"] }, dependencies: ["download"], - testIgnore: /.*\.downloadAssets\.spec\.ts/, + testIgnore: "**/downloadAssets.spec.ts", }, { name: "webkit", use: { ...devices["Desktop Safari"] }, dependencies: ["download"], - testIgnore: /.*\.downloadAssets\.spec\.ts/, + testIgnore: "**/downloadAssets.spec.ts", }, /* Test against mobile viewports. */ From 17ad0c99dfcbbbf7828ef813b2b5d2dc35a825f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 21 Oct 2025 17:16:12 +0200 Subject: [PATCH 51/82] Fix formatting --- eslint.config.js | 6 +- source/GltfState/gltf_state.js | 6 +- source/GltfView/gltf_view.js | 42 +-- source/Renderer/renderer.js | 286 +++++++++++++------- source/Renderer/shader.js | 36 ++- source/Renderer/webgl.js | 3 +- source/gltf/accessor.js | 8 +- source/gltf/animation.js | 56 ++-- source/gltf/camera.js | 27 +- source/gltf/gltf.js | 86 +++--- source/gltf/interactivity.js | 459 ++++++++++++++++++++++----------- source/gltf/interpolator.js | 54 ++-- source/gltf/mesh.js | 3 +- source/gltf/node.js | 69 +++-- source/gltf/primitive.js | 35 ++- source/gltf/scene.js | 31 ++- source/gltf/skin.js | 10 +- source/gltf/texture.js | 8 +- source/gltf/utils.js | 6 +- 19 files changed, 758 insertions(+), 473 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 46d83c66..69fe6ae9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,7 +25,11 @@ export default [ "semi": "warn", "no-extra-semi": "warn", "no-undef": "warn", - "no-unused-vars": "warn", + "no-unused-vars": ["warn", + { + "argsIgnorePattern": "^_", + } + ], "no-empty": "warn", "no-redeclare": "warn", "no-prototype-builtins": "warn", diff --git a/source/GltfState/gltf_state.js b/source/GltfState/gltf_state.js index e5667f7e..b9064442 100644 --- a/source/GltfState/gltf_state.js +++ b/source/GltfState/gltf_state.js @@ -1,6 +1,6 @@ -import { GraphController } from '../gltf/interactivity.js'; -import { UserCamera } from '../gltf/user_camera.js'; -import { AnimationTimer } from '../gltf/utils.js'; +import { GraphController } from "../gltf/interactivity.js"; +import { UserCamera } from "../gltf/user_camera.js"; +import { AnimationTimer } from "../gltf/utils.js"; /** * GltfState containing a state for visualization in GltfView diff --git a/source/GltfView/gltf_view.js b/source/GltfView/gltf_view.js index f4a12383..d8b2bd79 100644 --- a/source/GltfView/gltf_view.js +++ b/source/GltfView/gltf_view.js @@ -100,8 +100,13 @@ class GltfView { transparentMaterialsCount: 0 }; } - const nodes = scene.gatherNodes(state.gltf, state.renderingParameters.enabledExtensions).nodes; - const activeMeshes = nodes.filter(node => node.mesh !== undefined).map(node => state.gltf.meshes[node.mesh]); + const nodes = scene.gatherNodes( + state.gltf, + state.renderingParameters.enabledExtensions + ).nodes; + const activeMeshes = nodes + .filter((node) => node.mesh !== undefined) + .map((node) => state.gltf.meshes[node.mesh]); const activePrimitives = activeMeshes .reduce((acc, mesh) => acc.concat(mesh.primitives), []) .filter((primitive) => primitive.material !== undefined); @@ -156,31 +161,33 @@ class GltfView { }; } - _animate(state) - { - if(state.gltf === undefined || state.gltf.animations === undefined) - { + _animate(state) { + if (state.gltf === undefined || state.gltf.animations === undefined) { return; } let disabledAnimations = []; let enabledAnimations = []; - if (state.gltf?.extensions?.KHR_interactivity !== undefined && state.renderingParameters.enabledExtensions.KHR_interactivity) { - if (state.graphController.playing){ + if ( + state.gltf?.extensions?.KHR_interactivity !== undefined && + state.renderingParameters.enabledExtensions.KHR_interactivity + ) { + if (state.graphController.playing) { for (const animation of state.gltf.animations) { if (animation.createdTimestamp !== undefined) { enabledAnimations.push(animation); } } } - } else if(state.animationIndices !== undefined) - { - disabledAnimations = state.gltf.animations.filter( (anim, index) => { + } else if (state.animationIndices !== undefined) { + disabledAnimations = state.gltf.animations.filter((anim, index) => { return false === state.animationIndices.includes(index); }); - enabledAnimations = state.animationIndices.map(index => { - return state.gltf.animations[index]; - }).filter(animation => animation !== undefined); + enabledAnimations = state.animationIndices + .map((index) => { + return state.gltf.animations[index]; + }) + .filter((animation) => animation !== undefined); for (const animation of enabledAnimations) { if (animation.createdTimestamp !== undefined) { animation.reset(); @@ -188,18 +195,15 @@ class GltfView { } } - for(const disabledAnimation of disabledAnimations) - { + for (const disabledAnimation of disabledAnimations) { disabledAnimation.advance(state.gltf, undefined); } const t = state.animationTimer.elapsedSec(); - for(const animation of enabledAnimations) - { + for (const animation of enabledAnimations) { animation.advance(state.gltf, t); } - } } diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index cb5f1664..6c4bc365 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -1,29 +1,29 @@ -import { mat4, mat3, vec3, quat, vec4 } from 'gl-matrix'; -import { ShaderCache } from './shader_cache.js'; -import { GltfState } from '../GltfState/gltf_state.js'; -import { gltfWebGl, GL } from './webgl.js'; -import { EnvironmentRenderer } from './environment_renderer.js'; - -import pbrShader from './shaders/pbr.frag'; -import pickingShader from './shaders/picking.frag'; -import pickingVertShader from './shaders/picking.vert'; -import brdfShader from './shaders/brdf.glsl'; -import iridescenceShader from './shaders/iridescence.glsl'; -import materialInfoShader from './shaders/material_info.glsl'; -import iblShader from './shaders/ibl.glsl'; -import punctualShader from './shaders/punctual.glsl'; -import primitiveShader from './shaders/primitive.vert'; -import texturesShader from './shaders/textures.glsl'; -import tonemappingShader from './shaders/tonemapping.glsl'; -import shaderFunctions from './shaders/functions.glsl'; -import animationShader from './shaders/animation.glsl'; -import cubemapVertShader from './shaders/cubemap.vert'; -import cubemapFragShader from './shaders/cubemap.frag'; -import scatterShader from './shaders/scatter.frag'; -import specularGlossinesShader from './shaders/specular_glossiness.frag'; -import { gltfLight } from '../gltf/light.js'; -import { jsToGl } from '../gltf/utils.js'; -import { gltfMaterial } from '../gltf/material.js'; +import { mat4, mat3, vec3, quat, vec4 } from "gl-matrix"; +import { ShaderCache } from "./shader_cache.js"; +import { GltfState } from "../GltfState/gltf_state.js"; +import { gltfWebGl, GL } from "./webgl.js"; +import { EnvironmentRenderer } from "./environment_renderer.js"; + +import pbrShader from "./shaders/pbr.frag"; +import pickingShader from "./shaders/picking.frag"; +import pickingVertShader from "./shaders/picking.vert"; +import brdfShader from "./shaders/brdf.glsl"; +import iridescenceShader from "./shaders/iridescence.glsl"; +import materialInfoShader from "./shaders/material_info.glsl"; +import iblShader from "./shaders/ibl.glsl"; +import punctualShader from "./shaders/punctual.glsl"; +import primitiveShader from "./shaders/primitive.vert"; +import texturesShader from "./shaders/textures.glsl"; +import tonemappingShader from "./shaders/tonemapping.glsl"; +import shaderFunctions from "./shaders/functions.glsl"; +import animationShader from "./shaders/animation.glsl"; +import cubemapVertShader from "./shaders/cubemap.vert"; +import cubemapFragShader from "./shaders/cubemap.frag"; +import scatterShader from "./shaders/scatter.frag"; +import specularGlossinesShader from "./shaders/specular_glossiness.frag"; +import { gltfLight } from "../gltf/light.js"; +import { jsToGl } from "../gltf/utils.js"; +import { gltfMaterial } from "../gltf/material.js"; class gltfRenderer { constructor(context) { @@ -348,20 +348,34 @@ class gltfRenderer { } prepareScene(state, scene) { - const newNodes = scene.gatherNodes(state.gltf, state.renderingParameters.enabledExtensions); this.selectionDrawables = newNodes.selectableNodes - .filter(node => node.mesh !== undefined) - .reduce((acc, node) => acc.concat(state.gltf.meshes[node.mesh].primitives.map( (primitive, index) => { - return {node: node, primitive: primitive, primitiveIndex: index}; - })), []); + .filter((node) => node.mesh !== undefined) + .reduce( + (acc, node) => + acc.concat( + state.gltf.meshes[node.mesh].primitives.map((primitive, index) => { + return { node: node, primitive: primitive, primitiveIndex: index }; + }) + ), + [] + ); this.hoverDrawables = newNodes.hoverableNodes - .filter(node => node.mesh !== undefined) - .reduce((acc, node) => acc.concat(state.gltf.meshes[node.mesh].primitives.map( (primitive, index) => { - return {node: node, primitive: primitive, primitiveIndex: index}; - })), []); + .filter((node) => node.mesh !== undefined) + .reduce( + (acc, node) => + acc.concat( + state.gltf.meshes[node.mesh].primitives.map((primitive, index) => { + return { node: node, primitive: primitive, primitiveIndex: index }; + }) + ), + [] + ); - if (newNodes.nodes.length === this.nodes?.length && newNodes.nodes.every((element, i) => element === this.nodes[i])) { + if ( + newNodes.nodes.length === this.nodes?.length && + newNodes.nodes.every((element, i) => element === this.nodes[i]) + ) { return; } this.nodes = newNodes.nodes; @@ -369,11 +383,17 @@ class gltfRenderer { // collect drawables by essentially zipping primitives (for geometry and material) // and nodes for the transform const drawables = this.nodes - .filter(node => node.mesh !== undefined) - .reduce((acc, node) => acc.concat(state.gltf.meshes[node.mesh].primitives.map( (primitive, index) => { - return {node: node, primitive: primitive, primitiveIndex: index}; - })), []) - .filter(({primitive}) => primitive.material !== undefined); + .filter((node) => node.mesh !== undefined) + .reduce( + (acc, node) => + acc.concat( + state.gltf.meshes[node.mesh].primitives.map((primitive, index) => { + return { node: node, primitive: primitive, primitiveIndex: index }; + }) + ), + [] + ) + .filter(({ primitive }) => primitive.material !== undefined); this.drawables = drawables; // opaque drawables don't need sorting @@ -438,8 +458,7 @@ class gltfRenderer { } // render complete gltf scene with given camera - drawScene(state, scene) - { + drawScene(state, scene) { this.prepareScene(state, scene); let currentCamera = undefined; @@ -572,36 +591,62 @@ class gltfRenderer { const pickingY = state.pickingY; if (state.triggerSelection && pickingX !== undefined && pickingY !== undefined) { - pickingProjection = currentCamera.getProjectionMatrixForPixel(pickingX - aspectOffsetX, this.currentHeight - pickingY - aspectOffsetY, aspectWidth, aspectHeight); + pickingProjection = currentCamera.getProjectionMatrixForPixel( + pickingX - aspectOffsetX, + this.currentHeight - pickingY - aspectOffsetY, + aspectWidth, + aspectHeight + ); mat4.multiply(pickingViewProjection, pickingProjection, this.viewMatrix); - this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); + this.webGl.context.bindFramebuffer( + this.webGl.context.FRAMEBUFFER, + this.pickingFramebuffer + ); this.webGl.context.viewport(0, 0, 1, 1); const fragDefines = []; this.pushFragParameterDefines(fragDefines, state); - for (const drawable of this.selectionDrawables) - { + for (const drawable of this.selectionDrawables) { let renderpassConfiguration = {}; renderpassConfiguration.picking = true; - this.drawPrimitive(state, renderpassConfiguration, drawable.primitive, drawable.node, pickingViewProjection); + this.drawPrimitive( + state, + renderpassConfiguration, + drawable.primitive, + drawable.node, + pickingViewProjection + ); } } if (state.enableHover && pickingX !== undefined && pickingY !== undefined) { if (pickingProjection === undefined) { - pickingProjection = currentCamera.getProjectionMatrixForPixel(pickingX - aspectOffsetX, this.currentHeight - pickingY - aspectOffsetY, aspectWidth, aspectHeight); + pickingProjection = currentCamera.getProjectionMatrixForPixel( + pickingX - aspectOffsetX, + this.currentHeight - pickingY - aspectOffsetY, + aspectWidth, + aspectHeight + ); mat4.multiply(pickingViewProjection, pickingProjection, this.viewMatrix); } - this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); + this.webGl.context.bindFramebuffer( + this.webGl.context.FRAMEBUFFER, + this.hoverFramebuffer + ); this.webGl.context.viewport(0, 0, 1, 1); const fragDefines = []; this.pushFragParameterDefines(fragDefines, state); - for (const drawable of this.hoverDrawables) - { + for (const drawable of this.hoverDrawables) { let renderpassConfiguration = {}; renderpassConfiguration.picking = true; - this.drawPrimitive(state, renderpassConfiguration, drawable.primitive, drawable.node, pickingViewProjection); + this.drawPrimitive( + state, + renderpassConfiguration, + drawable.primitive, + drawable.node, + pickingViewProjection + ); } } @@ -787,36 +832,53 @@ class gltfRenderer { // Handle selection if (state.triggerSelection) { - this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.pickingFramebuffer); + this.webGl.context.bindFramebuffer( + this.webGl.context.FRAMEBUFFER, + this.pickingFramebuffer + ); this.webGl.context.viewport(0, 0, 1, 1); state.triggerSelection = false; this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT0); const pixels = new Uint32Array(1); - this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RED_INTEGER, this.webGl.context.UNSIGNED_INT, pixels); + this.webGl.context.readPixels( + 0, + 0, + 1, + 1, + this.webGl.context.RED_INTEGER, + this.webGl.context.UNSIGNED_INT, + pixels + ); let rayOrigin = undefined; if (currentCamera.type === "orthographic") { const x = pickingX - aspectOffsetX; const y = this.currentHeight - pickingY - aspectOffsetY; - const orthoX = -currentCamera.orthographic.xmag + (2 * currentCamera.orthographic.xmag / aspectWidth) * (x + 0.5); - const orthoY = -currentCamera.orthographic.ymag + (2 * currentCamera.orthographic.ymag / aspectHeight) * (y + 0.5); + const orthoX = + -currentCamera.orthographic.xmag + + ((2 * currentCamera.orthographic.xmag) / aspectWidth) * (x + 0.5); + const orthoY = + -currentCamera.orthographic.ymag + + ((2 * currentCamera.orthographic.ymag) / aspectHeight) * (y + 0.5); rayOrigin = vec3.fromValues(orthoX, orthoY, -currentCamera.orthographic.znear); - vec3.transformMat4(rayOrigin, rayOrigin, currentCamera.getTransformMatrix(state.gltf)); + vec3.transformMat4( + rayOrigin, + rayOrigin, + currentCamera.getTransformMatrix(state.gltf) + ); } else { rayOrigin = currentCamera.getPosition(state.gltf); } - + let pickingResult = { node: undefined, position: undefined, - rayOrigin: rayOrigin, + rayOrigin: rayOrigin }; let found = false; - for (const node of state.gltf.nodes) - { - if (node.pickingColor === pixels[0]) - { + for (const node of state.gltf.nodes) { + if (node.pickingColor === pixels[0]) { found = true; pickingResult.node = node; break; @@ -827,42 +889,76 @@ class gltfRenderer { // WebGL does not allow reading from depth buffer this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT1); const position = new Uint32Array(1); - this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RED_INTEGER, this.webGl.context.UNSIGNED_INT, position); - const z = position[0] / 4294967295 * 2.0 - 1.0; + this.webGl.context.readPixels( + 0, + 0, + 1, + 1, + this.webGl.context.RED_INTEGER, + this.webGl.context.UNSIGNED_INT, + position + ); + const z = (position[0] / 4294967295) * 2.0 - 1.0; const clipSpacePosition = vec4.fromValues(0, 0, z, 1); - vec4.transformMat4(clipSpacePosition, clipSpacePosition, mat4.invert(mat4.create(), pickingProjection)); - vec4.divide(clipSpacePosition, clipSpacePosition, vec4.fromValues(clipSpacePosition[3], clipSpacePosition[3], clipSpacePosition[3], clipSpacePosition[3])); - const worldPos = vec4.transformMat4(vec4.create(), clipSpacePosition, mat4.invert(mat4.create(), this.viewMatrix)); + vec4.transformMat4( + clipSpacePosition, + clipSpacePosition, + mat4.invert(mat4.create(), pickingProjection) + ); + vec4.divide( + clipSpacePosition, + clipSpacePosition, + vec4.fromValues( + clipSpacePosition[3], + clipSpacePosition[3], + clipSpacePosition[3], + clipSpacePosition[3] + ) + ); + const worldPos = vec4.transformMat4( + vec4.create(), + clipSpacePosition, + mat4.invert(mat4.create(), this.viewMatrix) + ); pickingResult.position = vec3.fromValues(worldPos[0], worldPos[1], worldPos[2]); } state.graphController.receiveSelection(pickingResult); - - if (state.selectionCallback){ + + if (state.selectionCallback) { state.selectionCallback(pickingResult); } } - if (state.enableHover) { - this.webGl.context.bindFramebuffer(this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer); + if (state.enableHover) { + this.webGl.context.bindFramebuffer( + this.webGl.context.FRAMEBUFFER, + this.hoverFramebuffer + ); this.webGl.context.viewport(0, 0, 1, 1); this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT0); const pixels = new Uint32Array(1); - this.webGl.context.readPixels(0, 0, 1, 1, this.webGl.context.RED_INTEGER, this.webGl.context.UNSIGNED_INT, pixels); + this.webGl.context.readPixels( + 0, + 0, + 1, + 1, + this.webGl.context.RED_INTEGER, + this.webGl.context.UNSIGNED_INT, + pixels + ); let pickingResult = { - node: undefined, + node: undefined }; - for (const node of state.gltf.nodes) - { - if (node.pickingColor === pixels[0]) - { + for (const node of state.gltf.nodes) { + if (node.pickingColor === pixels[0]) { pickingResult.node = node; break; } } state.graphController.receiveHover(pickingResult); - if (state.hoverCallback){ + if (state.hoverCallback) { state.hoverCallback(pickingResult); } } @@ -1349,21 +1445,21 @@ class gltfRenderer { } switch (state.renderingParameters.toneMap) { - case GltfState.ToneMaps.KHR_PBR_NEUTRAL: - fragDefines.push("TONEMAP_KHR_PBR_NEUTRAL 1"); - break; - case GltfState.ToneMaps.ACES_NARKOWICZ: - fragDefines.push("TONEMAP_ACES_NARKOWICZ 1"); - break; - case GltfState.ToneMaps.ACES_HILL: - fragDefines.push("TONEMAP_ACES_HILL 1"); - break; - case GltfState.ToneMaps.ACES_HILL_EXPOSURE_BOOST: - fragDefines.push("TONEMAP_ACES_HILL_EXPOSURE_BOOST 1"); - break; - case GltfState.ToneMaps.NONE: - default: - break; + case GltfState.ToneMaps.KHR_PBR_NEUTRAL: + fragDefines.push("TONEMAP_KHR_PBR_NEUTRAL 1"); + break; + case GltfState.ToneMaps.ACES_NARKOWICZ: + fragDefines.push("TONEMAP_ACES_NARKOWICZ 1"); + break; + case GltfState.ToneMaps.ACES_HILL: + fragDefines.push("TONEMAP_ACES_HILL 1"); + break; + case GltfState.ToneMaps.ACES_HILL_EXPOSURE_BOOST: + fragDefines.push("TONEMAP_ACES_HILL_EXPOSURE_BOOST 1"); + break; + case GltfState.ToneMaps.NONE: + default: + break; } let debugOutputMapping = [ diff --git a/source/Renderer/shader.js b/source/Renderer/shader.js index d91ba885..905f023c 100644 --- a/source/Renderer/shader.js +++ b/source/Renderer/shader.js @@ -164,24 +164,38 @@ class gltfShader { this.gl.context.uniform4iv(uniform.loc, value); break; - case GL.UNSIGNED_INT: - { - if(Array.isArray(value) || value instanceof Uint32Array || value instanceof Int32Array) - { + case GL.UNSIGNED_INT: { + if ( + Array.isArray(value) || + value instanceof Uint32Array || + value instanceof Int32Array + ) { this.gl.context.uniform1uiv(uniform.loc, value); - }else{ + } else { this.gl.context.uniform1ui(uniform.loc, value); } break; } - case GL.UNSIGNED_INT_VEC2: this.gl.context.uniform2uiv(uniform.loc, value); break; - case GL.UNSIGNED_INT_VEC3: this.gl.context.uniform3uiv(uniform.loc, value); break; - case GL.UNSIGNED_INT_VEC4: this.gl.context.uniform4uiv(uniform.loc, value); break; + case GL.UNSIGNED_INT_VEC2: + this.gl.context.uniform2uiv(uniform.loc, value); + break; + case GL.UNSIGNED_INT_VEC3: + this.gl.context.uniform3uiv(uniform.loc, value); + break; + case GL.UNSIGNED_INT_VEC4: + this.gl.context.uniform4uiv(uniform.loc, value); + break; - case GL.FLOAT_MAT2: this.gl.context.uniformMatrix2fv(uniform.loc, false, value); break; - case GL.FLOAT_MAT3: this.gl.context.uniformMatrix3fv(uniform.loc, false, value); break; - case GL.FLOAT_MAT4: this.gl.context.uniformMatrix4fv(uniform.loc, false, value); break; + case GL.FLOAT_MAT2: + this.gl.context.uniformMatrix2fv(uniform.loc, false, value); + break; + case GL.FLOAT_MAT3: + this.gl.context.uniformMatrix3fv(uniform.loc, false, value); + break; + case GL.FLOAT_MAT4: + this.gl.context.uniformMatrix4fv(uniform.loc, false, value); + break; } } else if (log) { console.warn("Unkown uniform: " + uniformName); diff --git a/source/Renderer/webgl.js b/source/Renderer/webgl.js index 0817ebda..e965ea6c 100644 --- a/source/Renderer/webgl.js +++ b/source/Renderer/webgl.js @@ -169,8 +169,7 @@ class gltfWebGl { return false; } - if (gltfAccessor.glBuffer === undefined) - { + if (gltfAccessor.glBuffer === undefined) { if (gltfAccessor.componentType === 5130) { throw new Error("64-bit float attributes are not supported in WebGL2"); } diff --git a/source/gltf/accessor.js b/source/gltf/accessor.js index 5ed24cdf..4f5ba839 100644 --- a/source/gltf/accessor.js +++ b/source/gltf/accessor.js @@ -167,7 +167,7 @@ class gltfAccessor extends GltfObject { break; case 5130: // KHR_accessor_float64 this.filteredView = new Float64Array(arrayLength); - func = 'getFloat64'; + func = "getFloat64"; break; } @@ -325,7 +325,11 @@ class gltfAccessor extends GltfObject { ); break; case 5130: // KHR_accessor_float64 - valuesTypedView = new Float64Array(valuesBuffer.buffer, valuesByteOffset, valuesArrayLength); + valuesTypedView = new Float64Array( + valuesBuffer.buffer, + valuesByteOffset, + valuesArrayLength + ); break; } diff --git a/source/gltf/animation.js b/source/gltf/animation.js index 69e4f05e..c64604cc 100644 --- a/source/gltf/animation.js +++ b/source/gltf/animation.js @@ -59,18 +59,20 @@ class gltfAnimation extends GltfObject { this.stopCallback = undefined; } - computeMinMaxTime(gltf) - { - if(isNaN(this.maxTime) || isNaN(this.minTime)) - { + computeMinMaxTime(gltf) { + if (isNaN(this.maxTime) || isNaN(this.minTime)) { this.maxTime = -Infinity; this.minTime = Infinity; - for(let i = 0; i < this.channels.length; ++i) - { + for (let i = 0; i < this.channels.length; ++i) { const channel = this.channels[i]; const sampler = this.samplers[channel.sampler]; const input = gltf.accessors[sampler.input]; - if (input.max === undefined || input.min === undefined || input.max.length !== 1 || input.min.length !== 1) { + if ( + input.max === undefined || + input.min === undefined || + input.max.length !== 1 || + input.min.length !== 1 + ) { console.error("Invalid input accessor for animation channel:", channel); this.minTime = undefined; this.maxTime = undefined; @@ -78,12 +80,10 @@ class gltfAnimation extends GltfObject { } const max = input.max[0]; const min = input.min[0]; - if(max > this.maxTime) - { + if (max > this.maxTime) { this.maxTime = max; } - if(min < this.minTime) - { + if (min < this.minTime) { this.minTime = min; } } @@ -119,21 +119,29 @@ class gltfAnimation extends GltfObject { elapsedTime = this.startTime; endAnimation = true; } else if (this.stopTime !== undefined) { - if ((this.startTime < this.endTime && elapsedTime >= this.stopTime && this.stopTime >= this.startTime && this.stopTime < this.endTime) - || (this.startTime > this.endTime && elapsedTime <= this.stopTime && this.stopTime <= this.startTime && this.stopTime > this.endTime)) { + if ( + (this.startTime < this.endTime && + elapsedTime >= this.stopTime && + this.stopTime >= this.startTime && + this.stopTime < this.endTime) || + (this.startTime > this.endTime && + elapsedTime <= this.stopTime && + this.stopTime <= this.startTime && + this.stopTime > this.endTime) + ) { elapsedTime = this.stopTime; stopAnimation = true; } - } else if ((this.startTime < this.endTime && elapsedTime >= this.endTime) || (this.startTime > this.endTime && elapsedTime <= this.endTime)) { + } else if ( + (this.startTime < this.endTime && elapsedTime >= this.endTime) || + (this.startTime > this.endTime && elapsedTime <= this.endTime) + ) { elapsedTime = this.endTime; endAnimation = true; } } - - - for(let i = 0; i < this.interpolators.length; ++i) - { + for (let i = 0; i < this.interpolators.length; ++i) { const channel = this.channels[i]; const sampler = this.samplers[channel.sampler]; const interpolator = this.interpolators[i]; @@ -197,8 +205,16 @@ class gltfAnimation extends GltfObject { if (animatedArrayElement !== undefined) { stride = animatedProperty.restValue[animatedArrayElement]?.length ?? 1; } - - let interpolant = interpolator.interpolate(gltf, channel, sampler, elapsedTime, stride, this.maxTime, reverse); + + let interpolant = interpolator.interpolate( + gltf, + channel, + sampler, + elapsedTime, + stride, + this.maxTime, + reverse + ); if (interpolant === undefined) { animatedProperty.rest(); continue; diff --git a/source/gltf/camera.js b/source/gltf/camera.js index 0f668511..3848ec48 100644 --- a/source/gltf/camera.js +++ b/source/gltf/camera.js @@ -78,9 +78,8 @@ class gltfCamera extends GltfObject { getProjectionMatrixForPixel(x, y, width, height) { const projection = mat4.create(); - if (this.type === "perspective") - { - const aspectRatio = this.perspective.aspectRatio ?? (width / height); + if (this.type === "perspective") { + const aspectRatio = this.perspective.aspectRatio ?? width / height; const top = Math.tan(this.perspective.yfov / 2) * this.perspective.znear; const bottom = -top; const left = bottom * aspectRatio; @@ -102,19 +101,18 @@ class gltfCamera extends GltfObject { this.perspective.znear, this.perspective.zfar ); - - - } - else if (this.type === "orthographic") - { - const subLeft = -this.orthographic.xmag + (2 * this.orthographic.xmag / width) * x; - const subRight = subLeft + (2 * this.orthographic.xmag / width); - const subBottom = -this.orthographic.ymag + (2 * this.orthographic.ymag / height) * y; - const subTop = subBottom + (2 * this.orthographic.ymag / height); + } else if (this.type === "orthographic") { + const subLeft = -this.orthographic.xmag + ((2 * this.orthographic.xmag) / width) * x; + const subRight = subLeft + (2 * this.orthographic.xmag) / width; + const subBottom = -this.orthographic.ymag + ((2 * this.orthographic.ymag) / height) * y; + const subTop = subBottom + (2 * this.orthographic.ymag) / height; mat4.ortho( projection, - subLeft, subRight, subBottom, subTop, + subLeft, + subRight, + subBottom, + subTop, this.orthographic.znear, this.orthographic.zfar ); @@ -123,8 +121,7 @@ class gltfCamera extends GltfObject { return projection; } - getViewMatrix(gltf) - { + getViewMatrix(gltf) { let result = mat4.create(); mat4.invert(result, this.getTransformMatrix(gltf)); return result; diff --git a/source/gltf/gltf.js b/source/gltf/gltf.js index 63e135f6..444ecfcd 100644 --- a/source/gltf/gltf.js +++ b/source/gltf/gltf.js @@ -1,22 +1,22 @@ -import { gltfAccessor } from './accessor.js'; -import { gltfBuffer } from './buffer.js'; -import { gltfBufferView } from './buffer_view.js'; -import { gltfCamera } from './camera.js'; -import { gltfImage } from './image.js'; -import { gltfLight } from './light.js'; -import { gltfMaterial } from './material.js'; -import { gltfMesh } from './mesh.js'; -import { gltfNode } from './node.js'; -import { gltfSampler } from './sampler.js'; -import { gltfScene } from './scene.js'; -import { gltfTexture } from './texture.js'; -import { initGlForMembers, objectsFromJsons, objectFromJson } from './utils'; -import { gltfAsset } from './asset.js'; -import { GltfObject } from './gltf_object.js'; -import { gltfAnimation } from './animation.js'; -import { gltfSkin } from './skin.js'; -import { gltfVariant } from './variant.js'; -import { gltfGraph } from './interactivity.js'; +import { gltfAccessor } from "./accessor.js"; +import { gltfBuffer } from "./buffer.js"; +import { gltfBufferView } from "./buffer_view.js"; +import { gltfCamera } from "./camera.js"; +import { gltfImage } from "./image.js"; +import { gltfLight } from "./light.js"; +import { gltfMaterial } from "./material.js"; +import { gltfMesh } from "./mesh.js"; +import { gltfNode } from "./node.js"; +import { gltfSampler } from "./sampler.js"; +import { gltfScene } from "./scene.js"; +import { gltfTexture } from "./texture.js"; +import { initGlForMembers, objectsFromJsons, objectFromJson } from "./utils"; +import { gltfAsset } from "./asset.js"; +import { GltfObject } from "./gltf_object.js"; +import { gltfAnimation } from "./animation.js"; +import { gltfSkin } from "./skin.js"; +import { gltfVariant } from "./variant.js"; +import { gltfGraph } from "./interactivity.js"; const allowedExtensions = [ "KHR_accessor_float64", @@ -51,9 +51,17 @@ const allowedExtensions = [ class glTF extends GltfObject { static animatedProperties = []; - static readOnlyAnimatedProperties = ["animations", "cameras", "materials", "meshes", "nodes", "scene", "scenes", "skins"]; - constructor(file) - { + static readOnlyAnimatedProperties = [ + "animations", + "cameras", + "materials", + "meshes", + "nodes", + "scene", + "scenes", + "skins" + ]; + constructor(file) { super(); this.asset = undefined; this.accessors = []; @@ -106,14 +114,25 @@ class glTF extends GltfObject { this.skins = objectsFromJsons(json.skins, gltfSkin); if (json.extensions?.KHR_lights_punctual !== undefined) { - this.extensions.KHR_lights_punctual.lights = objectsFromJsons(json.extensions.KHR_lights_punctual.lights, gltfLight); + this.extensions.KHR_lights_punctual.lights = objectsFromJsons( + json.extensions.KHR_lights_punctual.lights, + gltfLight + ); } if (json.extensions?.KHR_materials_variants !== undefined) { - this.extensions.KHR_materials_variants.variants = objectsFromJsons(json.extensions.KHR_materials_variants?.variants, gltfVariant); - this.extensions.KHR_materials_variants.variants = enforceVariantsUniqueness(this.extensions.KHR_materials_variants.variants); + this.extensions.KHR_materials_variants.variants = objectsFromJsons( + json.extensions.KHR_materials_variants?.variants, + gltfVariant + ); + this.extensions.KHR_materials_variants.variants = enforceVariantsUniqueness( + this.extensions.KHR_materials_variants.variants + ); } if (json.extensions?.KHR_interactivity !== undefined) { - this.extensions.KHR_interactivity.graphs = objectsFromJsons(json.extensions.KHR_interactivity?.graphs, gltfGraph); + this.extensions.KHR_interactivity.graphs = objectsFromJsons( + json.extensions.KHR_interactivity?.graphs, + gltfGraph + ); this.extensions.KHR_interactivity.graph = json.extensions.KHR_interactivity?.graph ?? 0; } @@ -133,17 +152,14 @@ class glTF extends GltfObject { } // Adds parent and scene information to each node - addNodeMetaInformation() - { - function recurseNodes(gltf, nodeIndex, scene, parent) - { + addNodeMetaInformation() { + function recurseNodes(gltf, nodeIndex, scene, parent) { const node = gltf.nodes[nodeIndex]; node.scene = scene; node.parentNode = parent; // recurse into children - for(const child of node.children) - { + for (const child of node.children) { recurseNodes(gltf, child, scene, node); } } @@ -233,10 +249,8 @@ class glTF extends GltfObject { } } -function enforceVariantsUniqueness(variants) -{ - for(let i=0;i graphIndex) { const graphCopy = JSON.parse(JSON.stringify(graphArray[graphIndex])); let events = graphCopy.events ?? []; - events = events.filter(event => event.id !== undefined); + events = events.filter((event) => event.id !== undefined); events = JSON.parse(JSON.stringify(events)); // Deep copy to avoid mutation for (const event of events) { for (const value of Object.values(event.values)) { @@ -256,7 +263,13 @@ class SampleViewerDecorator extends interactivity.ADecorator { } resetGraph() { - this.behaveEngine.loadBehaveGraph({nodes: [], types: [], events: [], declarations: [], variables: []}); + this.behaveEngine.loadBehaveGraph({ + nodes: [], + types: [], + events: [], + declarations: [], + variables: [] + }); if (this.world === undefined) { return; } @@ -326,17 +339,29 @@ class SampleViewerDecorator extends interactivity.ADecorator { case "float4": return [NaN, NaN, NaN, NaN]; case "float2x2": - return [[NaN, NaN], [NaN, NaN]]; + return [ + [NaN, NaN], + [NaN, NaN] + ]; case "float3x3": - return [[NaN, NaN, NaN], [NaN, NaN, NaN], [NaN, NaN, NaN]]; + return [ + [NaN, NaN, NaN], + [NaN, NaN, NaN], + [NaN, NaN, NaN] + ]; case "float4x4": - return [[NaN, NaN, NaN, NaN], [NaN, NaN, NaN, NaN], [NaN, NaN, NaN, NaN], [NaN, NaN, NaN, NaN]]; + return [ + [NaN, NaN, NaN, NaN], + [NaN, NaN, NaN, NaN], + [NaN, NaN, NaN, NaN], + [NaN, NaN, NaN, NaN] + ]; } return undefined; } traversePath(path, type, value = undefined) { - const pathPieces = path.split('/'); + const pathPieces = path.split("/"); pathPieces.shift(); // Remove first empty piece from split const lastPiece = pathPieces[pathPieces.length - 1]; if (value !== undefined) { @@ -381,7 +406,6 @@ class SampleViewerDecorator extends interactivity.ADecorator { return currentNode; } - recurseAllAnimatedProperties(gltfObject, callable, currentPath = "") { if (gltfObject === undefined || !(gltfObject instanceof GltfObject)) { return; @@ -400,23 +424,34 @@ class SampleViewerDecorator extends interactivity.ADecorator { } for (const key in gltfObject) { if (gltfObject[key] instanceof GltfObject) { - this.recurseAllAnimatedProperties(gltfObject[key], callable,currentPath + "/" + key); + this.recurseAllAnimatedProperties( + gltfObject[key], + callable, + currentPath + "/" + key + ); } else if (Array.isArray(gltfObject[key])) { if (gltfObject[key].length === 0 || !(gltfObject[key][0] instanceof GltfObject)) { continue; } for (let i = 0; i < gltfObject[key].length; i++) { - this.recurseAllAnimatedProperties(gltfObject[key][i], callable, currentPath + "/" + key + "/" + i); + this.recurseAllAnimatedProperties( + gltfObject[key][i], + callable, + currentPath + "/" + key + "/" + i + ); } } } for (const extensionName in gltfObject.extensions) { const extension = gltfObject.extensions[extensionName]; if (extension instanceof GltfObject) { - this.recurseAllAnimatedProperties(extension, callable, currentPath + "/extensions/" + extensionName); + this.recurseAllAnimatedProperties( + extension, + callable, + currentPath + "/extensions/" + extensionName + ); } } - } registerKnownPointers() { @@ -432,161 +467,281 @@ class SampleViewerDecorator extends interactivity.ADecorator { if (Array.isArray(parent[propertyName])) { jsonPtr += ".length"; type = "int"; - this.registerJsonPointer(jsonPtr, (path) => { - const fixedPath = path.slice(0, -7); // Remove ".length" - const result = this.traversePath(fixedPath, type); - if (result === undefined) { - return 0; - } - return result.length; - }, (path, value) => {}, type, true); + this.registerJsonPointer( + jsonPtr, + (path) => { + const fixedPath = path.slice(0, -7); // Remove ".length" + const result = this.traversePath(fixedPath, type); + if (result === undefined) { + return 0; + } + return result.length; + }, + (_path, _value) => {}, + type, + true + ); return; } - this.registerJsonPointer(jsonPtr, (path) => { + this.registerJsonPointer( + jsonPtr, + (path) => { + const result = this.traversePath(path, type); + if (result === undefined) { + return this.getDefaultValueFromType(type); + } + return result; + }, + (_path, _value) => {}, + type, + true + ); + } + if (type === undefined) { + return; + } + this.registerJsonPointer( + jsonPtr, + (path) => { const result = this.traversePath(path, type); if (result === undefined) { return this.getDefaultValueFromType(type); } return result; - }, (path, value) => {}, type, true); - } - if (type === undefined) { - return; - } - this.registerJsonPointer(jsonPtr, (path) => { - const result = this.traversePath(path, type); - if (result === undefined) { - return this.getDefaultValueFromType(type); - } - return result; - }, (path, value) => { - this.traversePath(path, type, value); - }, type, false); + }, + (path, value) => { + this.traversePath(path, type, value); + }, + type, + false + ); }; this.recurseAllAnimatedProperties(this.world.gltf, registerFunction); - this.registerJsonPointer(`/extensions/KHR_lights_punctual/lights.length`, (path) => { - const lights = this.world.gltf.extensions?.KHR_lights_punctual?.lights; - if (lights === undefined) { - return 0; - } - return lights.length; - }, (path, value) => {}, "int", true); + this.registerJsonPointer( + `/extensions/KHR_lights_punctual/lights.length`, + (_path) => { + const lights = this.world.gltf.extensions?.KHR_lights_punctual?.lights; + if (lights === undefined) { + return 0; + } + return lights.length; + }, + (_path, _value) => {}, + "int", + true + ); const nodeCount = this.world.gltf.nodes.length; - this.registerJsonPointer(`/nodes/${nodeCount}/children/${nodeCount}`, (path) => { - return this.traversePath(path, "int"); - }, (path, value) => {}, "int", true); - this.registerJsonPointer(`/nodes/${nodeCount}/globalMatrix`, (path) => { - const pathParts = path.split('/'); - const nodeIndex = parseInt(pathParts[2]); - const node = this.world.gltf.nodes[nodeIndex]; - node.scene.applyTransformHierarchy(this.world.gltf); - return this.convertArrayToMatrix(node.worldTransform, 4); // gl-matrix uses column-major order - }, (path, value) => {}, "float4x4", true); - this.registerJsonPointer(`/nodes/${nodeCount}/matrix`, (path) => { - const pathParts = path.split('/'); - const nodeIndex = parseInt(pathParts[2]); - const node = this.world.gltf.nodes[nodeIndex]; - return this.convertArrayToMatrix(node.getLocalTransform(), 4); // gl-matrix uses column-major order - }, (path, value) => {}, "float4x4", true); - this.registerJsonPointer(`/nodes/${nodeCount}/parent`, (path) => { - const pathParts = path.split('/'); - const nodeIndex = parseInt(pathParts[2]); - const node = this.world.gltf.nodes[nodeIndex]; - return node.parentNode?.gltfObjectIndex; - }, (path, value) => {}, "int", true); - this.registerJsonPointer(`/nodes/${nodeCount}/extensions/KHR_lights_punctual/light`, (path) => { - return this.traversePath(path, "int"); - }, (path, value) => {}, "int", true); + this.registerJsonPointer( + `/nodes/${nodeCount}/children/${nodeCount}`, + (path) => { + return this.traversePath(path, "int"); + }, + (_path, _value) => {}, + "int", + true + ); + this.registerJsonPointer( + `/nodes/${nodeCount}/globalMatrix`, + (path) => { + const pathParts = path.split("/"); + const nodeIndex = parseInt(pathParts[2]); + const node = this.world.gltf.nodes[nodeIndex]; + node.scene.applyTransformHierarchy(this.world.gltf); + return this.convertArrayToMatrix(node.worldTransform, 4); // gl-matrix uses column-major order + }, + (_path, _value) => {}, + "float4x4", + true + ); + this.registerJsonPointer( + `/nodes/${nodeCount}/matrix`, + (path) => { + const pathParts = path.split("/"); + const nodeIndex = parseInt(pathParts[2]); + const node = this.world.gltf.nodes[nodeIndex]; + return this.convertArrayToMatrix(node.getLocalTransform(), 4); // gl-matrix uses column-major order + }, + (_path, _value) => {}, + "float4x4", + true + ); + this.registerJsonPointer( + `/nodes/${nodeCount}/parent`, + (path) => { + const pathParts = path.split("/"); + const nodeIndex = parseInt(pathParts[2]); + const node = this.world.gltf.nodes[nodeIndex]; + return node.parentNode?.gltfObjectIndex; + }, + (_path, _value) => {}, + "int", + true + ); + this.registerJsonPointer( + `/nodes/${nodeCount}/extensions/KHR_lights_punctual/light`, + (path) => { + return this.traversePath(path, "int"); + }, + (_path, _value) => {}, + "int", + true + ); const sceneCount = this.world.gltf.scenes.length; - this.registerJsonPointer(`/scenes/${sceneCount}/nodes/${nodeCount}`, (path) => { - return this.traversePath(path, "int"); - }, (path, value) => {}, "int", true); + this.registerJsonPointer( + `/scenes/${sceneCount}/nodes/${nodeCount}`, + (path) => { + return this.traversePath(path, "int"); + }, + (_path, _value) => {}, + "int", + true + ); const skinCount = this.world.gltf.skins.length; - this.registerJsonPointer(`/skins/${skinCount}/joints/${nodeCount}`, (path) => { - return this.traversePath(path, "int"); - }, (path, value) => {}, "int", true); + this.registerJsonPointer( + `/skins/${skinCount}/joints/${nodeCount}`, + (path) => { + return this.traversePath(path, "int"); + }, + (_path, _value) => {}, + "int", + true + ); const animationCount = this.world.gltf.animations.length; - this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/isPlaying`, (path) => { - const pathParts = path.split('/'); - const animationIndex = parseInt(pathParts[2]); - const animation = this.world.gltf.animations[animationIndex]; - return animation.createdTimestamp !== undefined; - }, (path, value) => {}, "bool", true); - this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/minTime`, (path) => { - const pathParts = path.split('/'); - const animationIndex = parseInt(pathParts[2]); - const animation = this.world.gltf.animations[animationIndex]; - animation.computeMinMaxTime(); - return animation.minTime; - }, (path, value) => {}, "float", true); - this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/maxTime`, (path) => { - const pathParts = path.split('/'); - const animationIndex = parseInt(pathParts[2]); - const animation = this.world.gltf.animations[animationIndex]; - animation.computeMinMaxTime(); - return animation.maxTime; - }, (path, value) => {}, "float", true); - this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/playhead`, (path) => { - const pathParts = path.split('/'); - const animationIndex = parseInt(pathParts[2]); - const animation = this.world.gltf.animations[animationIndex]; - if (animation.interpolators.length === 0) { - return NaN; - } - return animation.interpolators[0].prevT; - }, (path, value) => {}, "float", true); - this.registerJsonPointer(`/animations/${animationCount}/extensions/KHR_interactivity/virtualPlayhead`, (path) => { - const pathParts = path.split('/'); - const animationIndex = parseInt(pathParts[2]); - const animation = this.world.gltf.animations[animationIndex]; - if (animation.interpolators.length === 0) { - return NaN; - } - return animation.interpolators[0].prevRequestedT; - }, (path, value) => {}, "float", true); - - this.registerJsonPointer(`/extensions/KHR_interactivity/activeCamera/rotation`, (path) => { - let activeCamera = this.world.userCamera; - if (this.world.cameraNodeIndex !== undefined) { - if (this.world.cameraNodeIndex < 0 || this.world.cameraNodeIndex >= this.world.gltf.nodes.length) { - return [NaN, NaN, NaN, NaN]; + this.registerJsonPointer( + `/animations/${animationCount}/extensions/KHR_interactivity/isPlaying`, + (path) => { + const pathParts = path.split("/"); + const animationIndex = parseInt(pathParts[2]); + const animation = this.world.gltf.animations[animationIndex]; + return animation.createdTimestamp !== undefined; + }, + (_path, _value) => {}, + "bool", + true + ); + this.registerJsonPointer( + `/animations/${animationCount}/extensions/KHR_interactivity/minTime`, + (path) => { + const pathParts = path.split("/"); + const animationIndex = parseInt(pathParts[2]); + const animation = this.world.gltf.animations[animationIndex]; + animation.computeMinMaxTime(); + return animation.minTime; + }, + (_path, _value) => {}, + "float", + true + ); + this.registerJsonPointer( + `/animations/${animationCount}/extensions/KHR_interactivity/maxTime`, + (path) => { + const pathParts = path.split("/"); + const animationIndex = parseInt(pathParts[2]); + const animation = this.world.gltf.animations[animationIndex]; + animation.computeMinMaxTime(); + return animation.maxTime; + }, + (_path, _value) => {}, + "float", + true + ); + this.registerJsonPointer( + `/animations/${animationCount}/extensions/KHR_interactivity/playhead`, + (path) => { + const pathParts = path.split("/"); + const animationIndex = parseInt(pathParts[2]); + const animation = this.world.gltf.animations[animationIndex]; + if (animation.interpolators.length === 0) { + return NaN; } - const cameraIndex = this.world.gltf.nodes[this.world.cameraNodeIndex].camera; - if (cameraIndex === undefined) { - return [NaN, NaN, NaN, NaN]; + return animation.interpolators[0].prevT; + }, + (_path, _value) => {}, + "float", + true + ); + this.registerJsonPointer( + `/animations/${animationCount}/extensions/KHR_interactivity/virtualPlayhead`, + (path) => { + const pathParts = path.split("/"); + const animationIndex = parseInt(pathParts[2]); + const animation = this.world.gltf.animations[animationIndex]; + if (animation.interpolators.length === 0) { + return NaN; } - activeCamera = this.world.gltf.cameras[cameraIndex]; - } - return activeCamera.getRotation().slice(0); - }, (path, value) => { - //no-op - }, "float4", true); - - this.registerJsonPointer(`/extensions/KHR_interactivity/activeCamera/position`, (path) => { - let activeCamera = this.world.userCamera; - if (this.world.cameraNodeIndex !== undefined) { - if (this.world.cameraNodeIndex < 0 || this.world.cameraNodeIndex >= this.world.gltf.nodes.length) { - return [NaN, NaN, NaN]; + return animation.interpolators[0].prevRequestedT; + }, + (_path, _value) => {}, + "float", + true + ); + + this.registerJsonPointer( + `/extensions/KHR_interactivity/activeCamera/rotation`, + (_path) => { + let activeCamera = this.world.userCamera; + if (this.world.cameraNodeIndex !== undefined) { + if ( + this.world.cameraNodeIndex < 0 || + this.world.cameraNodeIndex >= this.world.gltf.nodes.length + ) { + return [NaN, NaN, NaN, NaN]; + } + const cameraIndex = this.world.gltf.nodes[this.world.cameraNodeIndex].camera; + if (cameraIndex === undefined) { + return [NaN, NaN, NaN, NaN]; + } + activeCamera = this.world.gltf.cameras[cameraIndex]; } - const cameraIndex = this.world.gltf.nodes[this.world.cameraNodeIndex].camera; - if (cameraIndex === undefined) { - return [NaN, NaN, NaN]; + return activeCamera.getRotation().slice(0); + }, + (_path, _value) => { + //no-op + }, + "float4", + true + ); + + this.registerJsonPointer( + `/extensions/KHR_interactivity/activeCamera/position`, + (_path) => { + let activeCamera = this.world.userCamera; + if (this.world.cameraNodeIndex !== undefined) { + if ( + this.world.cameraNodeIndex < 0 || + this.world.cameraNodeIndex >= this.world.gltf.nodes.length + ) { + return [NaN, NaN, NaN]; + } + const cameraIndex = this.world.gltf.nodes[this.world.cameraNodeIndex].camera; + if (cameraIndex === undefined) { + return [NaN, NaN, NaN]; + } + activeCamera = this.world.gltf.cameras[cameraIndex]; } - activeCamera = this.world.gltf.cameras[cameraIndex]; - } - return activeCamera.getPosition().slice(0); - }, (path, value) => { - //no-op - }, "float3", true); + return activeCamera.getPosition().slice(0); + }, + (_path, _value) => { + //no-op + }, + "float3", + true + ); } registerJsonPointer(jsonPtr, getterCallback, setterCallback, typeName, readOnly) { - this.behaveEngine.registerJsonPointer(jsonPtr, getterCallback, setterCallback, typeName, readOnly); + this.behaveEngine.registerJsonPointer( + jsonPtr, + getterCallback, + setterCallback, + typeName, + readOnly + ); } getWorld() { @@ -618,4 +773,4 @@ class SampleViewerDecorator extends interactivity.ADecorator { } } -export { gltfGraph, GraphController }; \ No newline at end of file +export { gltfGraph, GraphController }; diff --git a/source/gltf/interpolator.js b/source/gltf/interpolator.js index 63b1d169..76ebbe76 100644 --- a/source/gltf/interpolator.js +++ b/source/gltf/interpolator.js @@ -10,10 +10,8 @@ class gltfInterpolator { this.prevRequestedT = 0.0; } - slerpQuat(q1, q2, t) - { - if (q1 instanceof Float64Array || q2 instanceof Float64Array) - { + slerpQuat(q1, q2, t) { + if (q1 instanceof Float64Array || q2 instanceof Float64Array) { glMatrix.setMatrixArrayType(Float64Array); } const qn1 = quat.create(); @@ -32,10 +30,8 @@ class gltfInterpolator { return quatResult; } - step(prevKey, output, stride) - { - if (output instanceof Float64Array) - { + step(prevKey, output, stride) { + if (output instanceof Float64Array) { glMatrix.setMatrixArrayType(Float64Array); } const result = new glMatrix.ARRAY_TYPE(stride); @@ -47,10 +43,8 @@ class gltfInterpolator { return result; } - linear(prevKey, nextKey, output, t, stride) - { - if (output instanceof Float64Array) - { + linear(prevKey, nextKey, output, t, stride) { + if (output instanceof Float64Array) { glMatrix.setMatrixArrayType(Float64Array); } const result = new glMatrix.ARRAY_TYPE(stride); @@ -71,8 +65,7 @@ class gltfInterpolator { const V = 1 * stride; const B = 2 * stride; - if (output instanceof Float64Array) - { + if (output instanceof Float64Array) { glMatrix.setMatrixArrayType(Float64Array); } const result = new glMatrix.ARRAY_TYPE(stride); @@ -103,10 +96,8 @@ class gltfInterpolator { this.prevKey = 0; } - interpolate(gltf, channel, sampler, t, stride, maxTime, reverse) - { - if(t === undefined) - { + interpolate(gltf, channel, sampler, t, stride, maxTime, reverse) { + if (t === undefined) { return undefined; } @@ -115,10 +106,9 @@ class gltfInterpolator { this.prevRequestedT = t; - if(output.length === stride) // no interpolation for single keyFrame animations - { - if (output instanceof Float64Array) - { + if (output.length === stride) { + // no interpolation for single keyFrame animations + if (output instanceof Float64Array) { glMatrix.setMatrixArrayType(Float64Array); } const result = jsToGlSlice(output, 0, stride); @@ -135,13 +125,11 @@ class gltfInterpolator { } t = clamp(t, input[0], input[input.length - 1]); - if (this.prevT > t && !reverse) - { + if (this.prevT > t && !reverse) { this.prevKey = 0; } - if (reverse && this.prevT < t) - { + if (reverse && this.prevT < t) { this.prevKey = input.length - 1; } @@ -150,20 +138,16 @@ class gltfInterpolator { // Find next keyframe: min{ t of input | t > prevKey } let nextKey = null; if (reverse) { - for (let i = this.prevKey; i >= 0; --i) - { - if (t >= input[i]) - { + for (let i = this.prevKey; i >= 0; --i) { + if (t >= input[i]) { nextKey = i; break; } } this.prevKey = clamp(nextKey + 1, nextKey, input.length - 1); } else { - for (let i = this.prevKey; i < input.length; ++i) - { - if (t <= input[i]) - { + for (let i = this.prevKey; i < input.length; ++i) { + if (t <= input[i]) { nextKey = clamp(i, 1, input.length - 1); break; } @@ -171,8 +155,6 @@ class gltfInterpolator { this.prevKey = clamp(nextKey - 1, 0, nextKey); } - - const keyDelta = Math.abs(input[nextKey] - input[this.prevKey]); // Normalize t: [t0, t1] -> [0, 1] diff --git a/source/gltf/mesh.js b/source/gltf/mesh.js index 746154da..1f4b1759 100644 --- a/source/gltf/mesh.js +++ b/source/gltf/mesh.js @@ -5,8 +5,7 @@ import { GltfObject } from "./gltf_object.js"; class gltfMesh extends GltfObject { static animatedProperties = ["weights"]; static readOnlyAnimatedProperties = ["weights", "primitives"]; - constructor() - { + constructor() { super(); this.primitives = []; this.name = undefined; diff --git a/source/gltf/node.js b/source/gltf/node.js index 2049c604..7d984300 100644 --- a/source/gltf/node.js +++ b/source/gltf/node.js @@ -1,30 +1,17 @@ -import { mat4, quat, vec3, vec4 } from 'gl-matrix'; -import { jsToGl, jsToGlSlice } from './utils.js'; -import { GltfObject } from './gltf_object.js'; -import { GL } from '../Renderer/webgl.js'; +import { mat4, quat, vec3 } from "gl-matrix"; +import { jsToGl, jsToGlSlice } from "./utils.js"; +import { GltfObject } from "./gltf_object.js"; +import { GL } from "../Renderer/webgl.js"; // contain: // transform // child indices (reference to scene array of nodes) -class gltfNode extends GltfObject -{ - static animatedProperties = [ - "rotation", - "scale", - "translation", - "weights" - ]; - static readOnlyAnimatedProperties = [ - "camera", - "children", - "mesh", - "skin", - "weights" - ]; +class gltfNode extends GltfObject { + static animatedProperties = ["rotation", "scale", "translation", "weights"]; + static readOnlyAnimatedProperties = ["camera", "children", "mesh", "skin", "weights"]; static currentPickingColor = 1; - constructor() - { + constructor() { super(); this.camera = undefined; this.children = []; @@ -50,8 +37,8 @@ class gltfNode extends GltfObject this.scene = undefined; } - initGl(gltf, webGlContext) - { + // eslint-disable-next-line no-unused-vars + initGl(gltf, webGlContext) { if (this.mesh !== undefined) { this.pickingColor = gltfNode.currentPickingColor; gltfNode.currentPickingColor += 1; @@ -74,12 +61,18 @@ class gltfNode extends GltfObject const rotationAccessor = this.extensions?.EXT_mesh_gpu_instancing?.attributes?.ROTATION; let rotationData = undefined; if (rotationAccessor !== undefined) { - if (rotationAccessor.componentType === GL.FLOAT || - (rotationAccessor.normalized && - (rotationAccessor.componentType === GL.BYTE || rotationAccessor.componentType === GL.SHORT))) { - rotationData = gltf.accessors[rotationAccessor].getNormalizedDeinterlacedView(gltf); + if ( + rotationAccessor.componentType === GL.FLOAT || + (rotationAccessor.normalized && + (rotationAccessor.componentType === GL.BYTE || + rotationAccessor.componentType === GL.SHORT)) + ) { + rotationData = + gltf.accessors[rotationAccessor].getNormalizedDeinterlacedView(gltf); } else { - console.warn("EXT_mesh_gpu_instancing rotation accessor must be a float, byte normalized, or short normalized"); + console.warn( + "EXT_mesh_gpu_instancing rotation accessor must be a float, byte normalized, or short normalized" + ); } } const scaleAccessor = this.extensions?.EXT_mesh_gpu_instancing?.attributes?.SCALE; @@ -118,11 +111,15 @@ class gltfNode extends GltfObject } if (jsonNode.extensions?.KHR_node_selectability !== undefined) { this.extensions.KHR_node_selectability = new KHR_node_selectability(); - this.extensions.KHR_node_selectability.fromJson(jsonNode.extensions.KHR_node_selectability); + this.extensions.KHR_node_selectability.fromJson( + jsonNode.extensions.KHR_node_selectability + ); } if (jsonNode.extensions?.KHR_node_hoverability !== undefined) { this.extensions.KHR_node_hoverability = new KHR_node_hoverability(); - this.extensions.KHR_node_hoverability.fromJson(jsonNode.extensions.KHR_node_hoverability); + this.extensions.KHR_node_hoverability.fromJson( + jsonNode.extensions.KHR_node_hoverability + ); } } @@ -163,9 +160,7 @@ class gltfNode extends GltfObject } class KHR_node_visibility extends GltfObject { - static animatedProperties = [ - "visible" - ]; + static animatedProperties = ["visible"]; constructor() { super(); this.visible = true; @@ -173,9 +168,7 @@ class KHR_node_visibility extends GltfObject { } class KHR_node_selectability extends GltfObject { - static animatedProperties = [ - "selectable" - ]; + static animatedProperties = ["selectable"]; constructor() { super(); this.selectable = true; @@ -183,9 +176,7 @@ class KHR_node_selectability extends GltfObject { } class KHR_node_hoverability extends GltfObject { - static animatedProperties = [ - "hoverable" - ]; + static animatedProperties = ["hoverable"]; constructor() { super(); this.hoverable = true; diff --git a/source/gltf/primitive.js b/source/gltf/primitive.js index 3390ad45..2babc9b2 100644 --- a/source/gltf/primitive.js +++ b/source/gltf/primitive.js @@ -15,8 +15,7 @@ import { generateTangents } from "../libs/mikktspace.js"; class gltfPrimitive extends GltfObject { static animatedProperties = []; static readOnlyAnimatedProperties = ["material"]; - constructor() - { + constructor() { super(); this.attributes = {}; this.targets = []; @@ -71,8 +70,12 @@ class gltfPrimitive extends GltfObject { } // Generate tangents with Mikktspace which needs normals and texcoords as inputs for triangles - if (this.attributes.TANGENT === undefined && this.attributes.NORMAL !== undefined && this.attributes.TEXCOORD_0 !== undefined && this.mode > 3) - { + if ( + this.attributes.TANGENT === undefined && + this.attributes.NORMAL !== undefined && + this.attributes.TEXCOORD_0 !== undefined && + this.mode > 3 + ) { console.info("Generating tangents using the MikkTSpace algorithm."); console.time("Tangent generation"); const tangentHash = `${this.attributes.POSITION}_${this.attributes.NORMAL}_${this.attributes.TEXCOORD_0}`; @@ -328,8 +331,7 @@ class gltfPrimitive extends GltfObject { throw new Error("64-bit float attributes are not supported in WebGL2"); } - if(this.indices !== undefined) - { + if (this.indices !== undefined) { // Primitive has indices. const indicesAccessor = gltf.accessors[this.indices]; @@ -952,28 +954,35 @@ class gltfPrimitive extends GltfObject { return; } - let positions = gltf.accessors[this.attributes.POSITION].getNormalizedDeinterlacedView(gltf); + let positions = + gltf.accessors[this.attributes.POSITION].getNormalizedDeinterlacedView(gltf); const normals = gltf.accessors[this.attributes.NORMAL].getNormalizedDeinterlacedView(gltf); - let texcoords = gltf.accessors[this.attributes.TEXCOORD_0].getNormalizedDeinterlacedView(gltf); - + let texcoords = + gltf.accessors[this.attributes.TEXCOORD_0].getNormalizedDeinterlacedView(gltf); if (positions instanceof Float64Array) { - console.warn("Cannot generate tangents: WebGL2 does not support 64-bit float attributes."); + console.warn( + "Cannot generate tangents: WebGL2 does not support 64-bit float attributes." + ); return; } else if (positions instanceof Float32Array === false) { positions = new Float32Array(positions); } if (normals instanceof Float64Array) { - console.warn("Cannot generate tangents: WebGL2 does not support 64-bit float attributes."); + console.warn( + "Cannot generate tangents: WebGL2 does not support 64-bit float attributes." + ); return; } else if (normals instanceof Float32Array === false) { console.warn("Cannot generate tangents: Normal attribute in wrong format"); return; } - + if (texcoords instanceof Float64Array) { - console.warn("Cannot generate tangents: WebGL2 does not support 64-bit float attributes."); + console.warn( + "Cannot generate tangents: WebGL2 does not support 64-bit float attributes." + ); return; } else if (texcoords instanceof Float32Array === false) { texcoords = new Float32Array(texcoords); diff --git a/source/gltf/scene.js b/source/gltf/scene.js index fabf2973..4966be10 100644 --- a/source/gltf/scene.js +++ b/source/gltf/scene.js @@ -4,8 +4,7 @@ import { GltfObject } from "./gltf_object"; class gltfScene extends GltfObject { static animatedProperties = []; static readOnlyAnimatedProperties = ["nodes"]; - constructor(nodes = [], name = undefined) - { + constructor(nodes = [], name = undefined) { super(); this.nodes = nodes; this.name = name; @@ -53,41 +52,45 @@ class gltfScene extends GltfObject { } } - - gatherNodes(gltf, enabledExtensions) - { + gatherNodes(gltf, enabledExtensions) { const nodes = []; const selectableNodes = []; const hoverableNodes = []; - function gatherNode(nodeIndex, visible, selectable, hoverable) - { + function gatherNode(nodeIndex, visible, selectable, hoverable) { const node = gltf.nodes[nodeIndex]; - if (!enabledExtensions.KHR_node_visibility || (node.extensions?.KHR_node_visibility?.visible !== false) && visible) { + if ( + !enabledExtensions.KHR_node_visibility || + (node.extensions?.KHR_node_visibility?.visible !== false && visible) + ) { nodes.push(node); } else { visible = false; } - if (!enabledExtensions.KHR_node_selectability || (node.extensions?.KHR_node_selectability?.selectable !== false) && selectable) { + if ( + !enabledExtensions.KHR_node_selectability || + (node.extensions?.KHR_node_selectability?.selectable !== false && selectable) + ) { selectableNodes.push(node); } else { selectable = false; } - if (!enabledExtensions.KHR_node_hoverability || (node.extensions?.KHR_node_hoverability?.hoverable !== false) && hoverable) { + if ( + !enabledExtensions.KHR_node_hoverability || + (node.extensions?.KHR_node_hoverability?.hoverable !== false && hoverable) + ) { hoverableNodes.push(node); } else { hoverable = false; } // recurse into children - for(const child of node.children) - { + for (const child of node.children) { gatherNode(child, visible, selectable, hoverable); } } - for (const node of this.nodes) - { + for (const node of this.nodes) { gatherNode(node, true, true, true); } diff --git a/source/gltf/skin.js b/source/gltf/skin.js index 273b0407..4c044348 100644 --- a/source/gltf/skin.js +++ b/source/gltf/skin.js @@ -11,8 +11,7 @@ import { gltfSampler } from "./sampler.js"; class gltfSkin extends GltfObject { static animatedProperties = []; static readOnlyAnimatedProperties = ["joints", "skeleton"]; - constructor() - { + constructor() { super(); this.name = ""; @@ -69,15 +68,16 @@ class gltfSkin extends GltfObject { this.jointTextureInfo.generateMips = false; } - computeJoints(gltf, webGlContext) - { + computeJoints(gltf, webGlContext) { let ibmAccessorData = null; if (this.inverseBindMatrices !== undefined) { const ibmAccessor = gltf.accessors[this.inverseBindMatrices]; if (ibmAccessor.componentType === GL.FLOAT) { ibmAccessorData = ibmAccessor.getDeinterlacedView(gltf); } else { - console.warn("EXT_mesh_gpu_instancing inverseBindMatrices accessor must be a float"); + console.warn( + "EXT_mesh_gpu_instancing inverseBindMatrices accessor must be a float" + ); } } diff --git a/source/gltf/texture.js b/source/gltf/texture.js index 4aaac9c2..2d5b4129 100644 --- a/source/gltf/texture.js +++ b/source/gltf/texture.js @@ -1,9 +1,9 @@ /* globals WebGl */ -import { fromKeys, initGlForMembers } from './utils.js'; -import { GL } from '../Renderer/webgl.js'; -import { GltfObject } from './gltf_object.js'; -import { vec2 } from 'gl-matrix'; +import { fromKeys, initGlForMembers } from "./utils.js"; +import { GL } from "../Renderer/webgl.js"; +import { GltfObject } from "./gltf_object.js"; +import { vec2 } from "gl-matrix"; class gltfTexture extends GltfObject { static animatedProperties = []; diff --git a/source/gltf/utils.js b/source/gltf/utils.js index 80c2a7e7..b17ad170 100644 --- a/source/gltf/utils.js +++ b/source/gltf/utils.js @@ -175,8 +175,7 @@ class AnimationTimer { elapsedSec() { if (this.paused) { return this.pausedTime / 1000; - } - else { + } else { return this.fixedTime || (performance.now() - this.startTime) / 1000; } } @@ -208,8 +207,7 @@ class AnimationTimer { if (!this.paused) { // Animation is running. this.startTime = performance.now(); - } - else { + } else { this.startTime = 0; } this.pausedTime = 0; From b6728aa5c39d6051795c1de6338b684b0492e318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 22 Oct 2025 18:59:49 +0200 Subject: [PATCH 52/82] Fix issues with pointer/get for int, bool and float --- source/gltf/gltf.js | 2 +- source/gltf/interactivity.js | 64 ++++++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/source/gltf/gltf.js b/source/gltf/gltf.js index 444ecfcd..5b201350 100644 --- a/source/gltf/gltf.js +++ b/source/gltf/gltf.js @@ -54,7 +54,7 @@ class glTF extends GltfObject { static readOnlyAnimatedProperties = [ "animations", "cameras", - "materials", + // "materials", materials.length need to be handled manually due to the default material "meshes", "nodes", "scene", diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index fe661809..6ede75c1 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -416,7 +416,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { } callable(currentPath, property, gltfObject, false); } - for (const property in gltfObject.constructor.readOnlyAnimatedProperties) { + for (const property of gltfObject.constructor.readOnlyAnimatedProperties) { if (gltfObject[property] === undefined) { continue; } @@ -464,6 +464,10 @@ class SampleViewerDecorator extends interactivity.ADecorator { let jsonPtr = currentPath + "/" + propertyName; let type = this.getTypeFromValue(parent[propertyName]); if (readOnly) { + if (type === "float") { + // All read-only number properties are currently integers + type = "int"; + } if (Array.isArray(parent[propertyName])) { jsonPtr += ".length"; type = "int"; @@ -473,9 +477,9 @@ class SampleViewerDecorator extends interactivity.ADecorator { const fixedPath = path.slice(0, -7); // Remove ".length" const result = this.traversePath(fixedPath, type); if (result === undefined) { - return 0; + return [0]; } - return result.length; + return [result.length]; }, (_path, _value) => {}, type, @@ -486,9 +490,12 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerJsonPointer( jsonPtr, (path) => { - const result = this.traversePath(path, type); + let result = this.traversePath(path, type); if (result === undefined) { - return this.getDefaultValueFromType(type); + result = this.getDefaultValueFromType(type); + } + if (type === "bool" || type === "int" || type === "float") { + result = [result]; } return result; }, @@ -496,6 +503,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { type, true ); + return; } if (type === undefined) { return; @@ -503,9 +511,12 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerJsonPointer( jsonPtr, (path) => { - const result = this.traversePath(path, type); + let result = this.traversePath(path, type); if (result === undefined) { - return this.getDefaultValueFromType(type); + result = this.getDefaultValueFromType(type); + } + if (type === "bool" || type === "int" || type === "float") { + result = [result]; } return result; }, @@ -523,9 +534,20 @@ class SampleViewerDecorator extends interactivity.ADecorator { (_path) => { const lights = this.world.gltf.extensions?.KHR_lights_punctual?.lights; if (lights === undefined) { - return 0; + return [0]; } - return lights.length; + return [lights.length]; + }, + (_path, _value) => {}, + "int", + true + ); + + this.registerJsonPointer( + `/materials.length`, + (_path) => { + // Return the number of materials excluding the default material + return [this.world.gltf.materials.length - 1]; }, (_path, _value) => {}, "int", @@ -536,7 +558,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerJsonPointer( `/nodes/${nodeCount}/children/${nodeCount}`, (path) => { - return this.traversePath(path, "int"); + return [this.traversePath(path, "int")]; }, (_path, _value) => {}, "int", @@ -573,7 +595,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { const pathParts = path.split("/"); const nodeIndex = parseInt(pathParts[2]); const node = this.world.gltf.nodes[nodeIndex]; - return node.parentNode?.gltfObjectIndex; + return [node.parentNode?.gltfObjectIndex]; }, (_path, _value) => {}, "int", @@ -582,7 +604,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerJsonPointer( `/nodes/${nodeCount}/extensions/KHR_lights_punctual/light`, (path) => { - return this.traversePath(path, "int"); + return [this.traversePath(path, "int")]; }, (_path, _value) => {}, "int", @@ -593,7 +615,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerJsonPointer( `/scenes/${sceneCount}/nodes/${nodeCount}`, (path) => { - return this.traversePath(path, "int"); + return [this.traversePath(path, "int")]; }, (_path, _value) => {}, "int", @@ -604,7 +626,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { this.registerJsonPointer( `/skins/${skinCount}/joints/${nodeCount}`, (path) => { - return this.traversePath(path, "int"); + return [this.traversePath(path, "int")]; }, (_path, _value) => {}, "int", @@ -618,7 +640,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { const pathParts = path.split("/"); const animationIndex = parseInt(pathParts[2]); const animation = this.world.gltf.animations[animationIndex]; - return animation.createdTimestamp !== undefined; + return [animation.createdTimestamp !== undefined]; }, (_path, _value) => {}, "bool", @@ -631,7 +653,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { const animationIndex = parseInt(pathParts[2]); const animation = this.world.gltf.animations[animationIndex]; animation.computeMinMaxTime(); - return animation.minTime; + return [animation.minTime]; }, (_path, _value) => {}, "float", @@ -644,7 +666,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { const animationIndex = parseInt(pathParts[2]); const animation = this.world.gltf.animations[animationIndex]; animation.computeMinMaxTime(); - return animation.maxTime; + return [animation.maxTime]; }, (_path, _value) => {}, "float", @@ -657,9 +679,9 @@ class SampleViewerDecorator extends interactivity.ADecorator { const animationIndex = parseInt(pathParts[2]); const animation = this.world.gltf.animations[animationIndex]; if (animation.interpolators.length === 0) { - return NaN; + return [NaN]; } - return animation.interpolators[0].prevT; + return [animation.interpolators[0].prevT]; }, (_path, _value) => {}, "float", @@ -672,9 +694,9 @@ class SampleViewerDecorator extends interactivity.ADecorator { const animationIndex = parseInt(pathParts[2]); const animation = this.world.gltf.animations[animationIndex]; if (animation.interpolators.length === 0) { - return NaN; + return [NaN]; } - return animation.interpolators[0].prevRequestedT; + return [animation.interpolators[0].prevRequestedT]; }, (_path, _value) => {}, "float", From 9bf97e770cfaa6c2648ebf6eaa6cbb8aa8724217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 23 Oct 2025 11:24:35 +0200 Subject: [PATCH 53/82] Fix issue with root level extensions --- source/gltf/gltf.js | 3 +++ source/gltf/gltf_object.js | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/source/gltf/gltf.js b/source/gltf/gltf.js index 5b201350..43b5afcf 100644 --- a/source/gltf/gltf.js +++ b/source/gltf/gltf.js @@ -114,12 +114,14 @@ class glTF extends GltfObject { this.skins = objectsFromJsons(json.skins, gltfSkin); if (json.extensions?.KHR_lights_punctual !== undefined) { + this.extensions.KHR_lights_punctual = new GltfObject([]); this.extensions.KHR_lights_punctual.lights = objectsFromJsons( json.extensions.KHR_lights_punctual.lights, gltfLight ); } if (json.extensions?.KHR_materials_variants !== undefined) { + this.extensions.KHR_materials_variants = new GltfObject([]); this.extensions.KHR_materials_variants.variants = objectsFromJsons( json.extensions.KHR_materials_variants?.variants, gltfVariant @@ -129,6 +131,7 @@ class glTF extends GltfObject { ); } if (json.extensions?.KHR_interactivity !== undefined) { + this.extensions.KHR_interactivity = new GltfObject([]); this.extensions.KHR_interactivity.graphs = objectsFromJsons( json.extensions.KHR_interactivity?.graphs, gltfGraph diff --git a/source/gltf/gltf_object.js b/source/gltf/gltf_object.js index 421d9891..e16dfcf6 100644 --- a/source/gltf/gltf_object.js +++ b/source/gltf/gltf_object.js @@ -3,11 +3,14 @@ import { initGlForMembers, fromKeys } from "./utils"; // base class for all gltf objects class GltfObject { - constructor() { + constructor(animatedProperties = undefined) { this.extensions = undefined; this.extras = undefined; this.gltfObjectIndex = undefined; this.animatedPropertyObjects = {}; + if (animatedProperties !== undefined) { + this.constructor.animatedProperties = animatedProperties; + } if (this.constructor.animatedProperties === undefined) { throw new Error("animatedProperties is not defined for " + this.constructor.name); } From 173f55bd6f7e70cb6c89a783cb308b80fbd27803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 23 Oct 2025 11:24:53 +0200 Subject: [PATCH 54/82] Improve test --- tests/interactivityTests.spec.ts | 141 ++++++++++++++++--------------- 1 file changed, 75 insertions(+), 66 deletions(-) diff --git a/tests/interactivityTests.spec.ts b/tests/interactivityTests.spec.ts index 2aa61033..0bd71cd3 100644 --- a/tests/interactivityTests.spec.ts +++ b/tests/interactivityTests.spec.ts @@ -23,73 +23,82 @@ declare global { const directories = fs.readdirSync(`${__dirname}/testAssetDownloads`); for (const dir of directories) { const configFile = `${__dirname}/testAssetDownloads/${dir}/test-index.json`; - if (fs.existsSync(configFile)) { - const fileContents = fs.readFileSync(configFile, "utf-8"); - const testAssets = JSON.parse(fileContents); - for (const asset of testAssets) { - if (asset.path) { - const path = asset.path; - const file = new Uint8Array(fs.readFileSync(path)); - const testName = path.substring(path.lastIndexOf("/testAssetDownloads/") + 19); - test(`Testing asset ${testName}`, async ({ page }) => { - await page.goto(""); - let testDuration : number | undefined = undefined; - let testResult : boolean | undefined = undefined; - const fun = (input: number | boolean) => { - if (typeof input === "number") { - testDuration = input; - } else if (typeof input === "boolean") { - testResult = input; - } - } - await page.exposeFunction("passTestData", fun); - const success = await page.evaluate(async (file) => { - const resourceLoader = window.resourceLoader as ResourceLoader; - const state = window.state as GltfState; - const glTF = await resourceLoader.loadGltf(file.buffer); - state.gltf = glTF; - const defaultScene = state.gltf.scene; - state.sceneIndex = defaultScene === undefined ? 0 : defaultScene; - state.cameraNodeIndex = undefined; - state.graphController.addCustomEventListener("test/onStart", (event) => { - window.passTestData(event.detail.expectedDuration); - window.TEST_TIME = event.detail.expectedDuration; - }); - state.graphController.addCustomEventListener("test/onSuccess", () => { - window.passTestData(true); - window.TEST_RESULT = true; - }); - state.graphController.addCustomEventListener("test/onFailed", () => { - window.passTestData(false); - window.TEST_RESULT = false; - }); - state.animationTimer.start(); - if (state.gltf?.extensions?.KHR_interactivity?.graphs !== undefined) { - state.graphController.initializeGraphs(state); - const graphIndex = state.gltf.extensions.KHR_interactivity.graph ?? 0; - state.graphController.startGraph(graphIndex); - state.graphController.resumeGraph(); - } else { - state.graphController.stopGraphEngine(); - } - return true; - }, file); - expect(success).toBeTruthy(); - await page.waitForFunction(() => { - return window.TEST_TIME !== undefined; - }, {timeout: 2000}); - if (testDuration! > 0) { - console.log("Test duration (s): ", testDuration); - } - await page.waitForFunction(() => { - return window.TEST_RESULT !== undefined; - }, {timeout: testDuration! * 1000 + 1000}); - if (testResult === false) { - console.log(await page.consoleMessages()); + if (!fs.existsSync(configFile)) { + continue; + } + const fileContents = fs.readFileSync(configFile, "utf-8"); + const testAssets = JSON.parse(fileContents); + for (const asset of testAssets) { + if (asset.path === undefined) { + continue; + } + const path = asset.path; + const file = new Uint8Array(fs.readFileSync(path)); + const testName = path.substring(path.lastIndexOf("/testAssetDownloads/") + 19); + test(`Testing asset ${testName}`, async ({ page }) => { + await page.goto(""); + let testDuration : number | undefined = undefined; + let testResult : boolean | undefined = undefined; + const fun = (input: number | boolean) => { + if (typeof input === "number") { + testDuration = input; + } else if (typeof input === "boolean") { + testResult = input; + } + } + await page.exposeFunction("passTestData", fun); + let success = false; + try { + success = await page.evaluate(async (file) => { + const resourceLoader = window.resourceLoader as ResourceLoader; + const state = window.state as GltfState; + const glTF = await resourceLoader.loadGltf(file.buffer); + state.gltf = glTF; + const defaultScene = state.gltf.scene; + state.sceneIndex = defaultScene === undefined ? 0 : defaultScene; + state.cameraNodeIndex = undefined; + state.graphController.addCustomEventListener("test/onStart", (event) => { + window.passTestData(event.detail.expectedDuration); + window.TEST_TIME = event.detail.expectedDuration; + }); + state.graphController.addCustomEventListener("test/onSuccess", () => { + window.passTestData(true); + window.TEST_RESULT = true; + }); + state.graphController.addCustomEventListener("test/onFailed", () => { + window.passTestData(false); + window.TEST_RESULT = false; + }); + state.animationTimer.start(); + if (state.gltf?.extensions?.KHR_interactivity?.graphs !== undefined) { + state.graphController.initializeGraphs(state); + const graphIndex = state.gltf.extensions.KHR_interactivity.graph ?? 0; + state.graphController.startGraph(graphIndex); + state.graphController.resumeGraph(); + } else { + state.graphController.stopGraphEngine(); } - expect(testResult).toBe(true); - }); + return true; + }, file); + } catch (error) { + console.log(await page.consoleMessages()); + throw error; } - } + expect(success).toBeTruthy(); + await page.waitForFunction(() => { + return window.TEST_TIME !== undefined; + }, {timeout: 2000}); + if (testDuration! > 0) { + console.log("Test duration (s): ", testDuration); + } + await page.waitForFunction(() => { + return window.TEST_RESULT !== undefined; + }, {timeout: testDuration! * 1000 + 1000}); + if (testResult === false) { + console.log(await page.consoleMessages()); + } + expect(testResult).toBe(true); + }); + } } From 520febd6c1b8c0302fa0d22a43fc30e41ad9a036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 24 Oct 2025 11:51:01 +0200 Subject: [PATCH 55/82] Calculate near plane hit for perspective camera --- source/Renderer/renderer.js | 32 +++++++++++--------------- source/gltf/camera.js | 46 ++++++++++++++++++++++--------------- 2 files changed, 41 insertions(+), 37 deletions(-) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index 6c4bc365..2a6e69be 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -850,25 +850,19 @@ class gltfRenderer { pixels ); - let rayOrigin = undefined; - if (currentCamera.type === "orthographic") { - const x = pickingX - aspectOffsetX; - const y = this.currentHeight - pickingY - aspectOffsetY; - const orthoX = - -currentCamera.orthographic.xmag + - ((2 * currentCamera.orthographic.xmag) / aspectWidth) * (x + 0.5); - const orthoY = - -currentCamera.orthographic.ymag + - ((2 * currentCamera.orthographic.ymag) / aspectHeight) * (y + 0.5); - rayOrigin = vec3.fromValues(orthoX, orthoY, -currentCamera.orthographic.znear); - vec3.transformMat4( - rayOrigin, - rayOrigin, - currentCamera.getTransformMatrix(state.gltf) - ); - } else { - rayOrigin = currentCamera.getPosition(state.gltf); - } + const x = pickingX - aspectOffsetX; + const y = this.currentHeight - pickingY - aspectOffsetY; + const nearPlane = currentCamera.getNearPlaneForPixel( + x + 0.5, + y + 0.5, + aspectWidth, + aspectHeight + ); + const zNear = currentCamera.perspective?.znear + ? currentCamera.perspective.znear + : currentCamera.orthographic.znear; + let rayOrigin = vec3.fromValues(nearPlane.left, nearPlane.bottom, -zNear); + vec3.transformMat4(rayOrigin, rayOrigin, currentCamera.getTransformMatrix(state.gltf)); let pickingResult = { node: undefined, diff --git a/source/gltf/camera.js b/source/gltf/camera.js index 3848ec48..e08389e6 100644 --- a/source/gltf/camera.js +++ b/source/gltf/camera.js @@ -75,9 +75,8 @@ class gltfCamera extends GltfObject { return projection; } - getProjectionMatrixForPixel(x, y, width, height) { - const projection = mat4.create(); - + getNearPlaneForPixel(x, y, width, height) { + let subRight, subTop, subLeft, subBottom; if (this.type === "perspective") { const aspectRatio = this.perspective.aspectRatio ?? width / height; const top = Math.tan(this.perspective.yfov / 2) * this.perspective.znear; @@ -89,30 +88,41 @@ class gltfCamera extends GltfObject { const subWidth = computedWidth / width; const subHeight = computedHeight / height; - const subLeft = left + x * subWidth; - const subBottom = bottom + y * subHeight; + subLeft = left + x * subWidth; + subBottom = bottom + y * subHeight; + subRight = subLeft + subWidth; + subTop = subBottom + subHeight; + } else if (this.type === "orthographic") { + subLeft = -this.orthographic.xmag + ((2 * this.orthographic.xmag) / width) * x; + subRight = subLeft + (2 * this.orthographic.xmag) / width; + subBottom = -this.orthographic.ymag + ((2 * this.orthographic.ymag) / height) * y; + subTop = subBottom + (2 * this.orthographic.ymag) / height; + } + return { left: subLeft, bottom: subBottom, right: subRight, top: subTop }; + } + getProjectionMatrixForPixel(x, y, width, height) { + const projection = mat4.create(); + + const nearPlane = this.getNearPlaneForPixel(x, y, width, height); + + if (this.type === "perspective") { mat4.frustum( projection, - subLeft, - subLeft + subWidth, - subBottom, - subBottom + subHeight, + nearPlane.left, + nearPlane.right, + nearPlane.bottom, + nearPlane.top, this.perspective.znear, this.perspective.zfar ); } else if (this.type === "orthographic") { - const subLeft = -this.orthographic.xmag + ((2 * this.orthographic.xmag) / width) * x; - const subRight = subLeft + (2 * this.orthographic.xmag) / width; - const subBottom = -this.orthographic.ymag + ((2 * this.orthographic.ymag) / height) * y; - const subTop = subBottom + (2 * this.orthographic.ymag) / height; - mat4.ortho( projection, - subLeft, - subRight, - subBottom, - subTop, + nearPlane.left, + nearPlane.right, + nearPlane.bottom, + nearPlane.top, this.orthographic.znear, this.orthographic.zfar ); From 4b64590be3b88848e7fb4bb655875205a705b8d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 24 Oct 2025 12:03:42 +0200 Subject: [PATCH 56/82] Remove unused frag defines --- source/Renderer/renderer.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index 1224116b..faeb8bd0 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -604,8 +604,6 @@ class gltfRenderer { ); this.webGl.context.viewport(0, 0, 1, 1); - const fragDefines = []; - this.pushFragParameterDefines(fragDefines, state); for (const drawable of this.selectionDrawables) { let renderpassConfiguration = {}; renderpassConfiguration.picking = true; @@ -635,8 +633,6 @@ class gltfRenderer { ); this.webGl.context.viewport(0, 0, 1, 1); - const fragDefines = []; - this.pushFragParameterDefines(fragDefines, state); for (const drawable of this.hoverDrawables) { let renderpassConfiguration = {}; renderpassConfiguration.picking = true; From ecb8ee6bbbd75a786b8a54ee2928a9f18bf86bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 24 Oct 2025 16:26:03 +0200 Subject: [PATCH 57/82] Add formatting to tests --- eslint.config.js | 7 +++--- package-lock.json | 31 ++++++++++++++------------ package.json | 2 +- tests/baseTestConfig.ts | 8 +++---- tests/downloadAssets.spec.ts | 37 +++++++++++++++++++++----------- tests/interactivityTests.spec.ts | 25 ++++++++++++--------- tests/testApp/main.js | 5 ++--- 7 files changed, 67 insertions(+), 48 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index e194d775..8209406d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,9 +23,10 @@ export default [ semi: "warn", "no-extra-semi": "warn", "no-undef": "warn", - "no-unused-vars": ["warn", - { - "argsIgnorePattern": "^_", + "no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_" } ], "no-empty": "warn", diff --git a/package-lock.json b/package-lock.json index fe583b56..261249b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -115,16 +115,19 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } @@ -1804,9 +1807,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", - "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5021,12 +5024,12 @@ } }, "@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "requires": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "dependencies": { "eslint-visitor-keys": { @@ -6237,9 +6240,9 @@ } }, "eslint-visitor-keys": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", - "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true }, "espree": { diff --git a/package.json b/package.json index c1915695..574dc1f8 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "build_docs": "jsdoc2md source/gltf-sample-renderer.js source/GltfView/gltf_view.js source/GltfState/gltf_state.js source/ResourceLoader/resource_loader.js source/gltf/user_camera.js > API.md", "lint": "eslint source/**/*.js", "lint:fix": "eslint --fix source/**/*.js", - "prettier": "npx prettier source/**/*.js --check", + "prettier": "npx prettier source/**/*.js tests/**/*.js tests/**/*.ts --check", "prettier:fix": "npm run prettier -- --write", "format": "npm run prettier:fix && npm run lint:fix" }, diff --git a/tests/baseTestConfig.ts b/tests/baseTestConfig.ts index 230f60e2..b20e7524 100644 --- a/tests/baseTestConfig.ts +++ b/tests/baseTestConfig.ts @@ -1,11 +1,11 @@ -import {test as base} from "@playwright/test"; +import { test as base } from "@playwright/test"; export type TestOptions = { testRepoURL: string; downloadFolder: string; -} +}; export const test = base.extend({ - testRepoURL: ["test", {option: true}], - downloadFolder: ["testAssetDownload", {option: true}], + testRepoURL: ["test", { option: true }], + downloadFolder: ["testAssetDownload", { option: true }] }); diff --git a/tests/downloadAssets.spec.ts b/tests/downloadAssets.spec.ts index 1d92edf9..5547e9b8 100644 --- a/tests/downloadAssets.spec.ts +++ b/tests/downloadAssets.spec.ts @@ -7,8 +7,13 @@ import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); test("download assets", async ({ testRepoURL, downloadFolder }) => { - if (fs.existsSync(`${__dirname}/testAssetDownloads/${downloadFolder}`) && process.env.REDOWNLOAD_ASSETS !== "true") { - console.log(`Assets already downloaded in testAssetDownloads/${downloadFolder}, skipping download. Set REDOWNLOAD_ASSETS=true to force re-download.`); + if ( + fs.existsSync(`${__dirname}/testAssetDownloads/${downloadFolder}`) && + process.env.REDOWNLOAD_ASSETS !== "true" + ) { + console.log( + `Assets already downloaded in testAssetDownloads/${downloadFolder}, skipping download. Set REDOWNLOAD_ASSETS=true to force re-download.` + ); return; } console.log(`Downloading assets to testAssetDownloads/${downloadFolder}`); @@ -19,39 +24,39 @@ test("download assets", async ({ testRepoURL, downloadFolder }) => { for (const asset of data) { if (asset.name === "math/E") { asset.name = "math/e"; - asset.variants = {"glTF-Binary": "e.glb"}; + asset.variants = { "glTF-Binary": "e.glb" }; } if (asset.name === "math/Inf") { asset.name = "math/inf"; - asset.variants = {"glTF-Binary": "inf.glb"}; + asset.variants = { "glTF-Binary": "inf.glb" }; } if (asset.name === "math/isInf") { asset.name = "math/isinf"; - asset.variants = {"glTF-Binary": "isinf.glb"}; + asset.variants = { "glTF-Binary": "isinf.glb" }; } if (asset.name === "math/isNaN") { asset.name = "math/isnan"; - asset.variants = {"glTF-Binary": "isnan.glb"}; + asset.variants = { "glTF-Binary": "isnan.glb" }; } if (asset.name === "math/matMul") { asset.name = "math/matmul"; - asset.variants = {"glTF-Binary": "matmul.glb"}; + asset.variants = { "glTF-Binary": "matmul.glb" }; } if (asset.name === "math/NaN") { asset.name = "math/nan"; - asset.variants = {"glTF-Binary": "nan.glb"}; + asset.variants = { "glTF-Binary": "nan.glb" }; } if (asset.name === "math/Pi") { asset.name = "math/pi"; - asset.variants = {"glTF-Binary": "pi.glb"}; + asset.variants = { "glTF-Binary": "pi.glb" }; } if (asset.name === "math/rotate2D") { asset.name = "math/rotate2d"; - asset.variants = {"glTF-Binary": "rotate2d.glb"}; + asset.variants = { "glTF-Binary": "rotate2d.glb" }; } if (asset.name === "math/rotate3D") { asset.name = "math/rotate3d"; - asset.variants = {"glTF-Binary": "rotate3d.glb"}; + asset.variants = { "glTF-Binary": "rotate3d.glb" }; } const path = `${asset.name}/glTF-Binary/${asset.variants?.["glTF-Binary"]}`; const assetResponse = await fetch(`${parentUrl}/${path}`); @@ -59,9 +64,15 @@ test("download assets", async ({ testRepoURL, downloadFolder }) => { expect(assetResponse.ok).toBeTruthy(); const arrayBuffer = await assetResponse.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - fs.mkdirSync(`${__dirname}/testAssetDownloads/${downloadFolder}/${asset.name}/glTF-Binary`, { recursive: true }); + fs.mkdirSync( + `${__dirname}/testAssetDownloads/${downloadFolder}/${asset.name}/glTF-Binary`, + { recursive: true } + ); fs.writeFileSync(`${__dirname}/testAssetDownloads/${downloadFolder}/${path}`, buffer); asset.path = `${__dirname}/testAssetDownloads/${downloadFolder}/${path}`; } - fs.writeFileSync(`${__dirname}/testAssetDownloads/${downloadFolder}/test-index.json`, JSON.stringify(data)); + fs.writeFileSync( + `${__dirname}/testAssetDownloads/${downloadFolder}/test-index.json`, + JSON.stringify(data) + ); }); diff --git a/tests/interactivityTests.spec.ts b/tests/interactivityTests.spec.ts index 0bd71cd3..153dd4ec 100644 --- a/tests/interactivityTests.spec.ts +++ b/tests/interactivityTests.spec.ts @@ -37,15 +37,15 @@ for (const dir of directories) { const testName = path.substring(path.lastIndexOf("/testAssetDownloads/") + 19); test(`Testing asset ${testName}`, async ({ page }) => { await page.goto(""); - let testDuration : number | undefined = undefined; - let testResult : boolean | undefined = undefined; + let testDuration: number | undefined = undefined; + let testResult: boolean | undefined = undefined; const fun = (input: number | boolean) => { if (typeof input === "number") { testDuration = input; } else if (typeof input === "boolean") { testResult = input; } - } + }; await page.exposeFunction("passTestData", fun); let success = false; try { @@ -85,20 +85,25 @@ for (const dir of directories) { throw error; } expect(success).toBeTruthy(); - await page.waitForFunction(() => { - return window.TEST_TIME !== undefined; - }, {timeout: 2000}); + await page.waitForFunction( + () => { + return window.TEST_TIME !== undefined; + }, + { timeout: 2000 } + ); if (testDuration! > 0) { console.log("Test duration (s): ", testDuration); } - await page.waitForFunction(() => { - return window.TEST_RESULT !== undefined; - }, {timeout: testDuration! * 1000 + 1000}); + await page.waitForFunction( + () => { + return window.TEST_RESULT !== undefined; + }, + { timeout: testDuration! * 1000 + 1000 } + ); if (testResult === false) { console.log(await page.consoleMessages()); } expect(testResult).toBe(true); }); - } } diff --git a/tests/testApp/main.js b/tests/testApp/main.js index d183662f..a9454d6e 100644 --- a/tests/testApp/main.js +++ b/tests/testApp/main.js @@ -1,8 +1,7 @@ -import {GltfView} from "./gltf-viewer.module.js"; - +import { GltfView } from "./gltf-viewer.module.js"; const canvas = document.getElementById("canvas"); -const context = canvas.getContext("webgl2", {antialias: true}); +const context = canvas.getContext("webgl2", { antialias: true }); const view = new GltfView(context); const resourceLoader = view.createResourceLoader(); const state = view.createState(); From 62bb1a519c153014a0fc7c36948692c02c8c1882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 24 Oct 2025 17:49:19 +0200 Subject: [PATCH 58/82] Update readme --- README.md | 11 +++++++---- source/gltf/gltf.js | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c0fe7215..418360db 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,13 @@ Formerly hosted together with the example frontend at the [glTF Sample Viewer](h - [x] glTF 2.0 - [KHR_accessor_float64](https://github.com/KhronosGroup/glTF/pull/2397) - - [x] Animations - - [x] KHR_animation_pointer - - [ ] Mesh Attributes not supported since WebGL2 only supports 32 bit - - [ ] Skins not supported since WebGL2 only supports 32 bit + - [x] Animations + - [x] KHR_animation_pointer + - [ ] Mesh Attributes not supported since WebGL2 only supports 32 bit + - [ ] Skins not supported since WebGL2 only supports 32 bit - [x] [KHR_animation_pointer](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_animation_pointer) - [x] [KHR_draco_mesh_compression](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_draco_mesh_compression) +- [x] [KHR_interactivity](https://github.com/KhronosGroup/glTF/pull/2293) - [x] [KHR_lights_punctual](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_lights_punctual) - [x] [KHR_materials_anisotropy](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_anisotropy) - [x] [KHR_materials_clearcoat](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_clearcoat) @@ -54,6 +55,8 @@ Formerly hosted together with the example frontend at the [glTF Sample Viewer](h - [x] For dense volumes using KHR_materials_diffuse_transmission - [ ] For sparse volumes using KHR_materials_transmission - [x] [KHR_mesh_quantization](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_mesh_quantization) +- [x] [KHR_node_hoverability](https://github.com/KhronosGroup/glTF/pull/2426) +- [x] [KHR_node_selectability](https://github.com/KhronosGroup/glTF/pull/2422) - [x] [KHR_node_visibility](https://github.com/KhronosGroup/glTF/pull/2410) - [x] [KHR_texture_basisu](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_texture_basisu) - [x] [KHR_texture_transform](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_texture_transform) diff --git a/source/gltf/gltf.js b/source/gltf/gltf.js index 7bb3c3ad..dee7008b 100644 --- a/source/gltf/gltf.js +++ b/source/gltf/gltf.js @@ -41,6 +41,8 @@ const allowedExtensions = [ "KHR_materials_volume", "KHR_materials_volume_scatter", "KHR_mesh_quantization", + "KHR_node_hoverability", + "KHR_node_selectability", "KHR_node_visibility", "KHR_texture_basisu", "KHR_texture_transform", From 106272438d82c2cdadfba624f95809c8b9fb36f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 24 Oct 2025 17:49:59 +0200 Subject: [PATCH 59/82] Improved variable naming --- source/GltfState/gltf_state.js | 12 +++++++----- source/Renderer/renderer.js | 15 +++++++++++---- source/gltf/interactivity.js | 10 +++++----- tests/interactivityTests.spec.ts | 2 +- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/source/GltfState/gltf_state.js b/source/GltfState/gltf_state.js index b9064442..e39e4a76 100644 --- a/source/GltfState/gltf_state.js +++ b/source/GltfState/gltf_state.js @@ -39,14 +39,16 @@ class GltfState { this.selectionCallback = undefined; this.hoverCallback = undefined; - /** If the renderer should compute selection/hover in the next frame */ + /** If the renderer should compute selection in the next frame. Is automatically reset after the frame is rendered */ this.triggerSelection = false; - /** enableHover is also set internally by KHR_interactivity */ + /** If the renderer should compute hovering in the next frame. */ this.enableHover = false; - /* screen position of the picking ray */ - this.pickingX = 0; - this.pickingY = 0; + /* Array of screen positions for selection. Currently only one is supported. */ + this.selectionPositions = [{ x: undefined, y: undefined }]; + + /* Array of screen positions for hovering. Currently only one is supported. */ + this.hoverPositions = [{ x: undefined, y: undefined }]; /** parameters used to configure the rendering */ this.renderingParameters = { diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index faeb8bd0..f8e0c408 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -587,8 +587,8 @@ class gltfRenderer { let pickingProjection = undefined; let pickingViewProjection = mat4.create(); - const pickingX = state.pickingX; - const pickingY = state.pickingY; + let pickingX = state.selectionPositions[0].x; + let pickingY = state.selectionPositions[0].y; if (state.triggerSelection && pickingX !== undefined && pickingY !== undefined) { pickingProjection = currentCamera.getProjectionMatrixForPixel( @@ -617,6 +617,9 @@ class gltfRenderer { } } + pickingX = state.hoverPositions[0].x; + pickingY = state.hoverPositions[0].y; + if (state.enableHover && pickingX !== undefined && pickingY !== undefined) { if (pickingProjection === undefined) { pickingProjection = currentCamera.getProjectionMatrixForPixel( @@ -846,6 +849,8 @@ class gltfRenderer { pixels ); + pickingX = state.selectionPositions[0].x; + pickingY = state.selectionPositions[0].y; const x = pickingX - aspectOffsetX; const y = this.currentHeight - pickingY - aspectOffsetY; const nearPlane = currentCamera.getNearPlaneForPixel( @@ -863,7 +868,8 @@ class gltfRenderer { let pickingResult = { node: undefined, position: undefined, - rayOrigin: rayOrigin + rayOrigin: rayOrigin, + controller: 0 }; let found = false; @@ -939,7 +945,8 @@ class gltfRenderer { ); let pickingResult = { - node: undefined + node: undefined, + controller: 0 }; for (const node of state.gltf.nodes) { if (node.pickingColor === pixels[0]) { diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 71e6fe1e..75d2f1fb 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -64,11 +64,11 @@ class GraphController { } /** - * Starts playing the specified graph. Resets the engine. + * Loads the specified graph. Resets the engine. Starts playing if this.playing is true. * @param {number} graphIndex * @return {Array} An array of custom events defined in the graph. */ - startGraph(graphIndex) { + loadGraph(graphIndex) { this.decorator.resetGraph(); try { this.customEvents = this.decorator.loadGraph(graphIndex); @@ -134,7 +134,7 @@ class GraphController { if (this.graphIndex === undefined) { return; } - this.startGraph(this.graphIndex); + this.loadGraph(this.graphIndex); } /** @@ -222,7 +222,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { if (pickingResult.node) { this.select( pickingResult.node?.gltfObjectIndex, - 0, + pickingResult.controller, pickingResult.position, pickingResult.rayOrigin ); @@ -230,7 +230,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { } receiveHover(pickingResult) { - this.hoverOn(pickingResult.node?.gltfObjectIndex, 0); + this.hoverOn(pickingResult.node?.gltfObjectIndex, pickingResult.controller); } getParentNodeIndex(nodeIndex) { diff --git a/tests/interactivityTests.spec.ts b/tests/interactivityTests.spec.ts index 153dd4ec..ff315fad 100644 --- a/tests/interactivityTests.spec.ts +++ b/tests/interactivityTests.spec.ts @@ -73,7 +73,7 @@ for (const dir of directories) { if (state.gltf?.extensions?.KHR_interactivity?.graphs !== undefined) { state.graphController.initializeGraphs(state); const graphIndex = state.gltf.extensions.KHR_interactivity.graph ?? 0; - state.graphController.startGraph(graphIndex); + state.graphController.loadGraph(graphIndex); state.graphController.resumeGraph(); } else { state.graphController.stopGraphEngine(); From 744cbb87328ba32def1dbeb74a9aa1c5fe0cc858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 24 Oct 2025 18:06:18 +0200 Subject: [PATCH 60/82] Handle interactivity hover internal --- source/Renderer/renderer.js | 8 ++++++-- source/gltf/interactivity.js | 26 ++++++++++++++++---------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index f8e0c408..37f39e62 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -620,7 +620,11 @@ class gltfRenderer { pickingX = state.hoverPositions[0].x; pickingY = state.hoverPositions[0].y; - if (state.enableHover && pickingX !== undefined && pickingY !== undefined) { + const needsHover = state.graphController.needsHover(); + const doHover = + (state.enableHover || needsHover) && pickingX !== undefined && pickingY !== undefined; + + if (doHover) { if (pickingProjection === undefined) { pickingProjection = currentCamera.getProjectionMatrixForPixel( pickingX - aspectOffsetX, @@ -926,7 +930,7 @@ class gltfRenderer { } } - if (state.enableHover) { + if (doHover) { this.webGl.context.bindFramebuffer( this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 75d2f1fb..64443a1d 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -34,6 +34,22 @@ class GraphController { this.decorator = new SampleViewerDecorator(this.engine, debug); } + needsHover() { + if (this.graphIndex === undefined || !this.playing) { + return false; + } + if ( + this.state?.renderingParameters?.enabledExtensions?.KHR_interactivity !== true || + this.state?.renderingParameters?.enabledExtensions?.KHR_node_hoverability !== true + ) { + return false; + } + return ( + this.state?.gltf?.extensions?.KHR_interactivity?.graphs[this.graphIndex] + ?.hasHoverEvent === true + ); + } + receiveSelection(pickingResult) { if (this.graphIndex !== undefined) { this.decorator.receiveSelection(pickingResult); @@ -74,12 +90,7 @@ class GraphController { this.customEvents = this.decorator.loadGraph(graphIndex); this.graphIndex = graphIndex; if (this.playing) { - this.state.enableHover = - this.state.gltf?.extensions?.KHR_interactivity?.graphs[this.graphIndex] - ?.hasHoverEvent ?? false; this.decorator.playEventQueue(); - } else { - this.state.enableHover = false; } } catch (error) { console.error("Error loading graph:", error); @@ -98,7 +109,6 @@ class GraphController { this.playing = false; this.decorator.pauseEventQueue(); this.decorator.resetGraph(); - this.state.enableHover = false; } /** @@ -110,7 +120,6 @@ class GraphController { } this.decorator.pauseEventQueue(); this.playing = false; - this.state.enableHover = false; } /** @@ -120,9 +129,6 @@ class GraphController { if (this.graphIndex === undefined || this.playing) { return; } - this.state.enableHover = - this.state.gltf?.extensions?.KHR_interactivity?.graphs[this.graphIndex] - ?.hasHoverEvent ?? false; this.decorator.playEventQueue(); this.playing = true; } From b469197c7e62264c8866381e6be9d45fbdd8b8a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Mon, 27 Oct 2025 15:58:12 +0100 Subject: [PATCH 61/82] Add and improve documentation --- API.md | 303 +++++++++ README.md | 14 +- package-lock.json | 1131 ++++++++------------------------ package.json | 6 +- source/GltfState/gltf_state.js | 27 +- source/Renderer/renderer.js | 2 +- 6 files changed, 608 insertions(+), 875 deletions(-) diff --git a/API.md b/API.md index cbd2bf65..0e8703f4 100644 --- a/API.md +++ b/API.md @@ -13,6 +13,9 @@ that are then used to display the loaded data with GltfView

UserCamera
+
GraphController
+

A controller for managing KHR_interactivity graphs in a glTF scene.

+
@@ -112,9 +115,31 @@ GltfState containing a state for visualization in GltfView * [.animationIndices](#GltfState+animationIndices) * [.animationTimer](#GltfState+animationTimer) * [.variant](#GltfState+variant) + * [.graphController](#GltfState+graphController) + * [.selectionCallback](#GltfState+selectionCallback) + * [.hoverCallback](#GltfState+hoverCallback) + * [.triggerSelection](#GltfState+triggerSelection) + * [.enableHover](#GltfState+enableHover) * [.renderingParameters](#GltfState+renderingParameters) * [.morphing](#GltfState+renderingParameters.morphing) * [.skinning](#GltfState+renderingParameters.skinning) + * [.enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + * [.KHR_materials_clearcoat](#GltfState+renderingParameters.enabledExtensions.KHR_materials_clearcoat) + * [.KHR_materials_sheen](#GltfState+renderingParameters.enabledExtensions.KHR_materials_sheen) + * [.KHR_materials_transmission](#GltfState+renderingParameters.enabledExtensions.KHR_materials_transmission) + * [.KHR_materials_volume](#GltfState+renderingParameters.enabledExtensions.KHR_materials_volume) + * [.KHR_materials_volume_scatter](#GltfState+renderingParameters.enabledExtensions.KHR_materials_volume_scatter) + * [.KHR_materials_ior](#GltfState+renderingParameters.enabledExtensions.KHR_materials_ior) + * [.KHR_materials_specular](#GltfState+renderingParameters.enabledExtensions.KHR_materials_specular) + * [.KHR_materials_iridescence](#GltfState+renderingParameters.enabledExtensions.KHR_materials_iridescence) + * [.KHR_materials_diffuse_transmission](#GltfState+renderingParameters.enabledExtensions.KHR_materials_diffuse_transmission) + * [.KHR_materials_anisotropy](#GltfState+renderingParameters.enabledExtensions.KHR_materials_anisotropy) + * [.KHR_materials_dispersion](#GltfState+renderingParameters.enabledExtensions.KHR_materials_dispersion) + * [.KHR_materials_emissive_strength](#GltfState+renderingParameters.enabledExtensions.KHR_materials_emissive_strength) + * [.KHR_interactivity](#GltfState+renderingParameters.enabledExtensions.KHR_interactivity) + * [.KHR_node_hoverability](#GltfState+renderingParameters.enabledExtensions.KHR_node_hoverability) + * [.KHR_node_selectability](#GltfState+renderingParameters.enabledExtensions.KHR_node_selectability) + * [.KHR_node_visibility](#GltfState+renderingParameters.enabledExtensions.KHR_node_visibility) * [.clearColor](#GltfState+renderingParameters.clearColor) * [.exposure](#GltfState+renderingParameters.exposure) * [.usePunctual](#GltfState+renderingParameters.usePunctual) @@ -143,6 +168,7 @@ GltfState containing a state for visualization in GltfView * [.GEOMETRYNORMAL](#GltfState.DebugOutput.generic.GEOMETRYNORMAL) * [.TANGENT](#GltfState.DebugOutput.generic.TANGENT) * [.BITANGENT](#GltfState.DebugOutput.generic.BITANGENT) + * [.TANGENTW](#GltfState.DebugOutput.generic.TANGENTW) * [.WORLDSPACENORMAL](#GltfState.DebugOutput.generic.WORLDSPACENORMAL) * [.ALPHA](#GltfState.DebugOutput.generic.ALPHA) * [.OCCLUSION](#GltfState.DebugOutput.generic.OCCLUSION) @@ -233,6 +259,40 @@ animation timer allows to control the animation time ### gltfState.variant KHR_materials_variants +**Kind**: instance property of [GltfState](#GltfState) + + +### gltfState.graphController +the graph controller allows selecting and playing graphs from KHR_interactivity + +**Kind**: instance property of [GltfState](#GltfState) + + +### gltfState.selectionCallback +callback for selection: (selectionInfo : { +node, +position, +rayOrigin, +controller }) => {} + +**Kind**: instance property of [GltfState](#GltfState) + + +### gltfState.hoverCallback +callback for hovering: (hoverInfo : { node, controller }) => {} + +**Kind**: instance property of [GltfState](#GltfState) + + +### gltfState.triggerSelection +If the renderer should compute selection in the next frame. Is automatically reset after the frame is rendered + +**Kind**: instance property of [GltfState](#GltfState) + + +### gltfState.enableHover +If the renderer should compute hovering in the next frame. + **Kind**: instance property of [GltfState](#GltfState) @@ -244,6 +304,23 @@ parameters used to configure the rendering * [.renderingParameters](#GltfState+renderingParameters) * [.morphing](#GltfState+renderingParameters.morphing) * [.skinning](#GltfState+renderingParameters.skinning) + * [.enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + * [.KHR_materials_clearcoat](#GltfState+renderingParameters.enabledExtensions.KHR_materials_clearcoat) + * [.KHR_materials_sheen](#GltfState+renderingParameters.enabledExtensions.KHR_materials_sheen) + * [.KHR_materials_transmission](#GltfState+renderingParameters.enabledExtensions.KHR_materials_transmission) + * [.KHR_materials_volume](#GltfState+renderingParameters.enabledExtensions.KHR_materials_volume) + * [.KHR_materials_volume_scatter](#GltfState+renderingParameters.enabledExtensions.KHR_materials_volume_scatter) + * [.KHR_materials_ior](#GltfState+renderingParameters.enabledExtensions.KHR_materials_ior) + * [.KHR_materials_specular](#GltfState+renderingParameters.enabledExtensions.KHR_materials_specular) + * [.KHR_materials_iridescence](#GltfState+renderingParameters.enabledExtensions.KHR_materials_iridescence) + * [.KHR_materials_diffuse_transmission](#GltfState+renderingParameters.enabledExtensions.KHR_materials_diffuse_transmission) + * [.KHR_materials_anisotropy](#GltfState+renderingParameters.enabledExtensions.KHR_materials_anisotropy) + * [.KHR_materials_dispersion](#GltfState+renderingParameters.enabledExtensions.KHR_materials_dispersion) + * [.KHR_materials_emissive_strength](#GltfState+renderingParameters.enabledExtensions.KHR_materials_emissive_strength) + * [.KHR_interactivity](#GltfState+renderingParameters.enabledExtensions.KHR_interactivity) + * [.KHR_node_hoverability](#GltfState+renderingParameters.enabledExtensions.KHR_node_hoverability) + * [.KHR_node_selectability](#GltfState+renderingParameters.enabledExtensions.KHR_node_selectability) + * [.KHR_node_visibility](#GltfState+renderingParameters.enabledExtensions.KHR_node_visibility) * [.clearColor](#GltfState+renderingParameters.clearColor) * [.exposure](#GltfState+renderingParameters.exposure) * [.usePunctual](#GltfState+renderingParameters.usePunctual) @@ -269,6 +346,127 @@ morphing between vertices skin / skeleton **Kind**: static property of [renderingParameters](#GltfState+renderingParameters) + + +#### renderingParameters.enabledExtensions +enabled extensions + +**Kind**: static property of [renderingParameters](#GltfState+renderingParameters) + +* [.enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + * [.KHR_materials_clearcoat](#GltfState+renderingParameters.enabledExtensions.KHR_materials_clearcoat) + * [.KHR_materials_sheen](#GltfState+renderingParameters.enabledExtensions.KHR_materials_sheen) + * [.KHR_materials_transmission](#GltfState+renderingParameters.enabledExtensions.KHR_materials_transmission) + * [.KHR_materials_volume](#GltfState+renderingParameters.enabledExtensions.KHR_materials_volume) + * [.KHR_materials_volume_scatter](#GltfState+renderingParameters.enabledExtensions.KHR_materials_volume_scatter) + * [.KHR_materials_ior](#GltfState+renderingParameters.enabledExtensions.KHR_materials_ior) + * [.KHR_materials_specular](#GltfState+renderingParameters.enabledExtensions.KHR_materials_specular) + * [.KHR_materials_iridescence](#GltfState+renderingParameters.enabledExtensions.KHR_materials_iridescence) + * [.KHR_materials_diffuse_transmission](#GltfState+renderingParameters.enabledExtensions.KHR_materials_diffuse_transmission) + * [.KHR_materials_anisotropy](#GltfState+renderingParameters.enabledExtensions.KHR_materials_anisotropy) + * [.KHR_materials_dispersion](#GltfState+renderingParameters.enabledExtensions.KHR_materials_dispersion) + * [.KHR_materials_emissive_strength](#GltfState+renderingParameters.enabledExtensions.KHR_materials_emissive_strength) + * [.KHR_interactivity](#GltfState+renderingParameters.enabledExtensions.KHR_interactivity) + * [.KHR_node_hoverability](#GltfState+renderingParameters.enabledExtensions.KHR_node_hoverability) + * [.KHR_node_selectability](#GltfState+renderingParameters.enabledExtensions.KHR_node_selectability) + * [.KHR_node_visibility](#GltfState+renderingParameters.enabledExtensions.KHR_node_visibility) + + + +##### enabledExtensions.KHR\_materials\_clearcoat +KHR_materials_clearcoat adds a clear coat layer on top of the glTF base material + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_materials\_sheen +KHR_materials_sheen adds a sheen layer on top of the glTF base material + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_materials\_transmission +KHR_materials_transmission adds physical-based transparency + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_materials\_volume +KHR_materials_volume adds support for volumetric materials. Used together with KHR_materials_transmission and KHR_materials_diffuse_transmission + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_materials\_volume\_scatter +KHR_materials_volume_scatter allows the simulation of scattering light inside a volume. Used together with KHR_materials_volume + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_materials\_ior +KHR_materials_ior makes the index of refraction configurable + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_materials\_specular +KHR_materials_specular allows configuring specular color (f0 color) and amount of specular reflection + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_materials\_iridescence +KHR_materials_iridescence adds a thin-film iridescence effect + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_materials\_diffuse\_transmission +KHR_materials_diffuse_transmission allows light to pass diffusely through the material + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_materials\_anisotropy +KHR_materials_anisotropy defines microfacet grooves in the surface, stretching the specular reflection on the surface + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_materials\_dispersion +KHR_materials_dispersion defines configuring the strength of the angular separation of colors (chromatic abberation) + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_materials\_emissive\_strength +KHR_materials_emissive_strength enables emissive factors larger than 1.0 + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_interactivity +KHR_interactivity enables execution of a behavior graph + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_node\_hoverability +KHR_node_hoverability enables hovering over nodes + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_node\_selectability +KHR_node_selectability enables selecting nodes + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) + + +##### enabledExtensions.KHR\_node\_visibility +KHR_node_visibility enables controlling the visibility of nodes + +**Kind**: static property of [enabledExtensions](#GltfState+renderingParameters.enabledExtensions) #### renderingParameters.clearColor @@ -408,6 +606,7 @@ such as "NORMAL" * [.GEOMETRYNORMAL](#GltfState.DebugOutput.generic.GEOMETRYNORMAL) * [.TANGENT](#GltfState.DebugOutput.generic.TANGENT) * [.BITANGENT](#GltfState.DebugOutput.generic.BITANGENT) + * [.TANGENTW](#GltfState.DebugOutput.generic.TANGENTW) * [.WORLDSPACENORMAL](#GltfState.DebugOutput.generic.WORLDSPACENORMAL) * [.ALPHA](#GltfState.DebugOutput.generic.ALPHA) * [.OCCLUSION](#GltfState.DebugOutput.generic.OCCLUSION) @@ -459,6 +658,7 @@ generic debug outputs * [.GEOMETRYNORMAL](#GltfState.DebugOutput.generic.GEOMETRYNORMAL) * [.TANGENT](#GltfState.DebugOutput.generic.TANGENT) * [.BITANGENT](#GltfState.DebugOutput.generic.BITANGENT) + * [.TANGENTW](#GltfState.DebugOutput.generic.TANGENTW) * [.WORLDSPACENORMAL](#GltfState.DebugOutput.generic.WORLDSPACENORMAL) * [.ALPHA](#GltfState.DebugOutput.generic.ALPHA) * [.OCCLUSION](#GltfState.DebugOutput.generic.OCCLUSION) @@ -499,6 +699,12 @@ output the tangent from the TBN ##### generic.BITANGENT output the bitangent from the TBN +**Kind**: static property of [generic](#GltfState.DebugOutput.generic) + + +##### generic.TANGENTW +output the tangent w from the TBN (black corresponds to -1; white to 1 + **Kind**: static property of [generic](#GltfState.DebugOutput.generic) @@ -992,3 +1198,100 @@ Fit view to updated canvas size without changing rotation if distance is incorre | gltf | Gltf | | sceneIndex | number | + + +## GraphController +A controller for managing KHR_interactivity graphs in a glTF scene. + +**Kind**: global class + +* [GraphController](#GraphController) + * [.initializeGraphs(state)](#GraphController+initializeGraphs) + * [.loadGraph(graphIndex)](#GraphController+loadGraph) ⇒ Array + * [.stopGraphEngine()](#GraphController+stopGraphEngine) + * [.pauseGraph()](#GraphController+pauseGraph) + * [.resumeGraph()](#GraphController+resumeGraph) + * [.resetGraph()](#GraphController+resetGraph) + * [.dispatchEvent(eventName, data)](#GraphController+dispatchEvent) + * [.addCustomEventListener(eventName, callback)](#GraphController+addCustomEventListener) + * [.clearCustomEventListeners()](#GraphController+clearCustomEventListeners) + + + +### graphController.initializeGraphs(state) +Initialize the graph controller with the given state. +This needs to be called every time a glTF assets is loaded. + +**Kind**: instance method of [GraphController](#GraphController) + +| Param | Type | Description | +| --- | --- | --- | +| state | [GltfState](#GltfState) | The state of the application. | + + + +### graphController.loadGraph(graphIndex) ⇒ Array +Loads the specified graph. Resets the engine. Starts playing if this.playing is true. + +**Kind**: instance method of [GraphController](#GraphController) +**Returns**: Array - An array of custom events defined in the graph. + +| Param | Type | +| --- | --- | +| graphIndex | number | + + + +### graphController.stopGraphEngine() +Stops the graph engine. + +**Kind**: instance method of [GraphController](#GraphController) + + +### graphController.pauseGraph() +Pauses the currently playing graph. + +**Kind**: instance method of [GraphController](#GraphController) + + +### graphController.resumeGraph() +Resumes the currently paused graph. + +**Kind**: instance method of [GraphController](#GraphController) + + +### graphController.resetGraph() +Resets the current graph. + +**Kind**: instance method of [GraphController](#GraphController) + + +### graphController.dispatchEvent(eventName, data) +Dispatches an event to the behavior engine. + +**Kind**: instance method of [GraphController](#GraphController) + +| Param | Type | +| --- | --- | +| eventName | string | +| data | \* | + + + +### graphController.addCustomEventListener(eventName, callback) +Adds a custom event listener to the decorator. +Khronos test assets use test/onStart, test/onFail and test/onSuccess. + +**Kind**: instance method of [GraphController](#GraphController) + +| Param | Type | +| --- | --- | +| eventName | string | +| callback | function | + + + +### graphController.clearCustomEventListeners() +Clears all custom event listeners from the decorator. + +**Kind**: instance method of [GraphController](#GraphController) diff --git a/README.md b/README.md index 418360db..f1db5293 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Try out the [glTF Sample Viewer](https://github.khronos.org/glTF-Sample-Viewer-R - [API](#api) - [GltfView](#gltfview) - [GltfState](#gltfstate) + - [GraphController](#graphcontroller) - [ResourceLoader](#resourceloader) - [Render Fidelity Tools](#render-fidelity-tools) - [Formatting](#formatting) @@ -24,6 +25,7 @@ Try out the [glTF Sample Viewer](https://github.khronos.org/glTF-Sample-Viewer-R Developed and refactored by [UX3D](https://www.ux3d.io/). Supported by the [Khronos Group](https://www.khronos.org/) and by [Google](https://www.google.com/) for the glTF Draco mesh compression import. Formerly hosted together with the example frontend at the [glTF Sample Viewer](https://github.com/KhronosGroup/glTF-Sample-Viewer) repository. Original code based on the concluded [glTF-WebGL-PBR](https://github.com/KhronosGroup/glTF-Sample-Viewer/tree/glTF-WebGL-PBR) project. Previously supported by [Facebook](https://www.facebook.com/) for animations, skinning and morphing. +For KHR_interactivity, the behavior engine of the [glTF-InteractivityGraph-AuthoringTool](https://github.com/KhronosGroup/glTF-InteractivityGraph-AuthoringTool) is used. ## Features @@ -92,7 +94,7 @@ window.requestAnimationFrame(update); ### GltfState -The GltfState encapsulates the state of the content of a GltfView. *As currently some WebGL resources are stored directly in the Gltf objects, the state cannot be shared between views.* +The GltfState encapsulates the state of the content of a GltfView. _As currently some WebGL resources are stored directly in the Gltf objects, the state cannot be shared between views._ ```js const state = view.createState(); @@ -103,6 +105,16 @@ state.animationTimer.start(); The state is passed to the `view.renderFrame` function to specify the content that should be rendered. +#### GraphController + +The GltfState contains an instance of the GraphController which can be used to load and execute `KHR_interactivity` graphs. One can also send custom events to the graph or subscribe to custom event via callbacks. + +In the GltfState you can define an array of selection and hover points. Each element of the array represents one controller. If `triggerSelection` is set to `true`, the render will return the picking result of the clicked position via `selectionCallback`. The interactivity engine will be notified as well, if `KHR_node_selectability` is used in the current glTF. + +If `enableHover` is set to `true`, the render will return the picking result of the hovered position via `hoverCallback`. The interactivity engine receives hover results independent of `enableHover` based on the `hoverPositions` array. `enableHover` enables the use of custom hover handling independent of `KHR_interactivity` and is set to `false` by default. + +To make sure that `KHR_interactivity` always behaves correctly together with `KHR_node_selectability` and `KHR_node_hoverability`, update the values in the `hoverPositions` and `selectionPositions` arrays and trigger selections via `triggerSelection`. Currently, only one controller is supported. All entries except the first one of each array are ignored. Arrays are used to enable multiple controllers in the future without breaking the API. + ### ResourceLoader The ResourceLoader can be used to load external resources and make them available to the renderer. diff --git a/package-lock.json b/package-lock.json index 261249b4..17c47c47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "concurrently": "^8.2.2", "eslint": "^9.5.0", "eslint-config-prettier": "^10.1.8", - "jsdoc-to-markdown": "^8.0.1", + "jsdoc-to-markdown": "^9.1.3", "prettier": "3.6.2", "rollup": "^4.23.0", "rollup-plugin-copy": "^3.5.0", @@ -47,30 +47,30 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", - "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", - "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dev": true, "dependencies": { - "@babel/types": "^7.25.6" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -89,14 +89,13 @@ } }, "node_modules/@babel/types": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", - "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.24.8", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -331,9 +330,9 @@ "dev": true }, "node_modules/@jsdoc/salty": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.8.tgz", - "integrity": "sha512-5e+SFVavj1ORKlKaKr2BmTOekmXbelU7dC0cDkQLqag7xfuTPuGMUFx7KWJuv4bYZrTsoL2Z18VVCOKYxzoHcg==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.9.tgz", + "integrity": "sha512-yYxMVH7Dqw6nO0d5NIV8OQWnitU8k6vXH8NtgqAfIa/IUqRMxRv/NUJJ08VEKbAakwxlgBl5PJdrU0dMPStsnw==", "dev": true, "dependencies": { "lodash": "^4.17.21" @@ -856,27 +855,6 @@ "node": ">=8" } }, - "node_modules/ansi-escape-sequences": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-escape-sequences/-/ansi-escape-sequences-4.1.0.tgz", - "integrity": "sha512-dzW9kHxH011uBsidTXd14JXgzye/YLb2LzeKZ4bsgl/Knwx8AtbSFkkGxagdNOoh0DlqHCmfiEjWKBaqjOanVw==", - "dev": true, - "dependencies": { - "array-back": "^3.0.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/ansi-escape-sequences/node_modules/array-back": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", - "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1054,26 +1032,23 @@ } }, "node_modules/cache-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/cache-point/-/cache-point-2.0.0.tgz", - "integrity": "sha512-4gkeHlFpSKgm3vm2gJN5sPqfmijYRFYCQ6tv5cLw0xVmT6r1z1vd4FNnpuOREco3cBs1G709sZ72LdgddKvL5w==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cache-point/-/cache-point-3.0.1.tgz", + "integrity": "sha512-itTIMLEKbh6Dw5DruXbxAgcyLnh/oPGVLBfTPqBOftASxHe8bAeXy7JkO4F0LvHqht7XqP5O/09h5UcHS2w0FA==", "dev": true, "dependencies": { - "array-back": "^4.0.1", - "fs-then-native": "^2.0.0", - "mkdirp2": "^1.0.4" + "array-back": "^6.2.2" }, "engines": { - "node": ">=8" - } - }, - "node_modules/cache-point/node_modules/array-back": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.2.tgz", - "integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==", - "dev": true, - "engines": { - "node": ">=8" + "node": ">=12.17" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } } }, "node_modules/callsites": { @@ -1232,19 +1207,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/collect-all": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/collect-all/-/collect-all-1.0.4.tgz", - "integrity": "sha512-RKZhRwJtJEP5FWul+gkSMEnaK6H3AGPTTWOiRimCcs+rc/OmQE3Yhy1Q7A7KsdkG3ZXVdZq68Y6ONSdvkeEcKA==", - "dev": true, - "dependencies": { - "stream-connect": "^1.0.2", - "stream-via": "^1.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1270,91 +1232,41 @@ "dev": true }, "node_modules/command-line-args": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", - "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.1.tgz", + "integrity": "sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==", "dev": true, "dependencies": { - "array-back": "^3.1.0", - "find-replace": "^3.0.0", + "array-back": "^6.2.2", + "find-replace": "^5.0.2", "lodash.camelcase": "^4.3.0", - "typical": "^4.0.0" + "typical": "^7.2.0" }, "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/command-line-args/node_modules/array-back": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", - "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/command-line-args/node_modules/typical": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", - "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/command-line-tool": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/command-line-tool/-/command-line-tool-0.8.0.tgz", - "integrity": "sha512-Xw18HVx/QzQV3Sc5k1vy3kgtOeGmsKIqwtFFoyjI4bbcpSgnw2CWVULvtakyw4s6fhyAdI6soQQhXc2OzJy62g==", - "dev": true, - "dependencies": { - "ansi-escape-sequences": "^4.0.0", - "array-back": "^2.0.0", - "command-line-args": "^5.0.0", - "command-line-usage": "^4.1.0", - "typical": "^2.6.1" + "node": ">=12.20" }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/command-line-tool/node_modules/array-back": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-2.0.0.tgz", - "integrity": "sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==", - "dev": true, - "dependencies": { - "typical": "^2.6.1" + "peerDependencies": { + "@75lb/nature": "latest" }, - "engines": { - "node": ">=4" + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } } }, "node_modules/command-line-usage": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-4.1.0.tgz", - "integrity": "sha512-MxS8Ad995KpdAC0Jopo/ovGIroV/m0KHwzKfXxKag6FHOkGsH8/lv5yjgablcRxCJJC0oJeUMuO/gmaq+Wq46g==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", + "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", "dev": true, "dependencies": { - "ansi-escape-sequences": "^4.0.0", - "array-back": "^2.0.0", - "table-layout": "^0.4.2", - "typical": "^2.6.1" + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.0", + "typical": "^7.1.1" }, "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/command-line-usage/node_modules/array-back": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-2.0.0.tgz", - "integrity": "sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==", - "dev": true, - "dependencies": { - "typical": "^2.6.1" - }, - "engines": { - "node": ">=4" + "node": ">=12.20.0" } }, "node_modules/commander": { @@ -1370,12 +1282,12 @@ "dev": true }, "node_modules/common-sequence": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/common-sequence/-/common-sequence-2.0.2.tgz", - "integrity": "sha512-jAg09gkdkrDO9EWTdXfv80WWH3yeZl5oT69fGfedBNS9pXUKYInVJ1bJ+/ht2+Moeei48TmSbQDYMc8EOx9G0g==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-sequence/-/common-sequence-3.0.0.tgz", + "integrity": "sha512-g/CgSYk93y+a1IKm50tKl7kaT/OjjTYVQlEbUlt/49ZLV1mcKpUU7iyDiqTAeLdb4QDtQfq3ako8y8v//fzrWQ==", "dev": true, "engines": { - "node": ">=8" + "node": ">=12.17" } }, "node_modules/commondir": { @@ -1533,6 +1445,15 @@ "node": ">= 8" } }, + "node_modules/current-module-paths": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/current-module-paths/-/current-module-paths-1.1.2.tgz", + "integrity": "sha512-H4s4arcLx/ugbu1XkkgSvcUZax0L6tXUqnppGniQb8l5VjUKGHoayXE5RiriiPhYDd+kjZnaok1Uig13PKtKYQ==", + "dev": true, + "engines": { + "node": ">=12.17" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -1603,26 +1524,29 @@ } }, "node_modules/dmd": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/dmd/-/dmd-6.2.3.tgz", - "integrity": "sha512-SIEkjrG7cZ9GWZQYk/mH+mWtcRPly/3ibVuXO/tP/MFoWz6KiRK77tSMq6YQBPl7RljPtXPQ/JhxbNuCdi1bNw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/dmd/-/dmd-7.1.1.tgz", + "integrity": "sha512-Ap2HP6iuOek7eShReDLr9jluNJm9RMZESlt29H/Xs1qrVMkcS9X6m5h1mBC56WMxNiSo0wvjGICmZlYUSFjwZQ==", "dev": true, "dependencies": { "array-back": "^6.2.2", - "cache-point": "^2.0.0", - "common-sequence": "^2.0.2", - "file-set": "^4.0.2", + "cache-point": "^3.0.0", + "common-sequence": "^3.0.0", + "file-set": "^5.2.2", "handlebars": "^4.7.8", "marked": "^4.3.0", - "object-get": "^2.1.1", - "reduce-flatten": "^3.0.1", - "reduce-unique": "^2.0.1", - "reduce-without": "^1.0.1", - "test-value": "^3.0.0", - "walk-back": "^5.1.0" + "walk-back": "^5.1.1" }, "engines": { - "node": ">=12" + "node": ">=12.17" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } } }, "node_modules/duplexify": { @@ -2037,46 +1961,24 @@ } }, "node_modules/file-set": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/file-set/-/file-set-4.0.2.tgz", - "integrity": "sha512-fuxEgzk4L8waGXaAkd8cMr73Pm0FxOVkn8hztzUW7BAHhOGH90viQNXbiOsnecCWmfInqU6YmAMwxRMdKETceQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/file-set/-/file-set-5.3.0.tgz", + "integrity": "sha512-FKCxdjLX0J6zqTWdT0RXIxNF/n7MyXXnsSUp0syLEOCKdexvPZ02lNNv2a+gpK9E3hzUYF3+eFZe32ci7goNUg==", "dev": true, "dependencies": { - "array-back": "^5.0.0", - "glob": "^7.1.6" + "array-back": "^6.2.2", + "fast-glob": "^3.3.2" }, "engines": { - "node": ">=10" - } - }, - "node_modules/file-set/node_modules/array-back": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-5.0.0.tgz", - "integrity": "sha512-kgVWwJReZWmVuWOQKEOohXKJX+nD02JAZ54D1RRWlv8L0NebauKAaFxACKzB74RTclt1+WNz5KHaLRDAPZbDEw==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/file-set/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "node": ">=12.17" }, - "engines": { - "node": "*" + "peerDependencies": { + "@75lb/nature": "latest" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } } }, "node_modules/fill-range": { @@ -2092,24 +1994,20 @@ } }, "node_modules/find-replace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", - "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", - "dev": true, - "dependencies": { - "array-back": "^3.0.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/find-replace/node_modules/array-back": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", - "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", + "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", "dev": true, "engines": { - "node": ">=6" + "node": ">=14" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } } }, "node_modules/find-up": { @@ -2187,15 +2085,6 @@ "node": ">=6 <7 || >=8" } }, - "node_modules/fs-then-native": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fs-then-native/-/fs-then-native-2.0.0.tgz", - "integrity": "sha512-X712jAOaWXkemQCAmWeg5rOT2i+KOpWz1Z/txk/cW0qlOu2oQ9H61vc5w3X/iyuUEfq/OyaFJ78/cZAQD1/bgA==", - "dev": true, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2871,9 +2760,9 @@ } }, "node_modules/jsdoc": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.3.tgz", - "integrity": "sha512-Nu7Sf35kXJ1MWDZIMAuATRQTg1iIPdzh7tqJ6jjvaU/GfDf+qi5UV8zJR3Mo+/pYFvm8mzay4+6O5EWigaQBQw==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.5.tgz", + "integrity": "sha512-P4C6MWP9yIlMiK8nwoZvxN84vb6MsnXcHuy7XzVOvQoCizWX5JFCBsWIIWKXBltpoRZXddUOVQmCTOZt9yDj9g==", "dev": true, "dependencies": { "@babel/parser": "^7.20.15", @@ -2900,76 +2789,73 @@ } }, "node_modules/jsdoc-api": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/jsdoc-api/-/jsdoc-api-8.1.1.tgz", - "integrity": "sha512-yas9E4h8NHp1CTEZiU/DPNAvLoUcip+Hl8Xi1RBYzHqSrgsF+mImAZNtwymrXvgbrgl4bNGBU9syulM0JzFeHQ==", + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/jsdoc-api/-/jsdoc-api-9.3.5.tgz", + "integrity": "sha512-TQwh1jA8xtCkIbVwm/XA3vDRAa5JjydyKx1cC413Sh3WohDFxcMdwKSvn4LOsq2xWyAmOU/VnSChTQf6EF0R8g==", "dev": true, "dependencies": { "array-back": "^6.2.2", - "cache-point": "^2.0.0", - "collect-all": "^1.0.4", - "file-set": "^4.0.2", - "fs-then-native": "^2.0.0", - "jsdoc": "^4.0.3", + "cache-point": "^3.0.1", + "current-module-paths": "^1.1.2", + "file-set": "^5.3.0", + "jsdoc": "^4.0.4", "object-to-spawn-args": "^2.0.1", - "temp-path": "^1.0.0", - "walk-back": "^5.1.0" + "walk-back": "^5.1.1" }, "engines": { "node": ">=12.17" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } } }, "node_modules/jsdoc-parse": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/jsdoc-parse/-/jsdoc-parse-6.2.4.tgz", - "integrity": "sha512-MQA+lCe3ioZd0uGbyB3nDCDZcKgKC7m/Ivt0LgKZdUoOlMJxUWJQ3WI6GeyHp9ouznKaCjlp7CU9sw5k46yZTw==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/jsdoc-parse/-/jsdoc-parse-6.2.5.tgz", + "integrity": "sha512-8JaSNjPLr2IuEY4Das1KM6Z4oLHZYUnjRrr27hKSa78Cj0i5Lur3DzNnCkz+DfrKBDoljGMoWOiBVQbtUZJBPw==", "dev": true, "dependencies": { "array-back": "^6.2.2", "find-replace": "^5.0.1", - "lodash.omit": "^4.5.0", "sort-array": "^5.0.0" }, "engines": { "node": ">=12" } }, - "node_modules/jsdoc-parse/node_modules/find-replace": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", - "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", - "dev": true, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@75lb/nature": "latest" - }, - "peerDependenciesMeta": { - "@75lb/nature": { - "optional": true - } - } - }, "node_modules/jsdoc-to-markdown": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/jsdoc-to-markdown/-/jsdoc-to-markdown-8.0.3.tgz", - "integrity": "sha512-JGYYd5xygnQt1DIxH+HUI+X/ynL8qWihzIF0n15NSCNtM6MplzawURRcaLI2WkiS2hIjRIgsphCOfM7FkaWiNg==", + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/jsdoc-to-markdown/-/jsdoc-to-markdown-9.1.3.tgz", + "integrity": "sha512-i9wi+6WHX0WKziv0ar88T8h7OmxA0LWdQaV23nY6uQyKvdUPzVt0o6YAaOceFuKRF5Rvlju5w/KnZBfdpDAlnw==", "dev": true, "dependencies": { "array-back": "^6.2.2", - "command-line-tool": "^0.8.0", + "command-line-args": "^6.0.1", + "command-line-usage": "^7.0.3", "config-master": "^3.1.0", - "dmd": "^6.2.3", - "jsdoc-api": "^8.1.1", - "jsdoc-parse": "^6.2.1", - "walk-back": "^5.1.0" + "dmd": "^7.1.1", + "jsdoc-api": "^9.3.5", + "jsdoc-parse": "^6.2.5", + "walk-back": "^5.1.1" }, "bin": { "jsdoc2md": "bin/cli.js" }, "engines": { "node": ">=12.17" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } } }, "node_modules/jsdoc/node_modules/escape-string-regexp": { @@ -3086,18 +2972,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/lodash.omit": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", - "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==", - "dev": true - }, - "node_modules/lodash.padend": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", - "integrity": "sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw==", - "dev": true - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -3264,12 +3138,6 @@ "node": ">=10" } }, - "node_modules/mkdirp2": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/mkdirp2/-/mkdirp2-1.0.5.tgz", - "integrity": "sha512-xOE9xbICroUDmG1ye2h4bZ8WBie9EGmACaco8K8cx6RlkJJrxGIqjGqztAI+NMhexXBcdGbSEzI6N3EJPevxZw==", - "dev": true - }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -3324,12 +3192,6 @@ "node": ">=8" } }, - "node_modules/object-get": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/object-get/-/object-get-2.1.1.tgz", - "integrity": "sha512-7n4IpLMzGGcLEMiQKsNR7vCe+N5E9LORFrtNUVy4sO3dj9a3HedZCxEL2T7QuLhcHN1NBuBsMOKaOsAYI9IIvg==", - "dev": true - }, "node_modules/object-to-spawn-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/object-to-spawn-args/-/object-to-spawn-args-2.0.1.tgz", @@ -3700,61 +3562,6 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, - "node_modules/reduce-flatten": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-3.0.1.tgz", - "integrity": "sha512-bYo+97BmUUOzg09XwfkwALt4PQH1M5L0wzKerBt6WLm3Fhdd43mMS89HiT1B9pJIqko/6lWx3OnV4J9f2Kqp5Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/reduce-unique": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/reduce-unique/-/reduce-unique-2.0.1.tgz", - "integrity": "sha512-x4jH/8L1eyZGR785WY+ePtyMNhycl1N2XOLxhCbzZFaqF4AXjLzqSxa2UHgJ2ZVR/HHyPOvl1L7xRnW8ye5MdA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/reduce-without": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/reduce-without/-/reduce-without-1.0.1.tgz", - "integrity": "sha512-zQv5y/cf85sxvdrKPlfcRzlDn/OqKFThNimYmsS3flmkioKvkUGn2Qg9cJVoQiEvdxFGLE0MQER/9fZ9sUqdxg==", - "dev": true, - "dependencies": { - "test-value": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/reduce-without/node_modules/array-back": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-1.0.4.tgz", - "integrity": "sha512-1WxbZvrmyhkNoeYcizokbmh5oiOCIfyvGtcqbK3Ls1v1fKcquzxnQSceOx6tzq7jmai2kFLWIpGND2cLhH6TPw==", - "dev": true, - "dependencies": { - "typical": "^2.6.0" - }, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/reduce-without/node_modules/test-value": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/test-value/-/test-value-2.1.0.tgz", - "integrity": "sha512-+1epbAxtKeXttkGFMTX9H42oqzOTufR1ceCF+GYA5aOmvaPq9wd4PUS8329fn2RRLGNeUkgRLnVpycjx8DsO2w==", - "dev": true, - "dependencies": { - "array-back": "^1.0.3", - "typical": "^2.6.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/registry-auth-token": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", @@ -4171,9 +3978,9 @@ } }, "node_modules/sort-array": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/sort-array/-/sort-array-5.0.0.tgz", - "integrity": "sha512-Sg9MzajSGprcSrMIxsXyNT0e0JB47RJRfJspC+7co4Z5BdNsNl8FmWI+lXEpyKq+vkMG6pHgAhqyCO+bkDTfFQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/sort-array/-/sort-array-5.1.1.tgz", + "integrity": "sha512-EltS7AIsNlAFIM9cayrgKrM6XP94ATWwXP4LCL4IQbvbYhELSt2hZTrixg+AaQwnWFs/JGJgqU3rxMcNNWxGAA==", "dev": true, "dependencies": { "array-back": "^6.2.2", @@ -4191,15 +3998,6 @@ } } }, - "node_modules/sort-array/node_modules/typical": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-7.2.0.tgz", - "integrity": "sha512-W1+HdVRUl8fS3MZ9ogD51GOb46xMmhAZzR0WPw5jcgIZQJVvkddYzAl4YTU6g5w33Y1iRQLdIi2/1jhi2RNL0g==", - "dev": true, - "engines": { - "node": ">=12.17" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4283,38 +4081,13 @@ "node": "*" } }, - "node_modules/static-eval": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", - "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", - "dev": true, - "dependencies": { - "escodegen": "^2.1.0" - } - }, - "node_modules/stream-connect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stream-connect/-/stream-connect-1.0.2.tgz", - "integrity": "sha512-68Kl+79cE0RGKemKkhxTSg8+6AGrqBt+cbZAXevg2iJ6Y3zX4JhA/sZeGzLpxW9cXhmqAcE7KnJCisUmIUfnFQ==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "dependencies": { - "array-back": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stream-connect/node_modules/array-back": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-1.0.4.tgz", - "integrity": "sha512-1WxbZvrmyhkNoeYcizokbmh5oiOCIfyvGtcqbK3Ls1v1fKcquzxnQSceOx6tzq7jmai2kFLWIpGND2cLhH6TPw==", + "node_modules/static-eval": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", + "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", "dev": true, "dependencies": { - "typical": "^2.6.0" - }, - "engines": { - "node": ">=0.12.0" + "escodegen": "^2.1.0" } }, "node_modules/stream-shift": { @@ -4323,15 +4096,6 @@ "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "dev": true }, - "node_modules/stream-via": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/stream-via/-/stream-via-1.0.4.tgz", - "integrity": "sha512-DBp0lSvX5G9KGRDTkR/R+a29H+Wk2xItOF+MpZLLNDWbEV9tGPnqLPxHEYjmiz8xGtJHRIqmI+hCjmNzqoA4nQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -4486,62 +4250,16 @@ } }, "node_modules/table-layout": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-0.4.5.tgz", - "integrity": "sha512-zTvf0mcggrGeTe/2jJ6ECkJHAQPIYEwDoqsiqBjI24mvRmQbInK5jq33fyypaCBxX08hMkfmdOqj6haT33EqWw==", - "dev": true, - "dependencies": { - "array-back": "^2.0.0", - "deep-extend": "~0.6.0", - "lodash.padend": "^4.6.1", - "typical": "^2.6.1", - "wordwrapjs": "^3.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/table-layout/node_modules/array-back": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-2.0.0.tgz", - "integrity": "sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==", - "dev": true, - "dependencies": { - "typical": "^2.6.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/temp-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/temp-path/-/temp-path-1.0.0.tgz", - "integrity": "sha512-TvmyH7kC6ZVTYkqCODjJIbgvu0FKiwQpZ4D1aknE7xpcDf/qEOB8KZEK5ef2pfbVoiBhNWs3yx4y+ESMtNYmlg==", - "dev": true - }, - "node_modules/test-value": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/test-value/-/test-value-3.0.0.tgz", - "integrity": "sha512-sVACdAWcZkSU9x7AOmJo5TqE+GyNJknHaHsMrR6ZnhjVlVN9Yx6FjHrsKZ3BjIpPCT68zYesPWkakrNupwfOTQ==", - "dev": true, - "dependencies": { - "array-back": "^2.0.0", - "typical": "^2.6.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/test-value/node_modules/array-back": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-2.0.0.tgz", - "integrity": "sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", "dev": true, "dependencies": { - "typical": "^2.6.1" + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" }, "engines": { - "node": ">=4" + "node": ">=12.17" } }, "node_modules/text-table": { @@ -4560,15 +4278,6 @@ "xtend": "~4.0.1" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4627,10 +4336,13 @@ "dev": true }, "node_modules/typical": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/typical/-/typical-2.6.1.tgz", - "integrity": "sha512-ofhi8kjIje6npGozTip9Fr8iecmYfEbS06i0JnIg+rh51KakryWF4+jX8lLKZVhy6N+ID45WYSFCxPOdTWCzNg==", - "dev": true + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "dev": true, + "engines": { + "node": ">=12.17" + } }, "node_modules/uc.micro": { "version": "2.1.0", @@ -4770,25 +4482,12 @@ "dev": true }, "node_modules/wordwrapjs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-3.0.0.tgz", - "integrity": "sha512-mO8XtqyPvykVCsrwj5MlOVWvSnCdT+C+QVbm6blradR7JExAhbkZ7hZ9A+9NUtwzSqrlUo9a67ws0EiILrvRpw==", - "dev": true, - "dependencies": { - "reduce-flatten": "^1.0.1", - "typical": "^2.6.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/wordwrapjs/node_modules/reduce-flatten": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-1.0.1.tgz", - "integrity": "sha512-j5WfFJfc9CoXv/WbwVLHq74i/hdTUpy+iNC534LxczMRP67vJeK3V9JOdnL0N1cIRbn9mYhE2yVjvvKXDxvNXQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=12.17" } }, "node_modules/wrap-ansi": { @@ -4977,24 +4676,24 @@ }, "dependencies": { "@babel/helper-string-parser": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", - "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true }, "@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true }, "@babel/parser": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", - "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dev": true, "requires": { - "@babel/types": "^7.25.6" + "@babel/types": "^7.28.5" } }, "@babel/runtime": { @@ -5004,14 +4703,13 @@ "dev": true }, "@babel/types": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", - "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dev": true, "requires": { - "@babel/helper-string-parser": "^7.24.8", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" } }, "@choojs/findup": { @@ -5171,9 +4869,9 @@ "dev": true }, "@jsdoc/salty": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.8.tgz", - "integrity": "sha512-5e+SFVavj1ORKlKaKr2BmTOekmXbelU7dC0cDkQLqag7xfuTPuGMUFx7KWJuv4bYZrTsoL2Z18VVCOKYxzoHcg==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.9.tgz", + "integrity": "sha512-yYxMVH7Dqw6nO0d5NIV8OQWnitU8k6vXH8NtgqAfIa/IUqRMxRv/NUJJ08VEKbAakwxlgBl5PJdrU0dMPStsnw==", "dev": true, "requires": { "lodash": "^4.17.21" @@ -5526,23 +5224,6 @@ } } }, - "ansi-escape-sequences": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-escape-sequences/-/ansi-escape-sequences-4.1.0.tgz", - "integrity": "sha512-dzW9kHxH011uBsidTXd14JXgzye/YLb2LzeKZ4bsgl/Knwx8AtbSFkkGxagdNOoh0DlqHCmfiEjWKBaqjOanVw==", - "dev": true, - "requires": { - "array-back": "^3.0.1" - }, - "dependencies": { - "array-back": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", - "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", - "dev": true - } - } - }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -5672,22 +5353,12 @@ "dev": true }, "cache-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/cache-point/-/cache-point-2.0.0.tgz", - "integrity": "sha512-4gkeHlFpSKgm3vm2gJN5sPqfmijYRFYCQ6tv5cLw0xVmT6r1z1vd4FNnpuOREco3cBs1G709sZ72LdgddKvL5w==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cache-point/-/cache-point-3.0.1.tgz", + "integrity": "sha512-itTIMLEKbh6Dw5DruXbxAgcyLnh/oPGVLBfTPqBOftASxHe8bAeXy7JkO4F0LvHqht7XqP5O/09h5UcHS2w0FA==", "dev": true, "requires": { - "array-back": "^4.0.1", - "fs-then-native": "^2.0.0", - "mkdirp2": "^1.0.4" - }, - "dependencies": { - "array-back": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.2.tgz", - "integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==", - "dev": true - } + "array-back": "^6.2.2" } }, "callsites": { @@ -5799,16 +5470,6 @@ } } }, - "collect-all": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/collect-all/-/collect-all-1.0.4.tgz", - "integrity": "sha512-RKZhRwJtJEP5FWul+gkSMEnaK6H3AGPTTWOiRimCcs+rc/OmQE3Yhy1Q7A7KsdkG3ZXVdZq68Y6ONSdvkeEcKA==", - "dev": true, - "requires": { - "stream-connect": "^1.0.2", - "stream-via": "^1.0.4" - } - }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5831,76 +5492,27 @@ "dev": true }, "command-line-args": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", - "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.1.tgz", + "integrity": "sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==", "dev": true, "requires": { - "array-back": "^3.1.0", - "find-replace": "^3.0.0", + "array-back": "^6.2.2", + "find-replace": "^5.0.2", "lodash.camelcase": "^4.3.0", - "typical": "^4.0.0" - }, - "dependencies": { - "array-back": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", - "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", - "dev": true - }, - "typical": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", - "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", - "dev": true - } - } - }, - "command-line-tool": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/command-line-tool/-/command-line-tool-0.8.0.tgz", - "integrity": "sha512-Xw18HVx/QzQV3Sc5k1vy3kgtOeGmsKIqwtFFoyjI4bbcpSgnw2CWVULvtakyw4s6fhyAdI6soQQhXc2OzJy62g==", - "dev": true, - "requires": { - "ansi-escape-sequences": "^4.0.0", - "array-back": "^2.0.0", - "command-line-args": "^5.0.0", - "command-line-usage": "^4.1.0", - "typical": "^2.6.1" - }, - "dependencies": { - "array-back": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-2.0.0.tgz", - "integrity": "sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==", - "dev": true, - "requires": { - "typical": "^2.6.1" - } - } + "typical": "^7.2.0" } }, "command-line-usage": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-4.1.0.tgz", - "integrity": "sha512-MxS8Ad995KpdAC0Jopo/ovGIroV/m0KHwzKfXxKag6FHOkGsH8/lv5yjgablcRxCJJC0oJeUMuO/gmaq+Wq46g==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", + "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", "dev": true, "requires": { - "ansi-escape-sequences": "^4.0.0", - "array-back": "^2.0.0", - "table-layout": "^0.4.2", - "typical": "^2.6.1" - }, - "dependencies": { - "array-back": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-2.0.0.tgz", - "integrity": "sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==", - "dev": true, - "requires": { - "typical": "^2.6.1" - } - } + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.0", + "typical": "^7.1.1" } }, "commander": { @@ -5916,9 +5528,9 @@ "dev": true }, "common-sequence": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/common-sequence/-/common-sequence-2.0.2.tgz", - "integrity": "sha512-jAg09gkdkrDO9EWTdXfv80WWH3yeZl5oT69fGfedBNS9pXUKYInVJ1bJ+/ht2+Moeei48TmSbQDYMc8EOx9G0g==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-sequence/-/common-sequence-3.0.0.tgz", + "integrity": "sha512-g/CgSYk93y+a1IKm50tKl7kaT/OjjTYVQlEbUlt/49ZLV1mcKpUU7iyDiqTAeLdb4QDtQfq3ako8y8v//fzrWQ==", "dev": true }, "commondir": { @@ -6049,6 +5661,12 @@ "which": "^2.0.1" } }, + "current-module-paths": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/current-module-paths/-/current-module-paths-1.1.2.tgz", + "integrity": "sha512-H4s4arcLx/ugbu1XkkgSvcUZax0L6tXUqnppGniQb8l5VjUKGHoayXE5RiriiPhYDd+kjZnaok1Uig13PKtKYQ==", + "dev": true + }, "date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -6095,23 +5713,18 @@ } }, "dmd": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/dmd/-/dmd-6.2.3.tgz", - "integrity": "sha512-SIEkjrG7cZ9GWZQYk/mH+mWtcRPly/3ibVuXO/tP/MFoWz6KiRK77tSMq6YQBPl7RljPtXPQ/JhxbNuCdi1bNw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/dmd/-/dmd-7.1.1.tgz", + "integrity": "sha512-Ap2HP6iuOek7eShReDLr9jluNJm9RMZESlt29H/Xs1qrVMkcS9X6m5h1mBC56WMxNiSo0wvjGICmZlYUSFjwZQ==", "dev": true, "requires": { "array-back": "^6.2.2", - "cache-point": "^2.0.0", - "common-sequence": "^2.0.2", - "file-set": "^4.0.2", + "cache-point": "^3.0.0", + "common-sequence": "^3.0.0", + "file-set": "^5.2.2", "handlebars": "^4.7.8", "marked": "^4.3.0", - "object-get": "^2.1.1", - "reduce-flatten": "^3.0.1", - "reduce-unique": "^2.0.1", - "reduce-without": "^1.0.1", - "test-value": "^3.0.0", - "walk-back": "^5.1.0" + "walk-back": "^5.1.1" } }, "duplexify": { @@ -6418,35 +6031,13 @@ } }, "file-set": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/file-set/-/file-set-4.0.2.tgz", - "integrity": "sha512-fuxEgzk4L8waGXaAkd8cMr73Pm0FxOVkn8hztzUW7BAHhOGH90viQNXbiOsnecCWmfInqU6YmAMwxRMdKETceQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/file-set/-/file-set-5.3.0.tgz", + "integrity": "sha512-FKCxdjLX0J6zqTWdT0RXIxNF/n7MyXXnsSUp0syLEOCKdexvPZ02lNNv2a+gpK9E3hzUYF3+eFZe32ci7goNUg==", "dev": true, "requires": { - "array-back": "^5.0.0", - "glob": "^7.1.6" - }, - "dependencies": { - "array-back": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-5.0.0.tgz", - "integrity": "sha512-kgVWwJReZWmVuWOQKEOohXKJX+nD02JAZ54D1RRWlv8L0NebauKAaFxACKzB74RTclt1+WNz5KHaLRDAPZbDEw==", - "dev": true - }, - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } + "array-back": "^6.2.2", + "fast-glob": "^3.3.2" } }, "fill-range": { @@ -6459,21 +6050,11 @@ } }, "find-replace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", - "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", + "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", "dev": true, - "requires": { - "array-back": "^3.0.1" - }, - "dependencies": { - "array-back": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", - "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", - "dev": true - } - } + "requires": {} }, "find-up": { "version": "5.0.0", @@ -6532,12 +6113,6 @@ "universalify": "^0.1.0" } }, - "fs-then-native": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fs-then-native/-/fs-then-native-2.0.0.tgz", - "integrity": "sha512-X712jAOaWXkemQCAmWeg5rOT2i+KOpWz1Z/txk/cW0qlOu2oQ9H61vc5w3X/iyuUEfq/OyaFJ78/cZAQD1/bgA==", - "dev": true - }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -7079,9 +6654,9 @@ } }, "jsdoc": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.3.tgz", - "integrity": "sha512-Nu7Sf35kXJ1MWDZIMAuATRQTg1iIPdzh7tqJ6jjvaU/GfDf+qi5UV8zJR3Mo+/pYFvm8mzay4+6O5EWigaQBQw==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.5.tgz", + "integrity": "sha512-P4C6MWP9yIlMiK8nwoZvxN84vb6MsnXcHuy7XzVOvQoCizWX5JFCBsWIIWKXBltpoRZXddUOVQmCTOZt9yDj9g==", "dev": true, "requires": { "@babel/parser": "^7.20.15", @@ -7110,56 +6685,45 @@ } }, "jsdoc-api": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/jsdoc-api/-/jsdoc-api-8.1.1.tgz", - "integrity": "sha512-yas9E4h8NHp1CTEZiU/DPNAvLoUcip+Hl8Xi1RBYzHqSrgsF+mImAZNtwymrXvgbrgl4bNGBU9syulM0JzFeHQ==", + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/jsdoc-api/-/jsdoc-api-9.3.5.tgz", + "integrity": "sha512-TQwh1jA8xtCkIbVwm/XA3vDRAa5JjydyKx1cC413Sh3WohDFxcMdwKSvn4LOsq2xWyAmOU/VnSChTQf6EF0R8g==", "dev": true, "requires": { "array-back": "^6.2.2", - "cache-point": "^2.0.0", - "collect-all": "^1.0.4", - "file-set": "^4.0.2", - "fs-then-native": "^2.0.0", - "jsdoc": "^4.0.3", + "cache-point": "^3.0.1", + "current-module-paths": "^1.1.2", + "file-set": "^5.3.0", + "jsdoc": "^4.0.4", "object-to-spawn-args": "^2.0.1", - "temp-path": "^1.0.0", - "walk-back": "^5.1.0" + "walk-back": "^5.1.1" } }, "jsdoc-parse": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/jsdoc-parse/-/jsdoc-parse-6.2.4.tgz", - "integrity": "sha512-MQA+lCe3ioZd0uGbyB3nDCDZcKgKC7m/Ivt0LgKZdUoOlMJxUWJQ3WI6GeyHp9ouznKaCjlp7CU9sw5k46yZTw==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/jsdoc-parse/-/jsdoc-parse-6.2.5.tgz", + "integrity": "sha512-8JaSNjPLr2IuEY4Das1KM6Z4oLHZYUnjRrr27hKSa78Cj0i5Lur3DzNnCkz+DfrKBDoljGMoWOiBVQbtUZJBPw==", "dev": true, "requires": { "array-back": "^6.2.2", "find-replace": "^5.0.1", - "lodash.omit": "^4.5.0", "sort-array": "^5.0.0" - }, - "dependencies": { - "find-replace": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", - "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", - "dev": true, - "requires": {} - } } }, "jsdoc-to-markdown": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/jsdoc-to-markdown/-/jsdoc-to-markdown-8.0.3.tgz", - "integrity": "sha512-JGYYd5xygnQt1DIxH+HUI+X/ynL8qWihzIF0n15NSCNtM6MplzawURRcaLI2WkiS2hIjRIgsphCOfM7FkaWiNg==", + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/jsdoc-to-markdown/-/jsdoc-to-markdown-9.1.3.tgz", + "integrity": "sha512-i9wi+6WHX0WKziv0ar88T8h7OmxA0LWdQaV23nY6uQyKvdUPzVt0o6YAaOceFuKRF5Rvlju5w/KnZBfdpDAlnw==", "dev": true, "requires": { "array-back": "^6.2.2", - "command-line-tool": "^0.8.0", + "command-line-args": "^6.0.1", + "command-line-usage": "^7.0.3", "config-master": "^3.1.0", - "dmd": "^6.2.3", - "jsdoc-api": "^8.1.1", - "jsdoc-parse": "^6.2.1", - "walk-back": "^5.1.0" + "dmd": "^7.1.1", + "jsdoc-api": "^9.3.5", + "jsdoc-parse": "^6.2.5", + "walk-back": "^5.1.1" } }, "json-buffer": { @@ -7258,18 +6822,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "lodash.omit": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", - "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==", - "dev": true - }, - "lodash.padend": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", - "integrity": "sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw==", - "dev": true - }, "lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -7399,12 +6951,6 @@ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true }, - "mkdirp2": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/mkdirp2/-/mkdirp2-1.0.5.tgz", - "integrity": "sha512-xOE9xbICroUDmG1ye2h4bZ8WBie9EGmACaco8K8cx6RlkJJrxGIqjGqztAI+NMhexXBcdGbSEzI6N3EJPevxZw==", - "dev": true - }, "moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -7450,12 +6996,6 @@ "path-key": "^3.0.0" } }, - "object-get": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/object-get/-/object-get-2.1.1.tgz", - "integrity": "sha512-7n4IpLMzGGcLEMiQKsNR7vCe+N5E9LORFrtNUVy4sO3dj9a3HedZCxEL2T7QuLhcHN1NBuBsMOKaOsAYI9IIvg==", - "dev": true - }, "object-to-spawn-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/object-to-spawn-args/-/object-to-spawn-args-2.0.1.tgz", @@ -7712,48 +7252,6 @@ } } }, - "reduce-flatten": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-3.0.1.tgz", - "integrity": "sha512-bYo+97BmUUOzg09XwfkwALt4PQH1M5L0wzKerBt6WLm3Fhdd43mMS89HiT1B9pJIqko/6lWx3OnV4J9f2Kqp5Q==", - "dev": true - }, - "reduce-unique": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/reduce-unique/-/reduce-unique-2.0.1.tgz", - "integrity": "sha512-x4jH/8L1eyZGR785WY+ePtyMNhycl1N2XOLxhCbzZFaqF4AXjLzqSxa2UHgJ2ZVR/HHyPOvl1L7xRnW8ye5MdA==", - "dev": true - }, - "reduce-without": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/reduce-without/-/reduce-without-1.0.1.tgz", - "integrity": "sha512-zQv5y/cf85sxvdrKPlfcRzlDn/OqKFThNimYmsS3flmkioKvkUGn2Qg9cJVoQiEvdxFGLE0MQER/9fZ9sUqdxg==", - "dev": true, - "requires": { - "test-value": "^2.0.0" - }, - "dependencies": { - "array-back": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-1.0.4.tgz", - "integrity": "sha512-1WxbZvrmyhkNoeYcizokbmh5oiOCIfyvGtcqbK3Ls1v1fKcquzxnQSceOx6tzq7jmai2kFLWIpGND2cLhH6TPw==", - "dev": true, - "requires": { - "typical": "^2.6.0" - } - }, - "test-value": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/test-value/-/test-value-2.1.0.tgz", - "integrity": "sha512-+1epbAxtKeXttkGFMTX9H42oqzOTufR1ceCF+GYA5aOmvaPq9wd4PUS8329fn2RRLGNeUkgRLnVpycjx8DsO2w==", - "dev": true, - "requires": { - "array-back": "^1.0.3", - "typical": "^2.6.0" - } - } - } - }, "registry-auth-token": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", @@ -8057,21 +7555,13 @@ "dev": true }, "sort-array": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/sort-array/-/sort-array-5.0.0.tgz", - "integrity": "sha512-Sg9MzajSGprcSrMIxsXyNT0e0JB47RJRfJspC+7co4Z5BdNsNl8FmWI+lXEpyKq+vkMG6pHgAhqyCO+bkDTfFQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/sort-array/-/sort-array-5.1.1.tgz", + "integrity": "sha512-EltS7AIsNlAFIM9cayrgKrM6XP94ATWwXP4LCL4IQbvbYhELSt2hZTrixg+AaQwnWFs/JGJgqU3rxMcNNWxGAA==", "dev": true, "requires": { "array-back": "^6.2.2", "typical": "^7.1.1" - }, - "dependencies": { - "typical": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-7.2.0.tgz", - "integrity": "sha512-W1+HdVRUl8fS3MZ9ogD51GOb46xMmhAZzR0WPw5jcgIZQJVvkddYzAl4YTU6g5w33Y1iRQLdIi2/1jhi2RNL0g==", - "dev": true - } } }, "source-map": { @@ -8160,38 +7650,12 @@ "escodegen": "^2.1.0" } }, - "stream-connect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stream-connect/-/stream-connect-1.0.2.tgz", - "integrity": "sha512-68Kl+79cE0RGKemKkhxTSg8+6AGrqBt+cbZAXevg2iJ6Y3zX4JhA/sZeGzLpxW9cXhmqAcE7KnJCisUmIUfnFQ==", - "dev": true, - "requires": { - "array-back": "^1.0.2" - }, - "dependencies": { - "array-back": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-1.0.4.tgz", - "integrity": "sha512-1WxbZvrmyhkNoeYcizokbmh5oiOCIfyvGtcqbK3Ls1v1fKcquzxnQSceOx6tzq7jmai2kFLWIpGND2cLhH6TPw==", - "dev": true, - "requires": { - "typical": "^2.6.0" - } - } - } - }, "stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "dev": true }, - "stream-via": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/stream-via/-/stream-via-1.0.4.tgz", - "integrity": "sha512-DBp0lSvX5G9KGRDTkR/R+a29H+Wk2xItOF+MpZLLNDWbEV9tGPnqLPxHEYjmiz8xGtJHRIqmI+hCjmNzqoA4nQ==", - "dev": true - }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -8302,54 +7766,13 @@ "dev": true }, "table-layout": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-0.4.5.tgz", - "integrity": "sha512-zTvf0mcggrGeTe/2jJ6ECkJHAQPIYEwDoqsiqBjI24mvRmQbInK5jq33fyypaCBxX08hMkfmdOqj6haT33EqWw==", - "dev": true, - "requires": { - "array-back": "^2.0.0", - "deep-extend": "~0.6.0", - "lodash.padend": "^4.6.1", - "typical": "^2.6.1", - "wordwrapjs": "^3.0.0" - }, - "dependencies": { - "array-back": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-2.0.0.tgz", - "integrity": "sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==", - "dev": true, - "requires": { - "typical": "^2.6.1" - } - } - } - }, - "temp-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/temp-path/-/temp-path-1.0.0.tgz", - "integrity": "sha512-TvmyH7kC6ZVTYkqCODjJIbgvu0FKiwQpZ4D1aknE7xpcDf/qEOB8KZEK5ef2pfbVoiBhNWs3yx4y+ESMtNYmlg==", - "dev": true - }, - "test-value": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/test-value/-/test-value-3.0.0.tgz", - "integrity": "sha512-sVACdAWcZkSU9x7AOmJo5TqE+GyNJknHaHsMrR6ZnhjVlVN9Yx6FjHrsKZ3BjIpPCT68zYesPWkakrNupwfOTQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", "dev": true, "requires": { - "array-back": "^2.0.0", - "typical": "^2.6.1" - }, - "dependencies": { - "array-back": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-2.0.0.tgz", - "integrity": "sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==", - "dev": true, - "requires": { - "typical": "^2.6.1" - } - } + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" } }, "text-table": { @@ -8368,12 +7791,6 @@ "xtend": "~4.0.1" } }, - "to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true - }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8417,9 +7834,9 @@ "dev": true }, "typical": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/typical/-/typical-2.6.1.tgz", - "integrity": "sha512-ofhi8kjIje6npGozTip9Fr8iecmYfEbS06i0JnIg+rh51KakryWF4+jX8lLKZVhy6N+ID45WYSFCxPOdTWCzNg==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", "dev": true }, "uc.micro": { @@ -8529,22 +7946,10 @@ "dev": true }, "wordwrapjs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-3.0.0.tgz", - "integrity": "sha512-mO8XtqyPvykVCsrwj5MlOVWvSnCdT+C+QVbm6blradR7JExAhbkZ7hZ9A+9NUtwzSqrlUo9a67ws0EiILrvRpw==", - "dev": true, - "requires": { - "reduce-flatten": "^1.0.1", - "typical": "^2.6.1" - }, - "dependencies": { - "reduce-flatten": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-1.0.1.tgz", - "integrity": "sha512-j5WfFJfc9CoXv/WbwVLHq74i/hdTUpy+iNC534LxczMRP67vJeK3V9JOdnL0N1cIRbn9mYhE2yVjvvKXDxvNXQ==", - "dev": true - } - } + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", + "dev": true }, "wrap-ansi": { "version": "8.1.0", diff --git a/package.json b/package.json index 574dc1f8..ac536273 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "testApp": "npm run build & npx serve ./dist", "test": "npx playwright test", "prepublishOnly": "npm run build && npm run build_docs", - "build_docs": "jsdoc2md source/gltf-sample-renderer.js source/GltfView/gltf_view.js source/GltfState/gltf_state.js source/ResourceLoader/resource_loader.js source/gltf/user_camera.js > API.md", + "build_docs": "jsdoc2md source/gltf-sample-renderer.js source/GltfView/gltf_view.js source/GltfState/gltf_state.js source/ResourceLoader/resource_loader.js source/gltf/user_camera.js source/gltf/interactivity.js > API.md", "lint": "eslint source/**/*.js", "lint:fix": "eslint --fix source/**/*.js", "prettier": "npx prettier source/**/*.js tests/**/*.js tests/**/*.ts --check", @@ -46,7 +46,7 @@ "concurrently": "^8.2.2", "eslint": "^9.5.0", "eslint-config-prettier": "^10.1.8", - "jsdoc-to-markdown": "^8.0.1", + "jsdoc-to-markdown": "^9.1.3", "prettier": "3.6.2", "rollup": "^4.23.0", "rollup-plugin-copy": "^3.5.0", @@ -58,4 +58,4 @@ "url": "https://github.com/KhronosGroup/glTF-Sample-Renderer/issues" }, "homepage": "https://github.com/KhronosGroup/glTF-Sample-Renderer/#readme" -} +} \ No newline at end of file diff --git a/source/GltfState/gltf_state.js b/source/GltfState/gltf_state.js index e39e4a76..0db6935a 100644 --- a/source/GltfState/gltf_state.js +++ b/source/GltfState/gltf_state.js @@ -35,8 +35,14 @@ class GltfState { /** the graph controller allows selecting and playing graphs from KHR_interactivity */ this.graphController = new GraphController(); - /** callback for selection/hover */ + /** callback for selection: (selectionInfo : { + * node, + * position, + * rayOrigin, + * controller }) => {} */ this.selectionCallback = undefined; + + /** callback for hovering: (hoverInfo : { node, controller }) => {} */ this.hoverCallback = undefined; /** If the renderer should compute selection in the next frame. Is automatically reset after the frame is rendered */ @@ -57,16 +63,17 @@ class GltfState { /** skin / skeleton */ skinning: true, + /** enabled extensions */ enabledExtensions: { - /** KHR_materials_clearcoat */ + /** KHR_materials_clearcoat adds a clear coat layer on top of the glTF base material */ KHR_materials_clearcoat: true, - /** KHR_materials_sheen */ + /** KHR_materials_sheen adds a sheen layer on top of the glTF base material */ KHR_materials_sheen: true, - /** KHR_materials_transmission */ + /** KHR_materials_transmission adds physical-based transparency */ KHR_materials_transmission: true, - /** KHR_materials_volume */ + /** KHR_materials_volume adds support for volumetric materials. Used together with KHR_materials_transmission and KHR_materials_diffuse_transmission */ KHR_materials_volume: true, - /** KHR_materials_volume_scatter */ + /** KHR_materials_volume_scatter allows the simulation of scattering light inside a volume. Used together with KHR_materials_volume */ KHR_materials_volume_scatter: true, /** KHR_materials_ior makes the index of refraction configurable */ KHR_materials_ior: true, @@ -74,15 +81,21 @@ class GltfState { KHR_materials_specular: true, /** KHR_materials_iridescence adds a thin-film iridescence effect */ KHR_materials_iridescence: true, + /** KHR_materials_diffuse_transmission allows light to pass diffusely through the material */ KHR_materials_diffuse_transmission: true, /** KHR_materials_anisotropy defines microfacet grooves in the surface, stretching the specular reflection on the surface */ KHR_materials_anisotropy: true, - /** KHR_materials_dispersion defines configuring the strength of the angular separation of colors (chromatic abberation)*/ + /** KHR_materials_dispersion defines configuring the strength of the angular separation of colors (chromatic abberation) */ KHR_materials_dispersion: true, + /** KHR_materials_emissive_strength enables emissive factors larger than 1.0 */ KHR_materials_emissive_strength: true, + /** KHR_interactivity enables execution of a behavior graph */ KHR_interactivity: true, + /** KHR_node_hoverability enables hovering over nodes */ KHR_node_hoverability: true, + /** KHR_node_selectability enables selecting nodes */ KHR_node_selectability: true, + /** KHR_node_visibility enables controlling the visibility of nodes */ KHR_node_visibility: true }, /** clear color expressed as list of ints in the range [0, 255] */ diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index 37f39e62..bdf0c2aa 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -959,7 +959,7 @@ class gltfRenderer { } } state.graphController.receiveHover(pickingResult); - if (state.hoverCallback) { + if (state.enableHover && state.hoverCallback) { state.hoverCallback(pickingResult); } } From 834e760fbe6579850ff9b71fecc9c9ccd6bd62fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Mon, 27 Oct 2025 19:12:57 +0100 Subject: [PATCH 62/82] Add testing section to ReadMe --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f1db5293..c1db1618 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,10 @@ Try out the [glTF Sample Viewer](https://github.khronos.org/glTF-Sample-Viewer-R - [GraphController](#graphcontroller) - [ResourceLoader](#resourceloader) - [Render Fidelity Tools](#render-fidelity-tools) - - [Formatting](#formatting) - - [Visual Studio Code](#visual-studio-code) + - [Development](#development) + - [Formatting](#formatting) + - [Visual Studio Code](#visual-studio-code) + - [Testing](#testing) ## Credits @@ -127,7 +129,21 @@ state.gltf = await resourceLoader.loadGltf("path/to/some.gltf"); The glTF Sample Renderer is integrated into Google's [render fidelity tools](https://github.com/google/model-viewer/tree/master/packages/render-fidelity-tools). The render fidelity tools allow the comparison of different renderers. To run the project follow the instructions [here](https://github.com/google/model-viewer/blob/master/README.md) and [here](https://github.com/google/model-viewer/blob/master/packages/render-fidelity-tools/README.md). For information on how the glTF Sample Renderer was integrated see the [pull request on Github](https://github.com/google/model-viewer/pull/1962). -## Formatting +## Development + +After cloning this repository, run + +``` +npm install +``` + +to install all dependencies. To test and view your changes on a canvas, it is recommended to clone [glTF Sample Viewer](https://github.com/KhronosGroup/glTF-Sample-Viewer) which uses this renderer as a submodule. + +`npm run build` will build the npm package and put the bundled code into the `dist` directory. + +`npm run build_docs` will regenerate the [API documentation](API.md). + +### Formatting This repository uses [Prettier](https://prettier.io/) for code formatting and [ESLint](https://eslint.org/) for linting. @@ -147,3 +163,27 @@ There are extensions for both Prettier and ESLint in Visual Studio Code. They ca - [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) You are encouraged to run Prettier and ESLint on your code before committing. + +### Testing + +glTF-Sample-Render uses [Playwright](https://playwright.dev/) for testing.\ +Currently, only `KHR_interactivity` tests are implemented. + +To run the tests run + +``` +npm run test +``` + +Playwright creates a new browser instance for each test. It can run on Chrome, Safari, Firefox and emulated mobile browsers. After all tests were run, a browser window with a summary will open. The `tests/testApp` directory contains a minimal frontend to be able to start a testing server. The server is started automatically. For debugging the test server you can also start it manually by running + +``` +npm run testApp +``` + +Tests are defined in the `tests` directory by files with the `.spec.ts` ending.\ +The interactivity tests download all test assets from the [glTF-Test-Assets-Interactivity repository](https://github.com/KhronosGroup/glTF-Test-Assets-Interactivity), loads each test file and listens on the `test/onStart`, `test/onSuccess` and `test/onFailed` events to determine if an interactivity test passes or not. `test/onStart` returns the needed execution time in seconds. + +You can also run more complex Playwright commands such as `npx playwright test --ui` or `npx playwright test --project chromium`. For more information check https://playwright.dev/docs/running-tests + +One can also use the [Playwright extension for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) to run the test more easily with advanced parameters, run tests only selectively or debug tests by adding breakpoints. From c933925eadcde05dc00e723ea917c7e3c556007c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 28 Oct 2025 11:19:03 +0100 Subject: [PATCH 63/82] Add documentation for AnimationTimer --- API.md | 66 +++++++++++++++++++++++++++ README.md | 6 +++ package.json | 2 +- source/GltfState/animation_timer.js | 69 +++++++++++++++++++++++++++++ source/GltfState/gltf_state.js | 2 +- source/gltf/utils.js | 55 ----------------------- 6 files changed, 143 insertions(+), 57 deletions(-) create mode 100644 source/GltfState/animation_timer.js diff --git a/API.md b/API.md index 0e8703f4..34446fe6 100644 --- a/API.md +++ b/API.md @@ -7,6 +7,9 @@
GltfState

GltfState containing a state for visualization in GltfView

+
AnimationTimer
+

AnimationTimer class to control animation playback.

+
ResourceLoader

ResourceLoader can be used to load resources for the GltfState that are then used to display the loaded data with GltfView

@@ -928,6 +931,69 @@ output the anisotropic strength output final direction as defined by the anisotropyTexture and rotation **Kind**: static property of [anisotropy](#GltfState.DebugOutput.anisotropy) + + +## AnimationTimer +AnimationTimer class to control animation playback. + +**Kind**: global class + +* [AnimationTimer](#AnimationTimer) + * [.start()](#AnimationTimer+start) + * [.pause()](#AnimationTimer+pause) + * [.unpause()](#AnimationTimer+unpause) + * [.toggle()](#AnimationTimer+toggle) + * [.reset()](#AnimationTimer+reset) + * [.setFixedTime(timeInSec)](#AnimationTimer+setFixedTime) + * [.elapsedSec()](#AnimationTimer+elapsedSec) + + + +### animationTimer.start() +Start the animation timer and all animations + +**Kind**: instance method of [AnimationTimer](#AnimationTimer) + + +### animationTimer.pause() +Pause all animations + +**Kind**: instance method of [AnimationTimer](#AnimationTimer) + + +### animationTimer.unpause() +Unpause all animations + +**Kind**: instance method of [AnimationTimer](#AnimationTimer) + + +### animationTimer.toggle() +Toggle the animation playback state + +**Kind**: instance method of [AnimationTimer](#AnimationTimer) + + +### animationTimer.reset() +Reset the animation timer. If animations were playing, they will be restarted. + +**Kind**: instance method of [AnimationTimer](#AnimationTimer) + + +### animationTimer.setFixedTime(timeInSec) +Plays all animations starting from the specified time + +**Kind**: instance method of [AnimationTimer](#AnimationTimer) + +| Param | Type | Description | +| --- | --- | --- | +| timeInSec | number | The time in seconds to set the animation timer to | + + + +### animationTimer.elapsedSec() +Get the elapsed time in seconds + +**Kind**: instance method of [AnimationTimer](#AnimationTimer) ## ResourceLoader diff --git a/README.md b/README.md index c1db1618..17a1d7ad 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Try out the [glTF Sample Viewer](https://github.khronos.org/glTF-Sample-Viewer-R - [GltfView](#gltfview) - [GltfState](#gltfstate) - [GraphController](#graphcontroller) + - [AnimationTimer](#animationtimer) - [ResourceLoader](#resourceloader) - [Render Fidelity Tools](#render-fidelity-tools) - [Development](#development) @@ -117,6 +118,11 @@ If `enableHover` is set to `true`, the render will return the picking result of To make sure that `KHR_interactivity` always behaves correctly together with `KHR_node_selectability` and `KHR_node_hoverability`, update the values in the `hoverPositions` and `selectionPositions` arrays and trigger selections via `triggerSelection`. Currently, only one controller is supported. All entries except the first one of each array are ignored. Arrays are used to enable multiple controllers in the future without breaking the API. +#### AnimationTimer + +The GltfState contains an instance of the AnimationTimer, which is used to play, pause and reset animations. It needs to be started to enable animations. +The `KHR_interactivity` extension controls animations if present. Therefore, the GraphController uses the time of the AnimationTimer to control animations. The GraphController is paused and resumed independently from the AnimationTimer, thus if an interactivity graph is paused, currently playing animations will continue playing if the AnimationTimer is not paused as well. + ### ResourceLoader The ResourceLoader can be used to load external resources and make them available to the renderer. diff --git a/package.json b/package.json index ac536273..b617476f 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "testApp": "npm run build & npx serve ./dist", "test": "npx playwright test", "prepublishOnly": "npm run build && npm run build_docs", - "build_docs": "jsdoc2md source/gltf-sample-renderer.js source/GltfView/gltf_view.js source/GltfState/gltf_state.js source/ResourceLoader/resource_loader.js source/gltf/user_camera.js source/gltf/interactivity.js > API.md", + "build_docs": "jsdoc2md source/gltf-sample-renderer.js source/GltfView/gltf_view.js source/GltfState/gltf_state.js source/GltfState/animation_timer.js source/ResourceLoader/resource_loader.js source/gltf/user_camera.js source/gltf/interactivity.js > API.md", "lint": "eslint source/**/*.js", "lint:fix": "eslint --fix source/**/*.js", "prettier": "npx prettier source/**/*.js tests/**/*.js tests/**/*.ts --check", diff --git a/source/GltfState/animation_timer.js b/source/GltfState/animation_timer.js new file mode 100644 index 00000000..ff62f054 --- /dev/null +++ b/source/GltfState/animation_timer.js @@ -0,0 +1,69 @@ +/** + * AnimationTimer class to control animation playback. + */ +class AnimationTimer { + constructor() { + this.startTime = 0; + this.paused = true; + this.fixedTime = null; + this.pausedTime = 0; + } + + /** Start the animation timer and all animations */ + start() { + this.startTime = performance.now(); + this.paused = false; + } + + /** Pause all animations */ + pause() { + this.pausedTime = performance.now() - this.startTime; + this.paused = true; + } + + /** Unpause all animations */ + unpause() { + this.startTime += performance.now() - this.startTime - this.pausedTime; + this.paused = false; + } + + /** Toggle the animation playback state */ + toggle() { + if (this.paused) { + this.unpause(); + } else { + this.pause(); + } + } + + /** Reset the animation timer. If animations were playing, they will be restarted. */ + reset() { + if (!this.paused) { + // Animation is running. + this.startTime = performance.now(); + } else { + this.startTime = 0; + } + this.pausedTime = 0; + } + + /** + * Plays all animations starting from the specified time + * @param {number} timeInSec The time in seconds to set the animation timer to + */ + setFixedTime(timeInSec) { + this.paused = false; + this.fixedTime = timeInSec; + } + + /** Get the elapsed time in seconds */ + elapsedSec() { + if (this.paused) { + return this.pausedTime / 1000; + } else { + return this.fixedTime || (performance.now() - this.startTime) / 1000; + } + } +} + +export { AnimationTimer }; diff --git a/source/GltfState/gltf_state.js b/source/GltfState/gltf_state.js index 0db6935a..7f8dae83 100644 --- a/source/GltfState/gltf_state.js +++ b/source/GltfState/gltf_state.js @@ -1,6 +1,6 @@ import { GraphController } from "../gltf/interactivity.js"; import { UserCamera } from "../gltf/user_camera.js"; -import { AnimationTimer } from "../gltf/utils.js"; +import { AnimationTimer } from "./animation_timer.js"; /** * GltfState containing a state for visualization in GltfView diff --git a/source/gltf/utils.js b/source/gltf/utils.js index b17ad170..1923d28a 100644 --- a/source/gltf/utils.js +++ b/source/gltf/utils.js @@ -164,61 +164,6 @@ class Timer { } } -class AnimationTimer { - constructor() { - this.startTime = 0; - this.paused = true; - this.fixedTime = null; - this.pausedTime = 0; - } - - elapsedSec() { - if (this.paused) { - return this.pausedTime / 1000; - } else { - return this.fixedTime || (performance.now() - this.startTime) / 1000; - } - } - - toggle() { - if (this.paused) { - this.unpause(); - } else { - this.pause(); - } - } - - start() { - this.startTime = performance.now(); - this.paused = false; - } - - pause() { - this.pausedTime = performance.now() - this.startTime; - this.paused = true; - } - - unpause() { - this.startTime += performance.now() - this.startTime - this.pausedTime; - this.paused = false; - } - - reset() { - if (!this.paused) { - // Animation is running. - this.startTime = performance.now(); - } else { - this.startTime = 0; - } - this.pausedTime = 0; - } - - setFixedTime(timeInSec) { - this.paused = false; - this.fixedTime = timeInSec; - } -} - export { jsToGl, jsToGlSlice, From c77a5682d60011c2b5884941971873faafba57f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 28 Oct 2025 12:19:09 +0100 Subject: [PATCH 64/82] Apply KHR_node_visibility to lights --- source/Renderer/renderer.js | 10 ++-------- source/gltf/animation.js | 7 +++++++ source/gltf/utils.js | 1 - 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index bdf0c2aa..874f6031 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -506,7 +506,7 @@ class gltfRenderer { this.viewMatrix = currentCamera.getViewMatrix(state.gltf); this.currentCameraPosition = currentCamera.getPosition(state.gltf); - this.visibleLights = this.getVisibleLights(state.gltf, scene.nodes); + this.visibleLights = this.getVisibleLights(state.gltf, this.nodes); if ( this.visibleLights.length === 0 && !state.renderingParameters.useIBL && @@ -1368,13 +1368,7 @@ class gltfRenderer { getVisibleLights(gltf, nodes) { let nodeLights = []; - for (const nodeIndex of nodes) { - const node = gltf.nodes[nodeIndex]; - - if (node.children !== undefined) { - nodeLights = nodeLights.concat(this.getVisibleLights(gltf, node.children)); - } - + for (const node of nodes) { const lightIndex = node.extensions?.KHR_lights_punctual?.light; if (lightIndex === undefined) { continue; diff --git a/source/gltf/animation.js b/source/gltf/animation.js index ee83447b..4254c062 100644 --- a/source/gltf/animation.js +++ b/source/gltf/animation.js @@ -172,6 +172,13 @@ class gltfAnimation extends GltfObject { if (property != null) { let jsonPointer = JsonPointer.create(property); let parentObject = jsonPointer.parent(gltf); + if (parentObject === undefined) { + if (!this.errors.includes(property)) { + console.warn(`Cannot find property ${property}`); + this.errors.push(property); + } + continue; + } let back = jsonPointer.path.at(-1); let animatedArrayElement = undefined; if (Array.isArray(parentObject)) { diff --git a/source/gltf/utils.js b/source/gltf/utils.js index 1923d28a..e8232c84 100644 --- a/source/gltf/utils.js +++ b/source/gltf/utils.js @@ -183,6 +183,5 @@ export { combinePaths, UniformStruct, Timer, - AnimationTimer, initGlForMembers }; From 3f05d6b103caf4f620e5fb2a0fa8d67d02410629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 28 Oct 2025 12:32:19 +0100 Subject: [PATCH 65/82] Add prettier ignore for readme --- .prettierignore | 3 ++- README.md | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.prettierignore b/.prettierignore index 26a3c207..4a599ffe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ -source/libs \ No newline at end of file +source/libs +**/*.md diff --git a/README.md b/README.md index 17a1d7ad..eac944d2 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,20 @@ Try out the [glTF Sample Viewer](https://github.khronos.org/glTF-Sample-Viewer-R ## Table of Contents - [Khronos glTF Sample Renderer](#khronos-gltf-sample-renderer) - - [Table of Contents](#table-of-contents) - - [Credits](#credits) - - [Features](#features) - - [API](#api) - - [GltfView](#gltfview) - - [GltfState](#gltfstate) - - [GraphController](#graphcontroller) - - [AnimationTimer](#animationtimer) - - [ResourceLoader](#resourceloader) - - [Render Fidelity Tools](#render-fidelity-tools) - - [Development](#development) - - [Formatting](#formatting) - - [Visual Studio Code](#visual-studio-code) - - [Testing](#testing) + - [Table of Contents](#table-of-contents) + - [Credits](#credits) + - [Features](#features) + - [API](#api) + - [GltfView](#gltfview) + - [GltfState](#gltfstate) + - [GraphController](#graphcontroller) + - [AnimationTimer](#animationtimer) + - [ResourceLoader](#resourceloader) + - [Render Fidelity Tools](#render-fidelity-tools) + - [Development](#development) + - [Formatting](#formatting) + - [Visual Studio Code](#visual-studio-code) + - [Testing](#testing) ## Credits @@ -97,7 +97,7 @@ window.requestAnimationFrame(update); ### GltfState -The GltfState encapsulates the state of the content of a GltfView. _As currently some WebGL resources are stored directly in the Gltf objects, the state cannot be shared between views._ +The GltfState encapsulates the state of the content of a GltfView. *As currently some WebGL resources are stored directly in the Gltf objects, the state cannot be shared between views.* ```js const state = view.createState(); From e057f8c0a0b3693718c544992912866134372318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 28 Oct 2025 15:34:52 +0100 Subject: [PATCH 66/82] Update engine npm name --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 17c47c47..f3c77c40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.1.0", "license": "Apache-2.0", "dependencies": { - "@khronosgroup/khr-interactivity-authoring-engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", + "@khronosgroup/gltf-interactivity-sample-engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", "fast-png": "^6.2.0", "gl-matrix": "^3.2.1", "globals": "^15.5.0", @@ -35,7 +35,7 @@ } }, "../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine": { - "name": "@khronosgroup/khr-interactivity-authoring-engine", + "name": "@khronosgroup/gltf-interactivity-sample-engine", "version": "0.1.0", "license": "Apache-2.0", "dependencies": { @@ -341,7 +341,7 @@ "node": ">=v12.0.0" } }, - "node_modules/@khronosgroup/khr-interactivity-authoring-engine": { + "node_modules/@khronosgroup/gltf-interactivity-sample-engine": { "resolved": "../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", "link": true }, @@ -4877,7 +4877,7 @@ "lodash": "^4.17.21" } }, - "@khronosgroup/khr-interactivity-authoring-engine": { + "@khronosgroup/gltf-interactivity-sample-engine": { "version": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", "requires": { "@types/node": "^24.1.0", diff --git a/package.json b/package.json index b617476f..bf8f40c3 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "author": "Khronos Group Inc.", "license": "Apache-2.0", "dependencies": { - "@khronosgroup/khr-interactivity-authoring-engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", + "@khronosgroup/gltf-interactivity-sample-engine": "file:../../glTF-InteractivityGraph-AuthoringTool/src/BasicBehaveEngine", "fast-png": "^6.2.0", "gl-matrix": "^3.2.1", "globals": "^15.5.0", From cc81fecbb3456544402a20c3f9ecaa00ba35e237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 28 Oct 2025 15:35:34 +0100 Subject: [PATCH 67/82] Fix projection matrix calculation check --- source/Renderer/renderer.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index 874f6031..ac8f0203 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -625,7 +625,11 @@ class gltfRenderer { (state.enableHover || needsHover) && pickingX !== undefined && pickingY !== undefined; if (doHover) { - if (pickingProjection === undefined) { + if ( + pickingProjection === undefined || + pickingX !== state.selectionPositions[0].x || + pickingY !== state.selectionPositions[0].y + ) { pickingProjection = currentCamera.getProjectionMatrixForPixel( pickingX - aspectOffsetX, this.currentHeight - pickingY - aspectOffsetY, From 7ceceba6fe4f03c841ef416320c2ba343c3d9ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 29 Oct 2025 11:24:33 +0100 Subject: [PATCH 68/82] Fix import --- source/gltf/interactivity.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 64443a1d..318a8494 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -1,5 +1,5 @@ import { GltfObject } from "./gltf_object"; -import * as interactivity from "@khronosgroup/khr-interactivity-authoring-engine"; +import * as interactivity from "@khronosgroup/gltf-interactivity-sample-engine"; class gltfGraph extends GltfObject { static animatedProperties = []; From cd9936d67d76539b11b2db831209b4d5d10bea3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 29 Oct 2025 11:26:34 +0100 Subject: [PATCH 69/82] Add min max check for animations --- source/gltf/animation.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/source/gltf/animation.js b/source/gltf/animation.js index 4254c062..3e205a06 100644 --- a/source/gltf/animation.js +++ b/source/gltf/animation.js @@ -88,6 +88,11 @@ class gltfAnimation extends GltfObject { } } } + if (this.minTime > this.maxTime || this.minTime < 0 || this.maxTime < 0) { + console.error("Invalid min/max time for animation with index:", this.gltfObjectIndex); + this.minTime = undefined; + this.maxTime = undefined; + } } // advance the animation, if totalTime is undefined, the animation is deactivated @@ -107,6 +112,7 @@ class gltfAnimation extends GltfObject { let elapsedTime = totalTime; let reverse = false; + // createdTimestamp is only used for KHR_interactivity if (this.createdTimestamp !== undefined) { elapsedTime = totalTime - this.createdTimestamp; elapsedTime *= this.speed; From 990dd2bbff218f8d662e4de9d132707ab20738d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 29 Oct 2025 13:42:49 +0100 Subject: [PATCH 70/82] Recreate engine on gltf loading to prevent race condition --- source/gltf/interactivity.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 318a8494..5c233567 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -26,12 +26,13 @@ class gltfGraph extends GltfObject { class GraphController { constructor(fps = 60, debug = false) { this.fps = fps; + this.debug = debug; this.graphIndex = undefined; this.playing = false; this.customEvents = []; this.eventBus = new interactivity.DOMEventBus(); this.engine = new interactivity.BasicBehaveEngine(this.fps, this.eventBus); - this.decorator = new SampleViewerDecorator(this.engine, debug); + this.decorator = new SampleViewerDecorator(this.engine, this.debug); } needsHover() { @@ -68,9 +69,13 @@ class GraphController { * @param {GltfState} state - The state of the application. */ initializeGraphs(state) { + this.decorator.pauseEventQueue(); this.state = state; this.graphIndex = undefined; this.playing = false; + this.eventBus = new interactivity.DOMEventBus(); + this.engine = new interactivity.BasicBehaveEngine(this.fps, this.eventBus); + this.decorator = new SampleViewerDecorator(this.engine, this.debug); this.decorator.setState(state); this.engine.clearEventList(); this.engine.clearPointerInterpolation(); @@ -269,6 +274,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { } resetGraph() { + this.pauseEventQueue(); this.behaveEngine.loadBehaveGraph({ nodes: [], types: [], @@ -288,12 +294,12 @@ class SampleViewerDecorator extends interactivity.ADecorator { } parent.animatedPropertyObjects[propertyName].rest(); }; - this.recurseAllAnimatedProperties(this.world.gltf, resetAnimatedProperty); this.behaveEngine.clearEventList(); this.behaveEngine.clearPointerInterpolation(); this.behaveEngine.clearVariableInterpolation(); this.behaveEngine.clearScheduledDelays(); this.behaveEngine.clearValueEvaluationCache(); + this.recurseAllAnimatedProperties(this.world.gltf, resetAnimatedProperty); } processNodeStarted(node) { From f2d615edf480c976916692eb9b5375a07c147aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Wed, 29 Oct 2025 15:32:31 +0100 Subject: [PATCH 71/82] Reapply event listeners --- API.md | 2 ++ source/gltf/interactivity.js | 2 ++ tests/interactivityTests.spec.ts | 24 ++++++++++++------------ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/API.md b/API.md index 34446fe6..fe9a474d 100644 --- a/API.md +++ b/API.md @@ -1287,6 +1287,7 @@ A controller for managing KHR_interactivity graphs in a glTF scene. ### graphController.initializeGraphs(state) Initialize the graph controller with the given state. This needs to be called every time a glTF assets is loaded. +Event listeners are cleared. **Kind**: instance method of [GraphController](#GraphController) @@ -1347,6 +1348,7 @@ Dispatches an event to the behavior engine. ### graphController.addCustomEventListener(eventName, callback) Adds a custom event listener to the decorator. Khronos test assets use test/onStart, test/onFail and test/onSuccess. +Needs to be called after initializeGraphs. **Kind**: instance method of [GraphController](#GraphController) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 5c233567..6d605fab 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -66,6 +66,7 @@ class GraphController { /** * Initialize the graph controller with the given state. * This needs to be called every time a glTF assets is loaded. + * Event listeners are cleared. * @param {GltfState} state - The state of the application. */ initializeGraphs(state) { @@ -163,6 +164,7 @@ class GraphController { /** * Adds a custom event listener to the decorator. * Khronos test assets use test/onStart, test/onFail and test/onSuccess. + * Needs to be called after initializeGraphs. * @param {string} eventName * @param {function(CustomEvent)} callback */ diff --git a/tests/interactivityTests.spec.ts b/tests/interactivityTests.spec.ts index ff315fad..3de3aa42 100644 --- a/tests/interactivityTests.spec.ts +++ b/tests/interactivityTests.spec.ts @@ -57,21 +57,21 @@ for (const dir of directories) { const defaultScene = state.gltf.scene; state.sceneIndex = defaultScene === undefined ? 0 : defaultScene; state.cameraNodeIndex = undefined; - state.graphController.addCustomEventListener("test/onStart", (event) => { - window.passTestData(event.detail.expectedDuration); - window.TEST_TIME = event.detail.expectedDuration; - }); - state.graphController.addCustomEventListener("test/onSuccess", () => { - window.passTestData(true); - window.TEST_RESULT = true; - }); - state.graphController.addCustomEventListener("test/onFailed", () => { - window.passTestData(false); - window.TEST_RESULT = false; - }); state.animationTimer.start(); if (state.gltf?.extensions?.KHR_interactivity?.graphs !== undefined) { state.graphController.initializeGraphs(state); + state.graphController.addCustomEventListener("test/onStart", (event) => { + window.passTestData(event.detail.expectedDuration); + window.TEST_TIME = event.detail.expectedDuration; + }); + state.graphController.addCustomEventListener("test/onSuccess", () => { + window.passTestData(true); + window.TEST_RESULT = true; + }); + state.graphController.addCustomEventListener("test/onFailed", () => { + window.passTestData(false); + window.TEST_RESULT = false; + }); const graphIndex = state.gltf.extensions.KHR_interactivity.graph ?? 0; state.graphController.loadGraph(graphIndex); state.graphController.resumeGraph(); From 158b434d896088e20f33ba53c965f5c480181ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 30 Oct 2025 11:21:45 +0100 Subject: [PATCH 72/82] Revert "Reapply event listeners" This reverts commit f2d615edf480c976916692eb9b5375a07c147aa9. --- API.md | 2 -- source/gltf/interactivity.js | 2 -- tests/interactivityTests.spec.ts | 24 ++++++++++++------------ 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/API.md b/API.md index fe9a474d..34446fe6 100644 --- a/API.md +++ b/API.md @@ -1287,7 +1287,6 @@ A controller for managing KHR_interactivity graphs in a glTF scene. ### graphController.initializeGraphs(state) Initialize the graph controller with the given state. This needs to be called every time a glTF assets is loaded. -Event listeners are cleared. **Kind**: instance method of [GraphController](#GraphController) @@ -1348,7 +1347,6 @@ Dispatches an event to the behavior engine. ### graphController.addCustomEventListener(eventName, callback) Adds a custom event listener to the decorator. Khronos test assets use test/onStart, test/onFail and test/onSuccess. -Needs to be called after initializeGraphs. **Kind**: instance method of [GraphController](#GraphController) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 6d605fab..5c233567 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -66,7 +66,6 @@ class GraphController { /** * Initialize the graph controller with the given state. * This needs to be called every time a glTF assets is loaded. - * Event listeners are cleared. * @param {GltfState} state - The state of the application. */ initializeGraphs(state) { @@ -164,7 +163,6 @@ class GraphController { /** * Adds a custom event listener to the decorator. * Khronos test assets use test/onStart, test/onFail and test/onSuccess. - * Needs to be called after initializeGraphs. * @param {string} eventName * @param {function(CustomEvent)} callback */ diff --git a/tests/interactivityTests.spec.ts b/tests/interactivityTests.spec.ts index 3de3aa42..ff315fad 100644 --- a/tests/interactivityTests.spec.ts +++ b/tests/interactivityTests.spec.ts @@ -57,21 +57,21 @@ for (const dir of directories) { const defaultScene = state.gltf.scene; state.sceneIndex = defaultScene === undefined ? 0 : defaultScene; state.cameraNodeIndex = undefined; + state.graphController.addCustomEventListener("test/onStart", (event) => { + window.passTestData(event.detail.expectedDuration); + window.TEST_TIME = event.detail.expectedDuration; + }); + state.graphController.addCustomEventListener("test/onSuccess", () => { + window.passTestData(true); + window.TEST_RESULT = true; + }); + state.graphController.addCustomEventListener("test/onFailed", () => { + window.passTestData(false); + window.TEST_RESULT = false; + }); state.animationTimer.start(); if (state.gltf?.extensions?.KHR_interactivity?.graphs !== undefined) { state.graphController.initializeGraphs(state); - state.graphController.addCustomEventListener("test/onStart", (event) => { - window.passTestData(event.detail.expectedDuration); - window.TEST_TIME = event.detail.expectedDuration; - }); - state.graphController.addCustomEventListener("test/onSuccess", () => { - window.passTestData(true); - window.TEST_RESULT = true; - }); - state.graphController.addCustomEventListener("test/onFailed", () => { - window.passTestData(false); - window.TEST_RESULT = false; - }); const graphIndex = state.gltf.extensions.KHR_interactivity.graph ?? 0; state.graphController.loadGraph(graphIndex); state.graphController.resumeGraph(); From b3299adf97569b09e85545434c709b166d50ba6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 30 Oct 2025 11:29:37 +0100 Subject: [PATCH 73/82] Automatically resubscribe --- source/gltf/interactivity.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 5c233567..044774cb 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -33,6 +33,7 @@ class GraphController { this.eventBus = new interactivity.DOMEventBus(); this.engine = new interactivity.BasicBehaveEngine(this.fps, this.eventBus); this.decorator = new SampleViewerDecorator(this.engine, this.debug); + this.eventSubscriptions = new Map(); } needsHover() { @@ -70,18 +71,20 @@ class GraphController { */ initializeGraphs(state) { this.decorator.pauseEventQueue(); + this.engine.clearEventList(); + this.engine.clearPointerInterpolation(); + this.engine.clearVariableInterpolation(); + this.engine.clearScheduledDelays(); + this.engine.clearValueEvaluationCache(); this.state = state; this.graphIndex = undefined; - this.playing = false; this.eventBus = new interactivity.DOMEventBus(); this.engine = new interactivity.BasicBehaveEngine(this.fps, this.eventBus); this.decorator = new SampleViewerDecorator(this.engine, this.debug); this.decorator.setState(state); - this.engine.clearEventList(); - this.engine.clearPointerInterpolation(); - this.engine.clearVariableInterpolation(); - this.engine.clearScheduledDelays(); - this.engine.clearValueEvaluationCache(); + for (const [eventName, callback] of this.eventSubscriptions) { + this.decorator.addCustomEventListener(eventName, callback); + } } /** @@ -167,6 +170,7 @@ class GraphController { * @param {function(CustomEvent)} callback */ addCustomEventListener(eventName, callback) { + this.eventSubscriptions.set(eventName, callback); this.decorator.addCustomEventListener(eventName, callback); } @@ -174,6 +178,7 @@ class GraphController { * Clears all custom event listeners from the decorator. */ clearCustomEventListeners() { + this.eventSubscriptions.clear(); this.decorator.clearCustomEventListeners(); } } From 7e5457e0578c907ba8d63a8bd4a843f7b8594be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 30 Oct 2025 11:34:03 +0100 Subject: [PATCH 74/82] Stop playing on init --- source/gltf/interactivity.js | 1 + 1 file changed, 1 insertion(+) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 044774cb..01decc2e 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -77,6 +77,7 @@ class GraphController { this.engine.clearScheduledDelays(); this.engine.clearValueEvaluationCache(); this.state = state; + this.playing = false; this.graphIndex = undefined; this.eventBus = new interactivity.DOMEventBus(); this.engine = new interactivity.BasicBehaveEngine(this.fps, this.eventBus); From 8bf6ee7c527641ce76929ce02ee2afe4aabac86e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 30 Oct 2025 15:19:08 +0100 Subject: [PATCH 75/82] Add more code comments --- API.md | 4 +-- README.md | 4 +-- source/GltfState/gltf_state.js | 4 +-- source/Renderer/renderer.js | 41 +++++++++++++++++++++------- source/gltf/animation.js | 9 +++++++ source/gltf/interactivity.js | 49 +++++++++++++++++++++++++++++----- source/gltf/interpolator.js | 1 + 7 files changed, 91 insertions(+), 21 deletions(-) diff --git a/API.md b/API.md index 34446fe6..f0d56285 100644 --- a/API.md +++ b/API.md @@ -288,13 +288,13 @@ callback for hovering: (hoverInfo : { node, controller }) => {} ### gltfState.triggerSelection -If the renderer should compute selection in the next frame. Is automatically reset after the frame is rendered +If the renderer should compute selection information in the next frame. Is automatically reset after the frame is rendered **Kind**: instance property of [GltfState](#GltfState) ### gltfState.enableHover -If the renderer should compute hovering in the next frame. +If the renderer should compute hover information in the next frame. **Kind**: instance property of [GltfState](#GltfState) diff --git a/README.md b/README.md index eac944d2..c6c6c457 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,9 @@ The state is passed to the `view.renderFrame` function to specify the content th The GltfState contains an instance of the GraphController which can be used to load and execute `KHR_interactivity` graphs. One can also send custom events to the graph or subscribe to custom event via callbacks. -In the GltfState you can define an array of selection and hover points. Each element of the array represents one controller. If `triggerSelection` is set to `true`, the render will return the picking result of the clicked position via `selectionCallback`. The interactivity engine will be notified as well, if `KHR_node_selectability` is used in the current glTF. +In the GltfState you can define an array of selection and hover points. Each element of the array represents one controller. If `triggerSelection` is set to `true`, the renderer will return the picking result of the clicked position via `selectionCallback`. The interactivity engine will be notified as well, if `KHR_node_selectability` is used in the current glTF. -If `enableHover` is set to `true`, the render will return the picking result of the hovered position via `hoverCallback`. The interactivity engine receives hover results independent of `enableHover` based on the `hoverPositions` array. `enableHover` enables the use of custom hover handling independent of `KHR_interactivity` and is set to `false` by default. +If `enableHover` is set to `true`, the renderer will return the picking result of the hovered position via `hoverCallback`. The interactivity engine receives hover results independent of `enableHover` based on the `hoverPositions` array. `enableHover` enables the use of custom hover handling independent of `KHR_interactivity` and is set to `false` by default. To make sure that `KHR_interactivity` always behaves correctly together with `KHR_node_selectability` and `KHR_node_hoverability`, update the values in the `hoverPositions` and `selectionPositions` arrays and trigger selections via `triggerSelection`. Currently, only one controller is supported. All entries except the first one of each array are ignored. Arrays are used to enable multiple controllers in the future without breaking the API. diff --git a/source/GltfState/gltf_state.js b/source/GltfState/gltf_state.js index 7f8dae83..696ea300 100644 --- a/source/GltfState/gltf_state.js +++ b/source/GltfState/gltf_state.js @@ -45,9 +45,9 @@ class GltfState { /** callback for hovering: (hoverInfo : { node, controller }) => {} */ this.hoverCallback = undefined; - /** If the renderer should compute selection in the next frame. Is automatically reset after the frame is rendered */ + /** If the renderer should compute selection information in the next frame. Is automatically reset after the frame is rendered */ this.triggerSelection = false; - /** If the renderer should compute hovering in the next frame. */ + /** If the renderer should compute hover information in the next frame. */ this.enableHover = false; /* Array of screen positions for selection. Currently only one is supported. */ diff --git a/source/Renderer/renderer.js b/source/Renderer/renderer.js index ac8f0203..4c41ccdf 100644 --- a/source/Renderer/renderer.js +++ b/source/Renderer/renderer.js @@ -352,8 +352,8 @@ class gltfRenderer { this.selectionDrawables = newNodes.selectableNodes .filter((node) => node.mesh !== undefined) .reduce( - (acc, node) => - acc.concat( + (accumulator, node) => + accumulator.concat( state.gltf.meshes[node.mesh].primitives.map((primitive, index) => { return { node: node, primitive: primitive, primitiveIndex: index }; }) @@ -363,8 +363,8 @@ class gltfRenderer { this.hoverDrawables = newNodes.hoverableNodes .filter((node) => node.mesh !== undefined) .reduce( - (acc, node) => - acc.concat( + (accumulator, node) => + accumulator.concat( state.gltf.meshes[node.mesh].primitives.map((primitive, index) => { return { node: node, primitive: primitive, primitiveIndex: index }; }) @@ -372,6 +372,7 @@ class gltfRenderer { [] ); + // check if nodes have changed since previous frame to avoid unnecessary updates if ( newNodes.nodes.length === this.nodes?.length && newNodes.nodes.every((element, i) => element === this.nodes[i]) @@ -385,8 +386,8 @@ class gltfRenderer { const drawables = this.nodes .filter((node) => node.mesh !== undefined) .reduce( - (acc, node) => - acc.concat( + (accumulator, node) => + accumulator.concat( state.gltf.meshes[node.mesh].primitives.map((primitive, index) => { return { node: node, primitive: primitive, primitiveIndex: index }; }) @@ -590,6 +591,7 @@ class gltfRenderer { let pickingX = state.selectionPositions[0].x; let pickingY = state.selectionPositions[0].y; + // Draw a 1x1 texture for picking if (state.triggerSelection && pickingX !== undefined && pickingY !== undefined) { pickingProjection = currentCamera.getProjectionMatrixForPixel( pickingX - aspectOffsetX, @@ -621,10 +623,12 @@ class gltfRenderer { pickingY = state.hoverPositions[0].y; const needsHover = state.graphController.needsHover(); - const doHover = + const calcHoverInfo = (state.enableHover || needsHover) && pickingX !== undefined && pickingY !== undefined; - if (doHover) { + // Draw a 1x1 texture for hover + if (calcHoverInfo) { + // We do not need to recalculate the picking projection matrix if selection and hover use the same position if ( pickingProjection === undefined || pickingX !== state.selectionPositions[0].x || @@ -846,6 +850,8 @@ class gltfRenderer { this.webGl.context.viewport(0, 0, 1, 1); state.triggerSelection = false; this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT0); + + // Read pixel under controller (e.g. mouse cursor), which contains the picking ID const pixels = new Uint32Array(1); this.webGl.context.readPixels( 0, @@ -857,6 +863,7 @@ class gltfRenderer { pixels ); + // Compute ray origin in world space. This is the near plane position of the current pixel. pickingX = state.selectionPositions[0].x; pickingY = state.selectionPositions[0].y; const x = pickingX - aspectOffsetX; @@ -880,6 +887,7 @@ class gltfRenderer { controller: 0 }; + // Search for node with matching picking ID let found = false; for (const node of state.gltf.nodes) { if (node.pickingColor === pixels[0]) { @@ -889,6 +897,7 @@ class gltfRenderer { } } + // If a node was found, we need to calculate the ray intersection position if (found) { // WebGL does not allow reading from depth buffer this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT1); @@ -902,13 +911,19 @@ class gltfRenderer { this.webGl.context.UNSIGNED_INT, position ); + + // Transform uint to float [-1, 1] in clip space const z = (position[0] / 4294967295) * 2.0 - 1.0; + + // Get view space position const clipSpacePosition = vec4.fromValues(0, 0, z, 1); vec4.transformMat4( clipSpacePosition, clipSpacePosition, mat4.invert(mat4.create(), pickingProjection) ); + + // Divide by w to get normalized device coordinates vec4.divide( clipSpacePosition, clipSpacePosition, @@ -927,6 +942,7 @@ class gltfRenderer { pickingResult.position = vec3.fromValues(worldPos[0], worldPos[1], worldPos[2]); } + // Send picking result to Interactivity engine state.graphController.receiveSelection(pickingResult); if (state.selectionCallback) { @@ -934,13 +950,15 @@ class gltfRenderer { } } - if (doHover) { + if (calcHoverInfo) { this.webGl.context.bindFramebuffer( this.webGl.context.FRAMEBUFFER, this.hoverFramebuffer ); this.webGl.context.viewport(0, 0, 1, 1); this.webGl.context.readBuffer(this.webGl.context.COLOR_ATTACHMENT0); + + // Read pixel under controller (e.g. mouse cursor), which contains the picking ID const pixels = new Uint32Array(1); this.webGl.context.readPixels( 0, @@ -956,13 +974,18 @@ class gltfRenderer { node: undefined, controller: 0 }; + + // Search for node with matching picking ID for (const node of state.gltf.nodes) { if (node.pickingColor === pixels[0]) { pickingResult.node = node; break; } } + + // Send picking result to Interactivity engine state.graphController.receiveHover(pickingResult); + if (state.enableHover && state.hoverCallback) { state.hoverCallback(pickingResult); } diff --git a/source/gltf/animation.js b/source/gltf/animation.js index 3e205a06..ff225aff 100644 --- a/source/gltf/animation.js +++ b/source/gltf/animation.js @@ -125,6 +125,7 @@ class gltfAnimation extends GltfObject { elapsedTime = this.startTime; endAnimation = true; } else if (this.stopTime !== undefined) { + // Check if stopTime is reached if ( (this.startTime < this.endTime && elapsedTime >= this.stopTime && @@ -139,6 +140,7 @@ class gltfAnimation extends GltfObject { stopAnimation = true; } } else if ( + // Check if endTime is reached (this.startTime < this.endTime && elapsedTime >= this.endTime) || (this.startTime > this.endTime && elapsedTime <= this.endTime) ) { @@ -175,6 +177,7 @@ class gltfAnimation extends GltfObject { break; } + // Search for the animated property if (property != null) { let jsonPointer = JsonPointer.create(property); let parentObject = jsonPointer.parent(gltf); @@ -187,12 +190,15 @@ class gltfAnimation extends GltfObject { } let back = jsonPointer.path.at(-1); let animatedArrayElement = undefined; + + // Check if we are animating an array element e.g. weights if (Array.isArray(parentObject)) { animatedArrayElement = Number(back); jsonPointer = JsonPointer.create(jsonPointer.path.slice(0, -1)); parentObject = jsonPointer.parent(gltf); back = jsonPointer.path.at(-1); } + let animatedProperty = undefined; if ( parentObject.animatedPropertyObjects && @@ -210,6 +216,8 @@ class gltfAnimation extends GltfObject { } continue; } + + // glTF value is not defined and does not have a default value if (animatedProperty.restValue === undefined) { continue; } @@ -257,6 +265,7 @@ class gltfAnimation extends GltfObject { } } + // Handle end/stop of animation in interactivity if (stopAnimation) { this.createdTimestamp = undefined; this.stopCallback?.(); diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 01decc2e..a56db75a 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -213,10 +213,12 @@ class SampleViewerDecorator extends interactivity.ADecorator { } dispatchCustomEvent(eventName, data) { + // KHR_INTERACTIVITY prefix is used in the interactivity engine this.behaveEngine.dispatchCustomEvent(`KHR_INTERACTIVITY:${eventName}`, data); } addCustomEventListener(eventName, callback) { + // KHR_INTERACTIVITY prefix is used in the interactivity engine this.behaveEngine.addCustomEventListener(`KHR_INTERACTIVITY:${eventName}`, callback); } @@ -294,17 +296,19 @@ class SampleViewerDecorator extends interactivity.ADecorator { for (const animation of this.world.gltf.animations) { animation.reset(); } + + this.behaveEngine.clearEventList(); + this.behaveEngine.clearPointerInterpolation(); + this.behaveEngine.clearVariableInterpolation(); + this.behaveEngine.clearScheduledDelays(); + this.behaveEngine.clearValueEvaluationCache(); + const resetAnimatedProperty = (path, propertyName, parent, readOnly) => { if (readOnly) { return; } parent.animatedPropertyObjects[propertyName].rest(); }; - this.behaveEngine.clearEventList(); - this.behaveEngine.clearPointerInterpolation(); - this.behaveEngine.clearVariableInterpolation(); - this.behaveEngine.clearScheduledDelays(); - this.behaveEngine.clearValueEvaluationCache(); this.recurseAllAnimatedProperties(this.world.gltf, resetAnimatedProperty); } @@ -408,6 +412,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { value = value.flat(); } else { const width = parseInt(type.charAt(5)); + // The engine currently uses 2D Arrays for matrices currentNode = this.convertArrayToMatrix(currentNode, width); } } else if (type === "float2" || type === "float3" || type === "float4") { @@ -428,18 +433,24 @@ class SampleViewerDecorator extends interactivity.ADecorator { if (gltfObject === undefined || !(gltfObject instanceof GltfObject)) { return; } + + // Call for all animated properties of this gltfObject for (const property of gltfObject.constructor.animatedProperties) { if (gltfObject[property] === undefined) { continue; } callable(currentPath, property, gltfObject, false); } + + // Call for all read-only animated properties of this gltfObject for (const property of gltfObject.constructor.readOnlyAnimatedProperties) { if (gltfObject[property] === undefined) { continue; } callable(currentPath, property, gltfObject, true); } + + // Recurse into all GltfObject for (const key in gltfObject) { if (gltfObject[key] instanceof GltfObject) { this.recurseAllAnimatedProperties( @@ -460,6 +471,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { } } } + + // Recurse into all extensions for (const extensionName in gltfObject.extensions) { const extension = gltfObject.extensions[extensionName]; if (extension instanceof GltfObject) { @@ -474,7 +487,6 @@ class SampleViewerDecorator extends interactivity.ADecorator { registerKnownPointers() { // The engine is checking if a path is valid so we do not need to handle this here - if (this.world === undefined) { return; } @@ -486,6 +498,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { // All read-only number properties are currently integers type = "int"; } + + // If the property is an array, read-only pointers return the length of the array if (Array.isArray(parent[propertyName])) { jsonPtr += ".length"; type = "int"; @@ -505,6 +519,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { ); return; } + this.registerJsonPointer( jsonPtr, (path) => { @@ -523,9 +538,12 @@ class SampleViewerDecorator extends interactivity.ADecorator { ); return; } + if (type === undefined) { return; } + + // Register getter and setter for the property this.registerJsonPointer( jsonPtr, (path) => { @@ -547,6 +565,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { }; this.recurseAllAnimatedProperties(this.world.gltf, registerFunction); + // Special pointers that need to be handled manually + this.registerJsonPointer( `/extensions/KHR_lights_punctual/lights.length`, (_path) => { @@ -582,6 +602,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { "int", true ); + + // Returns the currently computed global matrix of the node this.registerJsonPointer( `/nodes/${nodeCount}/globalMatrix`, (path) => { @@ -595,6 +617,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { "float4x4", true ); + + // Returns the currently computed local matrix of the node this.registerJsonPointer( `/nodes/${nodeCount}/matrix`, (path) => { @@ -607,6 +631,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { "float4x4", true ); + + // Returns the parent node index of the node this.registerJsonPointer( `/nodes/${nodeCount}/parent`, (path) => { @@ -619,6 +645,9 @@ class SampleViewerDecorator extends interactivity.ADecorator { "int", true ); + + // Pointer to indices + this.registerJsonPointer( `/nodes/${nodeCount}/extensions/KHR_lights_punctual/light`, (path) => { @@ -651,6 +680,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { true ); + // Pointer for animation control + const animationCount = this.world.gltf.animations.length; this.registerJsonPointer( `/animations/${animationCount}/extensions/KHR_interactivity/isPlaying`, @@ -690,6 +721,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { "float", true ); + + // The playhead returns a number between 0 and maxTime this.registerJsonPointer( `/animations/${animationCount}/extensions/KHR_interactivity/playhead`, (path) => { @@ -705,6 +738,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { "float", true ); + + // The virtual playhead return the current time on the infinite timeline. Can be negative or larger than maxTime this.registerJsonPointer( `/animations/${animationCount}/extensions/KHR_interactivity/virtualPlayhead`, (path) => { @@ -721,6 +756,8 @@ class SampleViewerDecorator extends interactivity.ADecorator { true ); + // Pointer for the active camera + this.registerJsonPointer( `/extensions/KHR_interactivity/activeCamera/rotation`, (_path) => { diff --git a/source/gltf/interpolator.js b/source/gltf/interpolator.js index 2bce0d55..ada29c55 100644 --- a/source/gltf/interpolator.js +++ b/source/gltf/interpolator.js @@ -137,6 +137,7 @@ class gltfInterpolator { // Find next keyframe: min{ t of input | t > prevKey } let nextKey = null; + // We need to search backwards for reversed animations if (reverse) { for (let i = this.prevKey; i >= 0; --i) { if (t >= input[i]) { From bc532c55f5e6b5ce4ffbfc4b43248c1c84a20de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Thu, 30 Oct 2025 15:23:24 +0100 Subject: [PATCH 76/82] Move test in function --- tests/interactivityTests.spec.ts | 144 ++++++++++++++++--------------- 1 file changed, 74 insertions(+), 70 deletions(-) diff --git a/tests/interactivityTests.spec.ts b/tests/interactivityTests.spec.ts index ff315fad..c1cb1f12 100644 --- a/tests/interactivityTests.spec.ts +++ b/tests/interactivityTests.spec.ts @@ -35,75 +35,79 @@ for (const dir of directories) { const path = asset.path; const file = new Uint8Array(fs.readFileSync(path)); const testName = path.substring(path.lastIndexOf("/testAssetDownloads/") + 19); - test(`Testing asset ${testName}`, async ({ page }) => { - await page.goto(""); - let testDuration: number | undefined = undefined; - let testResult: boolean | undefined = undefined; - const fun = (input: number | boolean) => { - if (typeof input === "number") { - testDuration = input; - } else if (typeof input === "boolean") { - testResult = input; - } - }; - await page.exposeFunction("passTestData", fun); - let success = false; - try { - success = await page.evaluate(async (file) => { - const resourceLoader = window.resourceLoader as ResourceLoader; - const state = window.state as GltfState; - const glTF = await resourceLoader.loadGltf(file.buffer); - state.gltf = glTF; - const defaultScene = state.gltf.scene; - state.sceneIndex = defaultScene === undefined ? 0 : defaultScene; - state.cameraNodeIndex = undefined; - state.graphController.addCustomEventListener("test/onStart", (event) => { - window.passTestData(event.detail.expectedDuration); - window.TEST_TIME = event.detail.expectedDuration; - }); - state.graphController.addCustomEventListener("test/onSuccess", () => { - window.passTestData(true); - window.TEST_RESULT = true; - }); - state.graphController.addCustomEventListener("test/onFailed", () => { - window.passTestData(false); - window.TEST_RESULT = false; - }); - state.animationTimer.start(); - if (state.gltf?.extensions?.KHR_interactivity?.graphs !== undefined) { - state.graphController.initializeGraphs(state); - const graphIndex = state.gltf.extensions.KHR_interactivity.graph ?? 0; - state.graphController.loadGraph(graphIndex); - state.graphController.resumeGraph(); - } else { - state.graphController.stopGraphEngine(); - } - return true; - }, file); - } catch (error) { - console.log(await page.consoleMessages()); - throw error; - } - expect(success).toBeTruthy(); - await page.waitForFunction( - () => { - return window.TEST_TIME !== undefined; - }, - { timeout: 2000 } - ); - if (testDuration! > 0) { - console.log("Test duration (s): ", testDuration); - } - await page.waitForFunction( - () => { - return window.TEST_RESULT !== undefined; - }, - { timeout: testDuration! * 1000 + 1000 } - ); - if (testResult === false) { - console.log(await page.consoleMessages()); - } - expect(testResult).toBe(true); - }); + createInteractivityTest(testName, file); } } + +function createInteractivityTest(name: string, file: Uint8Array) { + test(`Testing asset ${name}`, async ({ page }) => { + await page.goto(""); + let testDuration: number | undefined = undefined; + let testResult: boolean | undefined = undefined; + const fun = (input: number | boolean) => { + if (typeof input === "number") { + testDuration = input; + } else if (typeof input === "boolean") { + testResult = input; + } + }; + await page.exposeFunction("passTestData", fun); + let success = false; + try { + success = await page.evaluate(async (file) => { + const resourceLoader = window.resourceLoader as ResourceLoader; + const state = window.state as GltfState; + const glTF = await resourceLoader.loadGltf(file.buffer); + state.gltf = glTF; + const defaultScene = state.gltf.scene; + state.sceneIndex = defaultScene === undefined ? 0 : defaultScene; + state.cameraNodeIndex = undefined; + state.graphController.addCustomEventListener("test/onStart", (event) => { + window.passTestData(event.detail.expectedDuration); + window.TEST_TIME = event.detail.expectedDuration; + }); + state.graphController.addCustomEventListener("test/onSuccess", () => { + window.passTestData(true); + window.TEST_RESULT = true; + }); + state.graphController.addCustomEventListener("test/onFailed", () => { + window.passTestData(false); + window.TEST_RESULT = false; + }); + state.animationTimer.start(); + if (state.gltf?.extensions?.KHR_interactivity?.graphs !== undefined) { + state.graphController.initializeGraphs(state); + const graphIndex = state.gltf.extensions.KHR_interactivity.graph ?? 0; + state.graphController.loadGraph(graphIndex); + state.graphController.resumeGraph(); + } else { + state.graphController.stopGraphEngine(); + } + return true; + }, file); + } catch (error) { + console.log(await page.consoleMessages()); + throw error; + } + expect(success).toBeTruthy(); + await page.waitForFunction( + () => { + return window.TEST_TIME !== undefined; + }, + { timeout: 2000 } + ); + if (testDuration! > 0) { + console.log("Test duration (s): ", testDuration); + } + await page.waitForFunction( + () => { + return window.TEST_RESULT !== undefined; + }, + { timeout: testDuration! * 1000 + 1000 } + ); + if (testResult === false) { + console.log(await page.consoleMessages()); + } + expect(testResult).toBe(true); + }); +} From 21d16104fbfe155f7692bc1d66f6276d8a0446e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Mon, 3 Nov 2025 12:35:31 +0100 Subject: [PATCH 77/82] Engine switched to 1D arrays for matricies --- source/gltf/interactivity.js | 44 ++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index a56db75a..adb248a0 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -359,24 +359,21 @@ class SampleViewerDecorator extends interactivity.ADecorator { case "float3": return [NaN, NaN, NaN]; case "float4": - return [NaN, NaN, NaN, NaN]; case "float2x2": - return [ - [NaN, NaN], - [NaN, NaN] - ]; + return [NaN, NaN, NaN, NaN]; case "float3x3": + // prettier-ignore return [ - [NaN, NaN, NaN], - [NaN, NaN, NaN], - [NaN, NaN, NaN] - ]; + NaN, NaN, NaN, + NaN, NaN, NaN, + NaN, NaN, NaN]; case "float4x4": + // prettier-ignore return [ - [NaN, NaN, NaN, NaN], - [NaN, NaN, NaN, NaN], - [NaN, NaN, NaN, NaN], - [NaN, NaN, NaN, NaN] + NaN, NaN, NaN, NaN, + NaN, NaN, NaN, NaN, + NaN, NaN, NaN, NaN, + NaN, NaN, NaN, NaN ]; } return undefined; @@ -407,15 +404,14 @@ class SampleViewerDecorator extends interactivity.ADecorator { return undefined; } } - if (type === "float2x2" || type === "float3x3" || type === "float4x4") { - if (value !== undefined) { - value = value.flat(); - } else { - const width = parseInt(type.charAt(5)); - // The engine currently uses 2D Arrays for matrices - currentNode = this.convertArrayToMatrix(currentNode, width); - } - } else if (type === "float2" || type === "float3" || type === "float4") { + if ( + type === "float2" || + type === "float3" || + type === "float4" || + type === "float2x2" || + type === "float3x3" || + type === "float4x4" + ) { if (value !== undefined) { value = value.slice(0); //clone array } else { @@ -611,7 +607,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { const nodeIndex = parseInt(pathParts[2]); const node = this.world.gltf.nodes[nodeIndex]; node.scene.applyTransformHierarchy(this.world.gltf); - return this.convertArrayToMatrix(node.worldTransform, 4); // gl-matrix uses column-major order + return node.worldTransform.slice(0); }, (_path, _value) => {}, "float4x4", @@ -625,7 +621,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { const pathParts = path.split("/"); const nodeIndex = parseInt(pathParts[2]); const node = this.world.gltf.nodes[nodeIndex]; - return this.convertArrayToMatrix(node.getLocalTransform(), 4); // gl-matrix uses column-major order + return node.getLocalTransform(); }, (_path, _value) => {}, "float4x4", From f8ec0323ede05723931c828c780916b1f88563b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Fri, 14 Nov 2025 10:48:51 +0100 Subject: [PATCH 78/82] Remove case fixes --- tests/downloadAssets.spec.ts | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/tests/downloadAssets.spec.ts b/tests/downloadAssets.spec.ts index 5547e9b8..0f3f1ba8 100644 --- a/tests/downloadAssets.spec.ts +++ b/tests/downloadAssets.spec.ts @@ -22,42 +22,6 @@ test("download assets", async ({ testRepoURL, downloadFolder }) => { const data = await response.json(); const parentUrl = testRepoURL.substring(0, testRepoURL.lastIndexOf("/")); for (const asset of data) { - if (asset.name === "math/E") { - asset.name = "math/e"; - asset.variants = { "glTF-Binary": "e.glb" }; - } - if (asset.name === "math/Inf") { - asset.name = "math/inf"; - asset.variants = { "glTF-Binary": "inf.glb" }; - } - if (asset.name === "math/isInf") { - asset.name = "math/isinf"; - asset.variants = { "glTF-Binary": "isinf.glb" }; - } - if (asset.name === "math/isNaN") { - asset.name = "math/isnan"; - asset.variants = { "glTF-Binary": "isnan.glb" }; - } - if (asset.name === "math/matMul") { - asset.name = "math/matmul"; - asset.variants = { "glTF-Binary": "matmul.glb" }; - } - if (asset.name === "math/NaN") { - asset.name = "math/nan"; - asset.variants = { "glTF-Binary": "nan.glb" }; - } - if (asset.name === "math/Pi") { - asset.name = "math/pi"; - asset.variants = { "glTF-Binary": "pi.glb" }; - } - if (asset.name === "math/rotate2D") { - asset.name = "math/rotate2d"; - asset.variants = { "glTF-Binary": "rotate2d.glb" }; - } - if (asset.name === "math/rotate3D") { - asset.name = "math/rotate3d"; - asset.variants = { "glTF-Binary": "rotate3d.glb" }; - } const path = `${asset.name}/glTF-Binary/${asset.variants?.["glTF-Binary"]}`; const assetResponse = await fetch(`${parentUrl}/${path}`); console.log(`Downloading ${parentUrl}/${path}`); From cd3c3b26098fc82ded69ce244f3de33517d5f3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 25 Nov 2025 13:34:00 +0100 Subject: [PATCH 79/82] Manually execute engine --- source/GltfView/gltf_view.js | 4 ++++ source/gltf/interactivity.js | 11 +++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/source/GltfView/gltf_view.js b/source/GltfView/gltf_view.js index 8990047b..6ed85c12 100644 --- a/source/GltfView/gltf_view.js +++ b/source/GltfView/gltf_view.js @@ -74,6 +74,10 @@ class GltfView { return; } + if (state.graphController?.playing) { + state.graphController.simulateTick(); + } + scene.applyTransformHierarchy(state.gltf); this.renderer.drawScene(state, scene); diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index adb248a0..6bd61f34 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -98,9 +98,6 @@ class GraphController { try { this.customEvents = this.decorator.loadGraph(graphIndex); this.graphIndex = graphIndex; - if (this.playing) { - this.decorator.playEventQueue(); - } } catch (error) { console.error("Error loading graph:", error); } @@ -138,7 +135,6 @@ class GraphController { if (this.graphIndex === undefined || this.playing) { return; } - this.decorator.playEventQueue(); this.playing = true; } @@ -152,6 +148,13 @@ class GraphController { this.loadGraph(this.graphIndex); } + simulateTick() { + if (this.graphIndex === undefined) { + return; + } + this.decorator.executeEventQueueTick(); + } + /** * Dispatches an event to the behavior engine. * @param {string} eventName From 94ac412267e979f2646271e2d458cfd09836dd31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Mon, 8 Dec 2025 12:19:18 +0100 Subject: [PATCH 80/82] Fix computeMinMaxTime --- source/gltf/interactivity.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 6bd61f34..2a0f503a 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -700,7 +700,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { const pathParts = path.split("/"); const animationIndex = parseInt(pathParts[2]); const animation = this.world.gltf.animations[animationIndex]; - animation.computeMinMaxTime(); + animation.computeMinMaxTime(this.world.gltf); return [animation.minTime]; }, (_path, _value) => {}, @@ -713,7 +713,7 @@ class SampleViewerDecorator extends interactivity.ADecorator { const pathParts = path.split("/"); const animationIndex = parseInt(pathParts[2]); const animation = this.world.gltf.animations[animationIndex]; - animation.computeMinMaxTime(); + animation.computeMinMaxTime(this.world.gltf); return [animation.maxTime]; }, (_path, _value) => {}, From 7edd156a701b5bf9f91737673ad89524f8b1b9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Mon, 8 Dec 2025 13:05:37 +0100 Subject: [PATCH 81/82] Fix animation end at maxTime --- source/gltf/interpolator.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/gltf/interpolator.js b/source/gltf/interpolator.js index ada29c55..56b9a115 100644 --- a/source/gltf/interpolator.js +++ b/source/gltf/interpolator.js @@ -119,8 +119,9 @@ class gltfInterpolator { // Wrap t around, so the animation loops. // Make sure that t is never earlier than the first keyframe and never later then the last keyframe. const isNegative = t < 0; + const isZero = t === 0; t = t % maxTime; - if (isNegative) { + if (isNegative || (t === 0 && !isZero)) { t += maxTime; } t = clamp(t, input[0], input[input.length - 1]); From f1fd436de137e98b3d419230d6d7ef4ff85ab8b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4rtl?= Date: Tue, 9 Dec 2025 09:54:14 +0100 Subject: [PATCH 82/82] Fix active camera pointers --- source/gltf/interactivity.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/source/gltf/interactivity.js b/source/gltf/interactivity.js index 2a0f503a..e1903435 100644 --- a/source/gltf/interactivity.js +++ b/source/gltf/interactivity.js @@ -773,8 +773,9 @@ class SampleViewerDecorator extends interactivity.ADecorator { return [NaN, NaN, NaN, NaN]; } activeCamera = this.world.gltf.cameras[cameraIndex]; + activeCamera.setNode(this.world.gltf, this.world.cameraNodeIndex); } - return activeCamera.getRotation().slice(0); + return activeCamera.getRotation(this.world.gltf).slice(0); }, (_path, _value) => { //no-op @@ -799,8 +800,9 @@ class SampleViewerDecorator extends interactivity.ADecorator { return [NaN, NaN, NaN]; } activeCamera = this.world.gltf.cameras[cameraIndex]; + activeCamera.setNode(this.world.gltf, this.world.cameraNodeIndex); } - return activeCamera.getPosition().slice(0); + return activeCamera.getPosition(this.world.gltf).slice(0); }, (_path, _value) => { //no-op