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.

789 lines (684 loc) 26.2 kB
// @ts-check import { existsSync, readFileSync, readdirSync, openSync, readSync, closeSync } from 'fs'; import { join, relative } from 'path'; import { needleBlue, needleDim, needleLog, needleSupportsColor } from './logging.js'; /** @typedef {import('./local-files-types.js').LocalizationOptions} LocalizationOptions */ /** @typedef {import('./local-files-types.js').AutoPolicy} AutoPolicy */ /** @typedef {import('./local-files-types.js').SceneAnalysisReport} SceneAnalysisReport */ /** @typedef {import('./local-files-types.js').SceneFile} SceneFile */ /** @typedef {import('./local-files-types.js').UrlHandler} UrlHandler */ /** @typedef {import('./local-files-types.js').LocalizationContext} LocalizationContext */ const NEEDLE_COMPONENTS_EXTENSION = "NEEDLE_components"; export const SKYBOX_MAGIC_KEYWORDS = ["studio", "blurred-skybox", "quicklook", "quicklook-ar"]; const SKYBOX_BASE_URL = "https://cdn.needle.tools/static/skybox/"; const HLS_CDN_SEGMENT = "/npm/hls.js@"; /** @type {Map<string, {autoPolicy: AutoPolicy|null, report: SceneAnalysisReport, hasLogged: boolean}>} */ const analysisByProject = new Map(); /** * @param {"build" | "serve"} command * @param {unknown} _config * @param {import('../types').userSettings} userSettings * @returns {import('vite').Plugin | null} */ export function needleLocalFilesSceneAnalysis(command, _config, userSettings) { if (!makeFilesLocalIsEnabled(userSettings)) return null; const options = resolveOptions(userSettings); let pluginRoot = process.cwd(); let pluginCommand = command; return { name: "needle:local-files-scene-analysis", enforce: "pre", /** @param {import('vite').ResolvedConfig} config */ configResolved(config) { pluginRoot = config?.root || process.cwd(); pluginCommand = config?.command || command; }, /** @param {import('vite').ViteDevServer} server */ configureServer(server) { const projectDir = server?.config?.root || pluginRoot || process.cwd(); ensureProjectAnalysis(projectDir, options); logSceneAnalysisReport(projectDir, "serve"); server?.httpServer?.once("close", () => { logSceneAnalysisReport(projectDir, "serve"); analysisByProject.delete(projectDir); }); }, buildStart() { const projectDir = pluginRoot || process.cwd(); ensureProjectAnalysis(projectDir, options); }, buildEnd() { const projectDir = pluginRoot || process.cwd(); logSceneAnalysisReport(projectDir, "build"); analysisByProject.delete(projectDir); }, closeBundle() { const activeCommand = pluginCommand || command; if (activeCommand !== "serve") return; const projectDir = pluginRoot || process.cwd(); logSceneAnalysisReport(projectDir, "serve"); analysisByProject.delete(projectDir); } }; } /** * @param {string} projectDir * @returns {AutoPolicy | null} */ export function getLocalFilesAutoPolicy(projectDir) { const entry = analysisByProject.get(projectDir); return entry?.autoPolicy ?? null; } /** * @param {import('../types').userSettings | undefined | null} userSettings * @returns {boolean} */ export function makeFilesLocalIsEnabled(userSettings) { if (typeof userSettings?.makeFilesLocal === "object") return userSettings?.makeFilesLocal?.enabled === true; if (userSettings?.makeFilesLocal === "auto") return true; return userSettings?.makeFilesLocal === true; } /** * @param {import('../types').userSettings | undefined | null} userSettings * @returns {LocalizationOptions} */ export function resolveOptions(userSettings) { if (typeof userSettings?.makeFilesLocal === "object") { const raw = userSettings.makeFilesLocal; const opts = /** @type {LocalizationOptions} */ ({ ...raw }); if (!opts.exclude?.length && opts.excludeUrls?.length) { opts.exclude = opts.excludeUrls; } return opts; } if (userSettings?.makeFilesLocal === "auto") { return { features: "auto" }; } return {}; } /** * @param {string | string[] | undefined} features * @returns {boolean} */ export function hasAutoFeatureSelection(features) { if (features === "auto") return true; if (Array.isArray(features)) return features.includes("auto"); return false; } /** * @param {LocalizationOptions} options * @param {string} projectDir * @returns {AutoPolicy | null} */ export function buildAutoPolicy(options, projectDir) { if (!hasAutoFeatureSelection(options.features)) return null; return detectAutoPolicy(projectDir, options); } /** * @param {string} projectDir * @param {LocalizationOptions} options * @returns {{ autoPolicy: AutoPolicy|null, report: SceneAnalysisReport, hasLogged: boolean }} */ function ensureProjectAnalysis(projectDir, options) { const existing = analysisByProject.get(projectDir); if (existing) return existing; const report = analyzeProjectGlbs(projectDir); const autoPolicy = hasAutoFeatureSelection(options.features) ? detectAutoPolicy(projectDir, options, report) : null; const entry = { autoPolicy, report, hasLogged: false, }; analysisByProject.set(projectDir, entry); return entry; } /** * @param {string} projectDir * @param {string} mode */ function logSceneAnalysisReport(projectDir, mode) { const entry = analysisByProject.get(projectDir); if (!entry || entry.hasLogged) return; const message = formatSceneAnalysisReport(entry.report, entry.autoPolicy, projectDir, mode); needleLog("needle:local-files", message, "log", { dimBody: false }); entry.hasLogged = true; } /** * @param {string} projectDir * @param {LocalizationOptions} options * @param {SceneAnalysisReport | null} [cachedAnalysis] * @returns {AutoPolicy} */ export function detectAutoPolicy(projectDir, options, cachedAnalysis = null) { const features = /** @type {Set<string>} */ (new Set()); features.add("draco"); features.add("ktx2"); features.add("fonts"); features.add("cdn-scripts"); features.add("needle-uploads"); features.add("needle-fonts"); features.add("needle-branding"); const pkgJsonPath = join(projectDir, "package.json"); /** @type {{dependencies?: Record<string,string>, devDependencies?: Record<string,string>} | null} */ let pkgJson = null; try { if (existsSync(pkgJsonPath)) { pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); } } catch (_e) { } if (pkgJson) { const allDeps = { ...pkgJson.dependencies, ...pkgJson.devDependencies }; if (allDeps["@needle-tools/materialx"]) { features.add("materialx"); } } const indexHtmlPath = join(projectDir, "index.html"); let indexHtml = ""; try { if (existsSync(indexHtmlPath)) { indexHtml = readFileSync(indexHtmlPath, "utf-8"); } } catch (_e) { } const indexSkyboxUrls = collectIndexHtmlSkyboxUrls(indexHtml); const selectedSkyboxUrls = resolveSkyboxSelectionUrls(options.skybox, indexSkyboxUrls); if (options.skybox === "all") { features.add("skybox"); } if (selectedSkyboxUrls?.size) { features.add("skybox"); } const srcDir = join(projectDir, "src"); let srcContent = ""; try { srcContent = collectSourceFiles(srcDir); } catch (_e) { } const registerTypesPath = join(projectDir, "src", "generated", "register_types.ts"); let registerTypes = ""; try { if (existsSync(registerTypesPath)) { registerTypes = readFileSync(registerTypesPath, "utf-8"); } } catch (_e) { } const allSrc = srcContent + "\n" + registerTypes + "\n" + indexHtml; const sceneAnalysis = cachedAnalysis || analyzeProjectGlbs(projectDir); const hasWebXRComponent = sceneAnalysis.hasWebXRComponent; const hasVideoPlayerComponent = sceneAnalysis.hasVideoPlayerComponent; if (hasWebXRComponent) { features.add("xr"); } if (/polyhaven\.org/i.test(allSrc)) { features.add("polyhaven"); } if (/raw\.githubusercontent\.com/i.test(allSrc)) { features.add("github-content"); } if (/threejs\.org\/examples\/models/i.test(allSrc)) { features.add("threejs-models"); } if (/cdn\.needle\.tools\/static\/models/i.test(allSrc)) { features.add("needle-models"); } if (/cdn\.needle\.tools\/static\/avatars/i.test(allSrc) || /\bAvatarModel\b|\bAvatarLoader\b/i.test(allSrc) || hasWebXRComponent) { features.add("needle-avatars"); } if (/materialx|MaterialXLoader/i.test(allSrc)) { features.add("materialx"); } if (Array.from(sceneAnalysis.extensions).some(ext => /materialx/i.test(ext))) { features.add("materialx"); } if (selectedSkyboxUrls && selectedSkyboxUrls.size > 0) { features.add("skybox"); } return { features, hasWebXR: hasWebXRComponent, hasVideoPlayer: hasVideoPlayerComponent, allowedSkyboxUrls: selectedSkyboxUrls, selectedWebXRProfiles: getWebXRProfilesForMode(options.webxr), }; } /** * @param {string} projectDir * @returns {SceneAnalysisReport} */ export function analyzeProjectGlbs(projectDir) { const sceneFiles = collectAssetSceneFiles(projectDir); const extensions = /** @type {Set<string>} */ (new Set()); const componentTypes = /** @type {Set<string>} */ (new Set()); const componentCounts = /** @type {Map<string, number>} */ (new Map()); const needleExtensionBlobs = /** @type {string[]} */ ([]); let hasWebXRComponent = false; let hasVideoPlayerComponent = false; let totalNodeCount = 0; let totalVertexCount = 0; let totalTextureCount = 0; let totalMeshCount = 0; let totalPrimitiveCount = 0; let dracoPrimitiveCount = 0; let meshoptBufferViewCount = 0; for (const file of sceneFiles) { try { const json = /** @type {Record<string,any>|null} */ (file.type === "glb" ? readGlbJsonChunk(file.path) : readGltfJsonFile(file.path)); if (!json || typeof json !== "object") continue; const used = Array.isArray(json.extensionsUsed) ? json.extensionsUsed : []; for (const ext of used) { if (typeof ext === "string") extensions.add(ext); } if (json.extensions && typeof json.extensions === "object") { for (const ext of Object.keys(json.extensions)) { extensions.add(ext); } } totalNodeCount += Array.isArray(json.nodes) ? json.nodes.length : 0; totalTextureCount += Array.isArray(json.textures) ? json.textures.length : 0; if (Array.isArray(json.bufferViews)) { for (const view of json.bufferViews) { const hasMeshopt = !!view?.extensions?.EXT_meshopt_compression; if (hasMeshopt) meshoptBufferViewCount++; } } const accessors = Array.isArray(json.accessors) ? json.accessors : []; const meshes = Array.isArray(json.meshes) ? json.meshes : []; totalMeshCount += meshes.length; for (const mesh of meshes) { const primitives = Array.isArray(mesh?.primitives) ? mesh.primitives : []; totalPrimitiveCount += primitives.length; for (const primitive of primitives) { const hasDraco = !!primitive?.extensions?.KHR_draco_mesh_compression; if (hasDraco) dracoPrimitiveCount++; const positionAccessorIndex = getPrimitivePositionAccessorIndex(primitive); if (positionAccessorIndex < 0) continue; const accessor = accessors[positionAccessorIndex]; if (!accessor || typeof accessor.count !== "number") continue; totalVertexCount += accessor.count; } } const componentNames = collectNeedleComponentNames(json); needleExtensionBlobs.push(...collectNeedleComponentExtensionBlobs(json)); for (const component of componentNames) { componentTypes.add(component); componentCounts.set(component, (componentCounts.get(component) || 0) + 1); if (!hasWebXRComponent && /\bWebXR\b|\bXRRig\b|\bWebARSessionRoot\b/i.test(component)) { hasWebXRComponent = true; } if (!hasVideoPlayerComponent && /\bVideoPlayer\b/i.test(component)) { hasVideoPlayerComponent = true; } } if ((!hasWebXRComponent || !hasVideoPlayerComponent) && componentNames.length > 0) { const blob = componentNames.join(" "); if (!hasWebXRComponent && /\bWebXR\b|\bXRRig\b|\bWebARSessionRoot\b/i.test(blob)) { hasWebXRComponent = true; } if (!hasVideoPlayerComponent && /\bVideoPlayer\b/i.test(blob)) { hasVideoPlayerComponent = true; } } } catch (_e) { } } if (!hasWebXRComponent || !hasVideoPlayerComponent) { for (const blob of needleExtensionBlobs) { if (!hasWebXRComponent && /\bWebXR\b|\bXRRig\b|\bWebARSessionRoot\b/i.test(blob)) { hasWebXRComponent = true; } if (!hasVideoPlayerComponent && /\bVideoPlayer\b/i.test(blob)) { hasVideoPlayerComponent = true; } if (hasWebXRComponent && hasVideoPlayerComponent) break; } } return { hasWebXRComponent, hasVideoPlayerComponent, extensions, componentTypes, componentCounts, glbFileCount: sceneFiles.filter(f => f.type === "glb").length, gltfFileCount: sceneFiles.filter(f => f.type === "gltf").length, totalSceneFileCount: sceneFiles.length, totalNodeCount, totalVertexCount, totalTextureCount, totalMeshCount, totalPrimitiveCount, dracoPrimitiveCount, meshoptBufferViewCount, }; } /** * @param {Record<string, any> | null | undefined} primitive * @returns {number} */ function getPrimitivePositionAccessorIndex(primitive) { const direct = primitive?.attributes?.POSITION; if (typeof direct === "number") return direct; const draco = primitive?.extensions?.KHR_draco_mesh_compression?.attributes?.POSITION; if (typeof draco === "number") return draco; const fallback = firstNumericValue(primitive?.attributes); if (fallback >= 0) return fallback; const dracoFallback = firstNumericValue(primitive?.extensions?.KHR_draco_mesh_compression?.attributes); if (dracoFallback >= 0) return dracoFallback; return -1; } /** * @param {Record<string, unknown> | null | undefined} obj * @returns {number} */ function firstNumericValue(obj) { if (!obj || typeof obj !== "object") return -1; const values = Object.values(obj); for (const value of values) { if (typeof value === "number") return value; } return -1; } /** * @param {unknown} json * @returns {string[]} */ function collectNeedleComponentNames(json) { const names = new Set(); /** @param {unknown} node */ function visit(node) { if (!node || typeof node !== "object") return; if (Array.isArray(node)) { for (const item of node) visit(item); return; } for (const [key, value] of Object.entries(node)) { if (key === NEEDLE_COMPONENTS_EXTENSION && value) { collectComponentNamesFromNeedleExtension(value, names); } visit(value); } } visit(json); return Array.from(names); } /** * @param {import('./local-files-types.js').NeedleComponentExtension | null | undefined} value * @param {Set<string>} names */ function collectComponentNamesFromNeedleExtension(value, names) { const builtinComponents = value?.builtin_components ?? []; for (const entry of builtinComponents) { const candidateName = getBuiltinComponentName(entry); if (candidateName) names.add(candidateName); } } /** * @param {import('./local-files-types.js').NeedleComponentEntry | null | undefined} node * @returns {string} */ function getBuiltinComponentName(node) { if (!node || typeof node !== "object") return ""; if (typeof node.name === "string" && node.name.trim()) return node.name.trim(); return ""; } /** * @param {SceneAnalysisReport} report * @param {AutoPolicy | null} autoPolicy * @param {string} projectDir * @param {string} _mode * @returns {string} */ function formatSceneAnalysisReport(report, autoPolicy, projectDir, _mode) { const supportsColor = needleSupportsColor(); const projectName = relative(process.cwd(), projectDir) || "."; const componentList = report.componentCounts?.size > 0 ? Array.from(report.componentCounts.entries()) .sort((a, b) => String(a[0]).localeCompare(String(b[0]))) .map(([name, count]) => { const label = String(name); if (!count || count <= 1) return label; const suffix = ` ${count}×`; return supportsColor ? `${label}${needleDim(suffix)}` : `${label}${suffix}`; }) .join(", ") : "(none)"; const extensionList = report.extensions.size > 0 ? Array.from(report.extensions).sort((a, b) => a.localeCompare(b)).join(", ") : "(none)"; const autoFeatures = autoPolicy?.features?.size ? Array.from(autoPolicy.features).sort((a, b) => a.localeCompare(b)).join(", ") : "(n/a)"; const key = (text) => supportsColor ? needleBlue(text) : text; const lines = [ "Scene analysis report", key("Project") + " : " + projectName, key("Scene files") + " : " + report.totalSceneFileCount + " (" + report.glbFileCount + " .glb, " + report.gltfFileCount + " .gltf)", key("Nodes") + " : " + report.totalNodeCount, key("Meshes / Primitives") + " : " + report.totalMeshCount + " / " + report.totalPrimitiveCount, key("Vertices") + " : " + report.totalVertexCount, key("Textures") + " : " + report.totalTextureCount, key("Compression usage") + " : Draco primitives=" + report.dracoPrimitiveCount + ", Meshopt bufferViews=" + report.meshoptBufferViewCount, key("Components") + " : " + componentList, key("Extensions") + " : " + extensionList, key("Detected Features") + " : " + autoFeatures, ]; return lines.join("\n"); } /** * @param {string} projectDir * @returns {string[]} */ export function collectAssetGlbs(projectDir) { const files = collectAssetSceneFiles(projectDir); return files.filter(file => file.type === "glb").map(file => file.path); } /** * @param {string} projectDir * @returns {SceneFile[]} */ function collectAssetSceneFiles(projectDir) { const assetsDir = join(projectDir, "assets"); if (!existsSync(assetsDir)) return []; /** @type {SceneFile[]} */ const out = []; /** @param {string} dir */ function walk(dir) { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { 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" }); continue; } if (/\.gltf$/i.test(entry.name)) { out.push({ path: fullPath, type: "gltf" }); continue; } } } walk(assetsDir); return out; } /** * @param {string} filePath * @returns {unknown} */ function readGltfJsonFile(filePath) { const text = readFileSync(filePath, "utf8"); return JSON.parse(text); } /** * @param {string} filePath * @returns {Record<string, unknown>} */ export function readGlbJsonChunk(filePath) { const fd = openSync(filePath, "r"); try { const header = Buffer.allocUnsafe(20); const bytesRead = readSync(fd, header, 0, header.length, 0); if (bytesRead < 20) throw new Error("Invalid GLB header: " + filePath); const magic = header.readUInt32LE(0); const version = header.readUInt32LE(4); const chunkLength = header.readUInt32LE(12); const chunkType = header.readUInt32LE(16); if (magic !== 0x46546c67 || version !== 2) throw new Error("Not a GLB v2: " + filePath); if (chunkType !== 0x4E4F534A) throw new Error("First GLB chunk is not JSON: " + filePath); const jsonBuffer = Buffer.allocUnsafe(chunkLength); const jsonBytesRead = readSync(fd, jsonBuffer, 0, chunkLength, 20); if (jsonBytesRead < chunkLength) throw new Error("Failed to read GLB JSON chunk: " + filePath); const jsonText = jsonBuffer.toString("utf8").replace(/\u0000+$/g, ""); return JSON.parse(jsonText); } finally { closeSync(fd); } } /** * @param {unknown} json * @returns {string[]} */ export function collectNeedleComponentExtensionBlobs(json) { const blobs = /** @type {string[]} */ ([]); /** @param {unknown} node */ function visit(node) { if (!node || typeof node !== "object") return; if (Array.isArray(node)) { for (const item of node) visit(item); return; } for (const [key, value] of Object.entries(node)) { if (key === NEEDLE_COMPONENTS_EXTENSION && value) { try { blobs.push(JSON.stringify(value)); } catch (_e) { } } visit(value); } } visit(json); return blobs; } /** * @param {string} indexHtml * @returns {Set<string>} */ export function collectIndexHtmlSkyboxUrls(indexHtml) { const urls = new Set(); if (!indexHtml) return urls; const attrRegex = /\b(background-image|environment-image)\s*=\s*["']([^"']+)["']/gi; let match; while ((match = attrRegex.exec(indexHtml)) !== null) { const value = (match[2] || "").trim(); const resolved = resolveSkyboxValueToUrl(value); if (resolved) urls.add(resolved); } return urls; } /** * @param {string | string[] | undefined | null} skyboxOption * @param {Set<string>} indexSkyboxUrls * @returns {Set<string> | null} */ export function resolveSkyboxSelectionUrls(skyboxOption, indexSkyboxUrls) { if (skyboxOption === "all") return null; if (Array.isArray(skyboxOption)) { const urls = new Set(); for (const entry of skyboxOption) { const resolved = resolveSkyboxValueToUrl(entry); if (resolved) urls.add(resolved); } return urls; } return indexSkyboxUrls; } /** * @param {string | null | undefined} value * @returns {string | null} */ export function resolveSkyboxValueToUrl(value) { if (!value) return null; if (/^https?:\/\//i.test(value)) return value; const normalized = value.replace(/^\/+/, ""); if (/\.ktx2$/i.test(normalized)) return SKYBOX_BASE_URL + normalized; if (/^[a-z0-9\-]+$/i.test(normalized)) { return SKYBOX_BASE_URL + normalized + ".ktx2"; } return null; } /** * @param {string | undefined} mode * @returns {string[]} */ export function getWebXRProfilesForMode(mode) { const allProfiles = [ "generic-hand", "generic-trigger", "oculus-touch-v2", "oculus-touch-v3", "meta-quest-touch-pro", "pico-4", "pico-neo3", ]; if (mode === "minimal") return ["generic-hand", "generic-trigger"]; if (mode === "quest") return ["generic-hand", "generic-trigger", "oculus-touch-v2", "oculus-touch-v3", "meta-quest-touch-pro"]; if (mode === "pico") return ["generic-hand", "generic-trigger", "pico-4", "pico-neo3"]; return allProfiles; } /** * @param {string} dir * @param {number} [depth] * @returns {string} */ function collectSourceFiles(dir, depth = 0) { if (depth > 5) return ""; if (!existsSync(dir)) return ""; let content = ""; const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.name === "node_modules" || entry.name === ".git") continue; const fullPath = join(dir, entry.name); if (entry.isDirectory()) { content += collectSourceFiles(fullPath, depth + 1); } else if (/\.(ts|js|tsx|jsx|html|vue|svelte)$/i.test(entry.name)) { try { content += readFileSync(fullPath, "utf-8") + "\n"; } catch (_e) { } } } return content; } /** * @param {string} url * @param {UrlHandler} handler * @param {LocalizationContext} context * @returns {boolean} */ export function shouldHandleUrlInAutoMode(url, handler, context) { if (!hasAutoFeatureSelection(context.options.features) || !context.autoPolicy) return true; if (handler.feature === "cdn-scripts" && url.includes(HLS_CDN_SEGMENT)) { return context.autoPolicy.hasVideoPlayer; } if (handler.feature === "needle-avatars" && /\/static\/avatars\/default/i.test(url) && !context.autoPolicy.hasWebXR) { return false; } if (handler.feature === "skybox") { const allowed = context.autoPolicy.allowedSkyboxUrls; if (!allowed) return true; return allowed.has(url); } return true; }