@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.
280 lines (256 loc) • 10.3 kB
JavaScript
// @ts-check
/**
* GLB / glTF file discovery.
*
* Resolves which scene files to process — either from the project's entrypoints
* (index.html `<needle-engine src>` or gen.js push calls) or by recursively
* walking the assets directory.
*/
import { existsSync, readFileSync, readdirSync } from 'fs';
import { join, basename, extname } from 'path';
/**
* Generic URL path segments that carry no useful identity — fall back to
* the previous segment when the last segment matches one of these.
*/
const GENERIC_SEGMENTS = new Set(["file", "index", "scene", "assets", "glb", "gltf", "model", "download"]);
/**
* Derive a short, human-friendly identifier from a GLB URL or local path.
*
* Rules (in priority order):
* 1. If `contentDispositionFilename` is provided → strip extension, use it.
* 2. Remote URL → walk path segments right-to-left, skip generic ones,
* strip extension from first useful segment.
* 3. Local path → basename without extension.
*
* Result is identifier-safe: non-alphanumeric chars replaced with `_`,
* leading digits prefixed with `_`.
*
* @param {string} pathOrUrl
* @param {string | null} [contentDispositionFilename]
* @returns {string}
*/
export function glbFriendlyName(pathOrUrl, contentDispositionFilename) {
let raw = "";
if (contentDispositionFilename) {
raw = basename(contentDispositionFilename, extname(contentDispositionFilename));
} else if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
try {
const segments = new URL(pathOrUrl).pathname.split("/").filter(Boolean);
// Walk right-to-left, skip generic segments
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
const withoutExt = seg.replace(/\.(glb|gltf)$/i, "");
if (!GENERIC_SEGMENTS.has(withoutExt.toLowerCase())) {
raw = withoutExt;
break;
}
}
if (!raw) raw = segments[segments.length - 1] ?? "scene";
} catch (_e) {
raw = "scene";
}
} else {
raw = basename(pathOrUrl, extname(pathOrUrl));
}
// Make identifier-safe
let id = raw.replace(/[^a-zA-Z0-9]/g, "_");
if (/^\d/.test(id)) id = "_" + id;
return id || "scene";
}
/** Source file extensions that may contain `<needle-engine src="...">` markup. */
const SOURCE_EXTENSIONS = /\.(html|svelte|tsx|jsx|vue)$/i;
/**
* Parse `<needle-engine src="...">` from HTML.
* Handles both single strings and JSON arrays.
* @param {string} html
* @returns {string[]}
*/
function parseSrcAttribute(html) {
const re = /<needle-engine[\s\S]*?\ssrc=["']([^"']+)["']/gi;
/** @type {string[]} */
const out = [];
let m;
while ((m = re.exec(html)) !== null) {
const val = m[1].trim();
if (val.startsWith("[")) {
try {
const arr = JSON.parse(val);
if (Array.isArray(arr)) out.push(...arr.filter(v => typeof v === "string"));
} catch (_e) { /* malformed JSON, skip */ }
} else {
out.push(val);
}
}
return out;
}
/**
* Parse `needle_exported_files.push("path/to/file.glb")` lines from gen.js.
* @param {string} src
* @returns {string[]}
*/
function parseGenJs(src) {
const re = /needle_exported_files\.push\(["']([^"']+\.(?:glb|gltf))["']\)/gi;
/** @type {string[]} */
const out = [];
let m;
while ((m = re.exec(src)) !== null) {
out.push(m[1]);
}
return out;
}
/**
* Resolve a list of (possibly relative) GLB path strings to absolute paths.
* Remote URLs (http/https) are passed through as-is.
* The `key` field preserves the original src value (relative path or URL)
* for use as the SceneMap key in the generated .d.ts.
*
* @param {Array<{glbPath: string, sourceFile: string | null}>} pathEntries
* @param {string} projectRoot
* @param {string} assetsDir
* @returns {Array<{path: string, type: "glb"|"gltf", remote?: boolean, key: string, sourceFiles: string[]}>}
*/
function resolveGlbPaths(pathEntries, projectRoot, assetsDir) {
/** @type {Map<string, {path: string, type: "glb"|"gltf", remote?: boolean, key: string, sourceFiles: string[]}>} */
const byKey = new Map();
for (const { glbPath: p, sourceFile } of pathEntries) {
if (p.startsWith("http://") || p.startsWith("https://")) {
const type = p.toLowerCase().endsWith(".gltf") ? "gltf" : "glb";
const existing = byKey.get(p);
if (existing) {
if (sourceFile) existing.sourceFiles.push(sourceFile);
} else {
byKey.set(p, { path: p, type, remote: true, key: p, sourceFiles: sourceFile ? [sourceFile] : [] });
}
continue;
}
const clean = p.replace(/^\.\//, "").replace(/^\//, "");
const candidates = [
join(projectRoot, clean),
join(assetsDir, clean.replace(/^assets\//, "")),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
const type = candidate.toLowerCase().endsWith(".gltf") ? "gltf" : "glb";
const existing = byKey.get(clean);
if (existing) {
if (sourceFile) existing.sourceFiles.push(sourceFile);
} else {
byKey.set(clean, { path: candidate, type, key: clean, sourceFiles: sourceFile ? [sourceFile] : [] });
}
break;
}
}
}
return Array.from(byKey.values());
}
/**
* Walk `src/` (or the project root) for source files that may contain
* `<needle-engine src="...">` and return GLB path + source file pairs.
*
* @param {string} projectRoot
* @returns {Array<{glbPath: string, sourceFile: string}>}
*/
function scanSourceFilesForGlbs(projectRoot) {
/** @type {Array<{glbPath: string, sourceFile: string}>} */
const out = [];
const srcDir = join(projectRoot, "src");
const searchRoot = existsSync(srcDir) ? srcDir : projectRoot;
/** @param {string} dir */
function walk(dir) {
let entries;
try { entries = readdirSync(dir, { withFileTypes: true }); } catch (_e) { return; }
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
// Skip node_modules and hidden dirs
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
walk(fullPath);
} else if (SOURCE_EXTENSIONS.test(entry.name)) {
try {
const content = readFileSync(fullPath, "utf8");
const relPath = fullPath.replace(projectRoot + "/", "").replace(projectRoot + "\\", "");
for (const glbPath of parseSrcAttribute(content)) {
out.push({ glbPath, sourceFile: relPath });
}
} catch (_e) { /* ignore unreadable files */ }
}
}
}
walk(searchRoot);
return out;
}
/**
* Resolve the entrypoint GLB file paths for a project.
*
* Sources (all merged, deduplicated by key):
* 1. `<needle-engine src="...">` in `index.html`
* 2. `needle_exported_files.push("...")` lines in `{codegenDir}/gen.js`
* 3. `<needle-engine src="...">` in any `.svelte`, `.tsx`, `.jsx`, `.vue`, `.html` under `src/`
*
* Returns `null` if nothing found — caller should fall back to `collectSceneFiles`.
*
* @param {string} projectRoot Absolute path to the project root
* @param {string} assetsDir Absolute path to the assets directory
* @param {string} [codegenDir] Absolute path to the codegen directory
* @returns {Array<{path: string, type: "glb"|"gltf", remote?: boolean, key: string, sourceFiles: string[]}> | null}
*/
export function resolveEntrypointGlbs(projectRoot, assetsDir, codegenDir) {
/** @type {Array<{glbPath: string, sourceFile: string | null}>} */
const allEntries = [];
// 1. index.html
const htmlPath = join(projectRoot, "index.html");
if (existsSync(htmlPath)) {
try {
for (const glbPath of parseSrcAttribute(readFileSync(htmlPath, "utf8"))) {
allEntries.push({ glbPath, sourceFile: "index.html" });
}
} catch (_e) { /* ignore */ }
}
// 2. gen.js
const genDir = codegenDir ?? join(projectRoot, "src", "generated");
const genPath = join(genDir, "gen.js");
if (existsSync(genPath)) {
try {
const relGenPath = genPath.replace(projectRoot + "/", "").replace(projectRoot + "\\", "");
for (const glbPath of parseGenJs(readFileSync(genPath, "utf8"))) {
allEntries.push({ glbPath, sourceFile: relGenPath });
}
} catch (_e) { /* ignore */ }
}
// 3. Source files (SvelteKit, React, Vue, etc.)
allEntries.push(...scanSourceFilesForGlbs(projectRoot));
if (allEntries.length === 0) return null;
const resolved = resolveGlbPaths(allEntries, projectRoot, assetsDir);
return resolved.length > 0 ? resolved : null;
}
/**
* Recursively collect all scene GLB/glTF files in a directory.
* Skips LOD and image sub-glbs that aren't scene roots.
*
* @param {string} assetsDir
* @returns {Array<{path: string, type: "glb"|"gltf"}>}
*/
export function collectSceneFiles(assetsDir) {
if (!existsSync(assetsDir)) return [];
/** @type {Array<{path: string, type: "glb"|"gltf"}>} */
const out = [];
/** @param {string} dir */
function walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
continue;
}
if (/^image_\d+_.*\.glb$/i.test(entry.name)) continue;
if (/^mesh_lod_\d+_.*\.glb$/i.test(entry.name)) continue;
if (/\.glb$/i.test(entry.name)) {
out.push({ path: fullPath, type: "glb" });
} else if (/\.gltf$/i.test(entry.name)) {
out.push({ path: fullPath, type: "gltf" });
}
}
}
walk(assetsDir);
return out;
}