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.

335 lines (299 loc) â€Ē 13 kB
// @ts-check /** * Pure string generators — no I/O. * * Takes `BindingEntry[]` from dts.scan.js and produces: * - `needle-bindings.gen.d.ts` (TypeScript ambient module augmentation) * - `needle-html-data.json` (VS Code HTML custom data for data-bind-needle completions) * * Each scene node is emitted as a named type alias so VS Code hover shows * the alias name + JSDoc summary rather than expanding the full object type: * * /** `Minimal/Cube` — MeshRenderer, BoxCollider *\/ * type $Minimal__Cube = { $object: THREE.Mesh; $components: { ... }; }; * * interface SceneData { * Minimal: { Minimal: { Cube: $Minimal__Minimal__Cube; }; }; }; * } */ /** @typedef {import('./dts.scan.js').BindingEntry} BindingEntry */ /** Append `?view` to Needle Cloud asset URLs so the link opens the viewer. @param {string} url @returns {string} */ function addViewParam(url) { if (!url.includes("cloud.needle.tools")) return url; return url.includes("?") ? `${url}&view` : `${url}?view`; } /** @param {string} name @returns {string} */ function propKey(name) { return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `"${name}"`; } /** * Convert a node path like "Minimal/Cube_1/Child Of Cube" to a safe type alias name. * @param {string} nodePath * @returns {string} */ function typeAliasName(nodePath) { const safe = nodePath.replace(/[^a-zA-Z0-9]/g, "_"); return `$${safe}`; } /** * @typedef {{ * threeType: string, * components: Map<string, { isEngineComponent: boolean, fields: Map<string, Set<string>> }>, * children: Map<string, TreeNode> * }} TreeNode */ /** @returns {TreeNode} */ function makeNode() { return { threeType: `import("three").Object3D`, components: new Map(), children: new Map() }; } /** * Build a tree from BindingEntry[]. Each entry's nodePath (e.g. "UI/Camera/Target") * defines where in the tree the node lives. * * @param {BindingEntry[]} entries * @returns {Map<string, TreeNode>} Root-level nodes */ function buildTree(entries) { /** @type {Map<string, TreeNode>} */ const roots = new Map(); for (const { nodePath, componentName, fieldTypes, isEngineComponent, nodeThreeType } of entries) { const parts = nodePath.split("/").filter(Boolean); if (parts.length === 0) continue; let map = roots; /** @type {TreeNode | null} */ let node = null; for (const part of parts) { if (!map.has(part)) map.set(part, makeNode()); node = /** @type {TreeNode} */ (map.get(part)); map = node.children; } if (!node) continue; node.threeType = nodeThreeType; if (componentName) { if (!node.components.has(componentName)) { node.components.set(componentName, { isEngineComponent, fields: new Map() }); } const comp = /** @type {{ isEngineComponent: boolean, fields: Map<string, Set<string>> }} */ (node.components.get(componentName)); for (const [field, tsType] of Object.entries(fieldTypes)) { if (!comp.fields.has(field)) comp.fields.set(field, new Set()); /** @type {Set<string>} */ (comp.fields.get(field)).add(tsType); } } } return roots; } /** * Compute the human-readable summary for a node (component names or Three.js type). * @param {TreeNode} node * @returns {string} */ function nodeSummary(node) { const threeShortType = node.threeType.replace(/import\("three"\)\./g, "THREE."); const compNames = Array.from(node.components.keys()).filter(n => n !== "").sort(); return compNames.length > 0 ? compNames.join(", ") : threeShortType; } /** * Recursively collect type alias declarations for all nodes in the tree. * Emits one `type $Alias = { ... }` per node, with JSDoc summary. * * @param {string} _key * @param {TreeNode} node * @param {string} nodePath Full path e.g. "Minimal/Cube" * @param {string[]} aliases Accumulator — lines pushed in bottom-up order */ function collectTypeAliases(_key, node, nodePath, aliases) { // Recurse into children first (bottom-up so aliases are defined before use) for (const [childKey, childNode] of Array.from(node.children.entries()).sort(([a], [b]) => a.localeCompare(b))) { collectTypeAliases(childKey, childNode, `${nodePath}/${childKey}`, aliases); } const threeShortType = node.threeType.replace(/import\("three"\)\./g, "THREE."); const compEntries = Array.from(node.components.entries()) .filter(([n]) => n !== "") .sort(([a], [b]) => a.localeCompare(b)); const summary = nodeSummary(node); const alias = typeAliasName(nodePath); // Emit as an interface so each child property can carry JSDoc const lines = []; lines.push(`/** \`${nodePath}\` — ${summary} */`); lines.push(`interface ${alias} {`); lines.push(` $object: ${threeShortType};`); if (compEntries.length > 0) { const compParts = compEntries.map(([compName, { isEngineComponent, fields }]) => { if (isEngineComponent) { return `${propKey(compName)}: NE.${compName}`; } else { const fieldParts = [`enabled: boolean`]; for (const [field, types] of Array.from(fields.entries()).sort(([a], [b]) => a.localeCompare(b))) { fieldParts.push(`${field}: ${Array.from(types).join(" | ")}`); } return `${propKey(compName)}: { ${fieldParts.join("; ")} }`; } }); lines.push(` /** Needle Engine components on this node. Access via \`getComponent()\`, \`addComponent()\`, or \`findObjectOfType()\` / \`findObjectsOfType()\`. */`); lines.push(` $components: { ${compParts.join("; ")} };`); } for (const [childKey, childNode] of Array.from(node.children.entries()).sort(([a], [b]) => a.localeCompare(b))) { const childAlias = typeAliasName(`${nodePath}/${childKey}`); const childSummary = nodeSummary(childNode); lines.push(` /** ${childSummary} */`); lines.push(` ${propKey(childKey)}: ${childAlias};`); } lines.push(`}`); aliases.push(lines.join("\n")); } /** * Recursively render a tree node as indented JSDoc lines. * @param {string} key * @param {TreeNode} node * @param {number} depth * @param {string[]} lines */ /** @param {string} threeType @returns {string} */ function nodeEmoji(threeType) { if (threeType.includes("Scene")) return "🎎"; if (threeType.includes("SkinnedMesh")) return "ðŸĶī"; if (threeType.includes("Mesh")) return "⊞"; if (threeType.includes("PerspectiveCamera") || threeType.includes("OrthographicCamera") || threeType.includes("Camera")) return "👁"; if (threeType.includes("Light")) return "ðŸ’Ą"; return ""; } function buildTreeDoc(key, node, depth, lines) { const indent = " ".repeat(depth); const hasComponents = Array.from(node.components.keys()).some(n => n !== ""); const summary = nodeSummary(node); const emoji = nodeEmoji(node.threeType); const prefix = emoji ? `${emoji} ` : ""; // Bold + components for nodes that have something actionable; plain name for empty containers const label = hasComponents ? `${prefix}**${key}** — ${summary}` : `${prefix}${key}`; lines.push(` * ${indent}- ${label}`); for (const [childKey, childNode] of Array.from(node.children.entries()).sort(([a], [b]) => a.localeCompare(b))) { buildTreeDoc(childKey, childNode, depth + 1, lines); } } /** * Generate the `needle-bindings.gen.d.ts` content from binding entries. * * @param {BindingEntry[]} entries * @returns {string} */ export function generateDts(entries) { /** @type {Map<string, BindingEntry[]>} */ const byGlb = new Map(); /** @type {Map<string, string>} glbKey → source path/URL */ const glbSrcMap = new Map(); /** @type {Map<string, Set<string>>} glbKey → set of source files referencing it */ const glbSourceFilesMap = new Map(); for (const entry of entries) { const k = entry.glbKey; if (!byGlb.has(k)) byGlb.set(k, []); /** @type {BindingEntry[]} */ (byGlb.get(k)).push(entry); if (entry.glbSrc && !glbSrcMap.has(k)) glbSrcMap.set(k, entry.glbSrc); if (entry.glbSourceFiles?.length) { if (!glbSourceFilesMap.has(k)) glbSourceFilesMap.set(k, new Set()); for (const sf of entry.glbSourceFiles) /** @type {Set<string>} */ (glbSourceFilesMap.get(k)).add(sf); } } const header = [ `// Auto-generated by @needle-tools/engine — do not edit`, `// Regenerated on each vite dev-server start and GLB change.`, `// Augments the base "needle-bindings" declaration in @needle-tools/engine.`, `import type * as NE from "../../lib/needle-engine.js";`, `import type * as THREE from "three";`, ]; /** @type {string[]} */ const aliases = []; /** @type {string[]} */ const interfaceLines = [ `declare module "needle-bindings" {`, ` interface SceneData {`, ]; for (const [glbKey, glbEntries] of Array.from(byGlb.entries()).sort(([a], [b]) => a.localeCompare(b))) { const roots = buildTree(glbEntries); if (roots.size === 0) continue; // Collect type aliases for all nodes in this GLB for (const [rootKey, rootNode] of Array.from(roots.entries()).sort(([a], [b]) => a.localeCompare(b))) { collectTypeAliases(rootKey, rootNode, `${glbKey}/${rootKey}`, aliases); } // Emit SceneData property references with hierarchy JSDoc const glbSrc = glbSrcMap.get(glbKey); const sourceFiles = glbSourceFilesMap.get(glbKey); const treeDocLines = [` * GLB/glTF scene file`]; const glbSrcLink = glbSrc ? (glbSrc.startsWith("http://") || glbSrc.startsWith("https://") ? ` — [${glbSrc}](${addViewParam(glbSrc)})` : ` — \`${glbSrc}\``) : ""; treeDocLines.push(` * \`${glbKey}\`${glbSrcLink}`); if (sourceFiles?.size) { treeDocLines.push(` *`); treeDocLines.push(` * **Referenced from:**`); for (const sf of Array.from(sourceFiles).sort()) { treeDocLines.push(` * - \`${sf}\``); } } treeDocLines.push(` *`); treeDocLines.push(` * ---`); treeDocLines.push(` *`); for (const [rootKey, rootNode] of Array.from(roots.entries()).sort(([a], [b]) => a.localeCompare(b))) { buildTreeDoc(rootKey, rootNode, 0, treeDocLines); } interfaceLines.push(` /**\n ${treeDocLines.join("\n ")}\n */`); interfaceLines.push(` $${glbKey}: {`); for (const [rootKey, rootNode] of Array.from(roots.entries()).sort(([a], [b]) => a.localeCompare(b))) { const alias = typeAliasName(`${glbKey}/${rootKey}`); const summary = nodeSummary(rootNode); interfaceLines.push(` /** ${summary} */`); interfaceLines.push(` ${propKey(rootKey)}: ${alias};`); } interfaceLines.push(` };`); } interfaceLines.push(` }`); interfaceLines.push(`}`); return [...header, ``, ...aliases, ``, ...interfaceLines, ``].join("\n"); } /** * Generate VS Code HTML custom data JSON for `data-bind-needle` completions. * * @param {BindingEntry[]} entries * @returns {string} JSON string */ export function generateHtmlCustomData(entries) { const pairs = Array.from( new Set(entries.filter(e => e.componentName).map(e => `${e.nodePath}/${e.componentName}`)) ).sort(); const values = pairs.map(pair => { const slash = pair.lastIndexOf("/"); const nodePath = pair.slice(0, slash); const compName = pair.slice(slash + 1); return { name: pair, description: `Bind to the **${compName}** component on node \`${nodePath}\`.`, }; }); const data = { version: 1.1, globalAttributes: [ { name: "data-bind-needle", description: { kind: "markdown", value: [ "Binds this HTML element to a Needle Engine scene component.", "", "**Format:** `NodePath/ComponentName`", "", "The Needle Engine runtime will associate this element with the specified", "component instance in the live scene graph.", "", "**Example:**", "```html", '<div data-bind-needle="Camera/OrbitControls">', "```", ].join("\n"), }, values, }, ], }; return JSON.stringify(data, null, 2) + "\n"; }