@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
216 lines (193 loc) • 8.78 kB
JavaScript
// @ts-check
/**
* glTF node tree walker and NEEDLE_components extractor.
*
* Walks the parsed glTF JSON, sanitizes node names to match Three.js runtime
* behaviour, and extracts component data from the NEEDLE_components extension.
*/
const NEEDLE_COMPONENTS_EXTENSION = "NEEDLE_components";
/**
* Replicate Three.js GLTFLoader's sanitizeNodeName + createUniqueName logic.
* - Whitespace → `_`
* - Reserved chars `[ ] . : /` → removed
* - Duplicate names get `_1`, `_2`, … suffix
*
* @param {string} rawName
* @param {Record<string, number>} namesUsed Mutated in-place — pass the same object for all nodes in a GLB
* @returns {string}
*/
export function sanitizeNodeName(rawName, namesUsed) {
const sanitized = rawName.replace(/\s/g, "_").replace(/[\[\].:\/]/g, "");
if (sanitized in namesUsed) {
return sanitized + "_" + (++namesUsed[sanitized]);
}
namesUsed[sanitized] = 0;
return sanitized;
}
/**
* Infer the Three.js runtime type of a glTF node from its JSON properties.
* - `mesh` present with skinning → `import("three").SkinnedMesh`
* - `mesh` present → `import("three").Mesh`
* - `camera` present, perspective → `import("three").PerspectiveCamera`
* - `camera` present, orthographic→ `import("three").OrthographicCamera`
* - KHR_lights_punctual → `import("three").Light`
* - otherwise → `import("three").Object3D`
*
* @param {Record<string, unknown>} node
* @param {Array<Record<string, unknown>>} cameras Raw glTF cameras array
* @param {Array<Record<string, unknown>>} meshes Raw glTF meshes array
* @returns {string}
*/
export function inferNodeThreeType(node, cameras, meshes) {
if ("mesh" in node) {
const hasSkin = "skin" in node;
if (!hasSkin) {
const meshIdx = /** @type {number} */ (node.mesh);
const mesh = meshes[meshIdx];
const primitives = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(mesh?.primitives) ? mesh.primitives : []);
const isSkinned = primitives.some(p => {
const attrs = /** @type {Record<string, unknown>} */ (p?.attributes ?? {});
return "WEIGHTS_0" in attrs;
});
if (isSkinned) return `import("three").SkinnedMesh`;
} else {
return `import("three").SkinnedMesh`;
}
return `import("three").Mesh`;
}
if ("camera" in node) {
const camIdx = /** @type {number} */ (node.camera);
const cam = cameras[camIdx];
if (cam && typeof cam === "object") {
if (cam.type === "perspective") return `import("three").PerspectiveCamera`;
if (cam.type === "orthographic") return `import("three").OrthographicCamera`;
}
return `import("three").Camera`;
}
if (node.extensions && /** @type {any} */ (node.extensions)["KHR_lights_punctual"] != null) return `import("three").Light`;
return `import("three").Object3D`;
}
/**
* Infer a TypeScript type string from a raw JSON value.
* Only primitives are typed precisely; everything else → `unknown`.
*
* @param {unknown} value
* @returns {string}
*/
export function inferTsType(value) {
if (value === null || value === undefined) return "unknown";
switch (typeof value) {
case "number": return "number";
case "string": return "string";
case "boolean": return "boolean";
default: return "unknown";
}
}
/**
* Build a map of nodeIndex → sanitized name and nodeIndex → parent index
* from the glTF node tree, then compute the full path for each node.
*
* @param {Array<Record<string, unknown>>} nodes Raw glTF nodes array
* @param {Record<string, number>} namesUsed Already-used name registry (shared with extraction pass)
* @returns {{ nameMap: Map<number, string>, pathMap: Map<number, string> }}
*/
function buildNodePaths(nodes, namesUsed) {
/** @type {Map<number, string>} nodeIndex → sanitized name */
const nameMap = new Map();
/** @type {Map<number, number>} nodeIndex → parent nodeIndex */
const parentMap = new Map();
// First pass: sanitize all names
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (!node || typeof node !== "object") continue;
const rawName = typeof node.name === "string" ? node.name : "";
if (!rawName) continue;
nameMap.set(i, sanitizeNodeName(rawName, namesUsed));
}
// Second pass: build parent map from children arrays
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (!node || typeof node !== "object") continue;
const children = Array.isArray(node.children) ? node.children : [];
for (const childIdx of children) {
parentMap.set(childIdx, i);
}
}
// Third pass: compute full path per node by walking up the parent chain
/** @type {Map<number, string>} nodeIndex → full path string */
const pathMap = new Map();
for (const [idx] of nameMap) {
const parts = [];
/** @type {number | undefined} */
let cur = idx;
while (cur !== undefined && nameMap.has(cur)) {
parts.unshift(/** @type {string} */ (nameMap.get(cur)));
cur = parentMap.get(cur);
}
pathMap.set(idx, parts.join("/"));
}
return { nameMap, pathMap };
}
/**
* Walk the glTF JSON and collect every node name + any NEEDLE_components extension blocks.
* Node names are sanitized and de-duplicated to match Three.js GLTFLoader behaviour.
*
* Nodes without NEEDLE_components are returned with an empty componentName so that
* callers can decide whether to emit them.
*
* @param {Record<string, unknown>} json Parsed glTF JSON
* @returns {Array<{nodeName: string, nodePath: string, componentName: string, fields: Record<string, unknown>, nodeThreeType: string}>}
*/
export function extractComponentBindings(json) {
const nodes = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(json.nodes) ? json.nodes : []);
const cameras = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(json.cameras) ? json.cameras : []);
const meshes = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(json.meshes) ? json.meshes : []);
// Collect scene root node indices — these map to THREE.Scene at runtime
const scenes = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(json.scenes) ? json.scenes : []);
/** @type {Set<number>} */
const sceneRootNodeIndices = new Set();
for (const scene of scenes) {
const sceneNodes = Array.isArray(scene.nodes) ? /** @type {number[]} */ (scene.nodes) : [];
for (const idx of sceneNodes) sceneRootNodeIndices.add(idx);
}
/** @type {Array<{nodeName: string, nodePath: string, componentName: string, fields: Record<string, unknown>, nodeThreeType: string}>} */
const results = [];
/** @type {Record<string, number>} */
const namesUsed = {};
const { nameMap, pathMap } = buildNodePaths(nodes, namesUsed);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (!node || typeof node !== "object") continue;
const nodeName = nameMap.get(i);
if (!nodeName) continue;
const nodePath = pathMap.get(i) ?? nodeName;
const nodeThreeType = sceneRootNodeIndices.has(i)
? `import("three").Scene`
: inferNodeThreeType(/** @type {Record<string, unknown>} */ (node), cameras, meshes);
const ext = node.extensions?.[NEEDLE_COMPONENTS_EXTENSION];
if (!ext || typeof ext !== "object") {
results.push({ nodeName, nodePath, componentName: "", fields: {}, nodeThreeType });
continue;
}
const lists = [
...(Array.isArray(ext.components) ? ext.components : []),
...(Array.isArray(ext.builtin_components) ? ext.builtin_components : []),
];
const internalKeys = new Set([
"name", "type", "guid", "gameObject", "enabled",
"didAwake", "didStart", "transformHandle", "destroyCancellationToken",
]);
for (const comp of lists) {
if (!comp || typeof comp !== "object") continue;
const componentName = typeof comp.name === "string" ? comp.name.trim() : "";
if (!componentName) continue;
/** @type {Record<string, unknown>} */
const fields = {};
for (const [k, v] of Object.entries(comp)) {
if (!internalKeys.has(k)) fields[k] = v;
}
results.push({ nodeName, nodePath, componentName, fields, nodeThreeType });
}
}
return results;
}