UNPKG

@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
// @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; }