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