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.

280 lines (256 loc) 10.3 kB
// @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; }