Skip to content
扫码开始移动端阅读

上线飞牛插件3d模型查看器(glbload)

1340
需要≈
6.7
分钟
生活技巧
Three.js
飞牛
飞牛插件3d模型查看器(glbload)展示图
飞牛插件3d模型查看器(glbload)展示图

初衷

我写这个插件的原因很简单,因为我闺女特别喜欢看3D 模型,看着她拿着鼠标晃来晃去模型也跟着动来动去的开心的样子,我就很欣慰了,因为模型要比平面的书本给她更好的体验。所以我在发现平时很难在各种设备上随手打开 GLB 文件:软件不统一、路径不方便、网络条件也不总是理想。于是这件事有了明确目标:

  • 在任何时候、任何网络、任何支持飞牛的设备上,随时随地查看家里的 3D 模型文件
  • 把“打开模型”这件事变得像点开照片一样顺手

工作场景也同样需要它:有些团队要管理和查看大量 GLB 文件,统一入口、统一权限、统一错误提示,能节省沟通时间,也能降低协作成本。

如何找到插件

你可以在飞牛的应用市场搜索 leelaa或者 glbload,就能看到这个插件。

前言

这次做 glbload飞牛插件,最大的收获不是功能,而是过程。启用时的那句

server.mjs not found

像一记重锤敲在脑门上。反复看日志、对路径、改脚本,才意识到:结构没对上,怎么可能跑起来。

关键原则(先说结论)

  • 后端服务必须打包到:fnConfig/app/server
  • 前端必须打包到:fnConfig/app/ui

否则平台不会把它们释放到 @appcenter,启动脚本也就“找不到入口”。这两个路径,是整个插件能不能“活过来”的底线。

排查与修正

入口找不到

启用后只看到:

server.mjs not found: /vol4/@appcenter/.../app/server/server.mjs

一开始以为漏打包,其实是“落点错了”。我把后端放在了错误的目录。严格改为 fnConfig/app/server(后端)与 fnConfig/app/ui(前端)后,日志立刻能定位到入口。

运行时崩溃(ESM/CJS)

报错:

Dynamic require of "path" is not supported

把后端改为 CommonJS(server.cjs),并用 process.argv[1] 推导运行文件路径,稳定解决。

403 提示更明确(感谢飞牛审核人员的建议)

路径访问走平台授权。后端只做“存在/可读/越界”校验。前端遇到 403,用更直白的提示:

无权限打开,请右键文件设置权限或在应用设置中调整授权目录范围

飞牛的容器不支持全屏

所以我在前端去掉全屏的功能,希望以后能增加更多官方的api。最好能提供nodejs的sdk。这样也更方便开发者进行工作了。

配置多个入口

这个type是有两个选择,如果选择url的话,可以在新标签页打开,如果选择iframe的话,就是在飞牛的应用内打开。

以下是我的配置文件 fnConfig\app\ui\config的内容:

{
  ".url": {
    "leelaa.glbload.Application": {
      "title": "GLB 查看器",
      "icon": "images/icon_{0}.png",
      "type": "iframe",
      "protocol": "http",
      "port": "5073",
      "url": "/",
      "allUsers": true
    },
    "leelaa.glbload.viewer": {
      "title": "GLB 查看器(窗口)",
      "icon": "images/icon_{0}.png",
      "type": "iframe",
      "protocol": "http",
      "port": "5073",
      "url": "/viewer",
      "allUsers": true,
      "fileTypes": [
        "glb",
        "gltf",
        "GLB",
        "GLTF"
      ],
      "noDisplay": true,
      "control": {
        "accessPerm": "readonly",
        "portPerm": "readonly",
        "pathPerm": "readonly"
      }
    },
    "leelaa.glbload.externalViewer": {
      "title": "GLB 查看器(外部)",
      "icon": "images/icon_{0}.png",
      "type": "url",
      "protocol": "http",
      "port": "5073",
      "url": "/viewer",
      "allUsers": true,
      "fileTypes": [
        "glb",
        "gltf",
        "GLB",
        "GLTF"
      ],
      "noDisplay": true,
      "control": {
        "accessPerm": "readonly",
        "portPerm": "readonly",
        "pathPerm": "readonly"
      }
    }
  }
}

一键打包脚本(build-all.js)

这段脚本是“把前端和后端放到正确位置”的关键。它统一了落点、消除了历史残留,并产出 .fpk,仅供参考,和官方的

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import esbuild from "esbuild";
import { spawnSync } from "child_process";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, "..");
const distDir = path.join(root, "release", "frontend");
const releaseDir = path.join(root, "release");
const feOut = path.join(releaseDir, "frontend");
const beOut = path.join(releaseDir, "app", "server");
const uiOut = path.join(releaseDir, "app", "ui");
const extraDir = path.join(root, "fnConfig");

function readText(file) {
  try {
    return fs.readFileSync(file, "utf8");
  } catch {
    return "";
  }
}

function parseManifest(txt) {
  const info = {};
  txt.split(/\r?\n/).forEach((line) => {
    const m = line.match(/^([^=]+)=(.*)$/);
    if (m) info[m[1].trim()] = m[2].trim();
  });
  return info;
}

function run(cmd, args, cwd) {
  const r = spawnSync(cmd, args, { cwd, stdio: "inherit" });
  return r.status === 0;
}

function rm(dir) {
  if (fs.existsSync(dir)) {
    fs.rmSync(dir, { recursive: true, force: true });
  }
}

function ensure(dir) {
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}

function copy(src, dst) {
  const st = fs.statSync(src);
  if (st.isDirectory()) {
    ensure(dst);
    for (const name of fs.readdirSync(src)) {
      copy(path.join(src, name), path.join(dst, name));
    }
  } else {
    ensure(path.dirname(dst));
    fs.copyFileSync(src, dst);
  }
}

ensure(releaseDir);

if (!fs.existsSync(distDir)) {
  process.stderr.write("frontend build not found. run 'npm run build' first\n");
  process.exit(1);
}

ensure(feOut);
rm(beOut);
ensure(beOut);
ensure(uiOut);
rm(path.join(releaseDir, "server"));
try { fs.unlinkSync(path.join(releaseDir, "app", "server.mjs")); } catch {}
ensure(path.join(beOut, "uploads"));
copy(distDir, uiOut);

await esbuild.build({
  entryPoints: [path.join(root, "server", "index.js")],
  outfile: path.join(beOut, "server.cjs"),
  bundle: true,
  platform: "node",
  format: "cjs",
  target: ["node22"],
  minify: false,
  sourcemap: false
});

const wizardOut = path.join(releaseDir, "wizard");
rm(wizardOut);
if (fs.existsSync(extraDir)) {
  for (const name of fs.readdirSync(extraDir)) {
    const src = path.join(extraDir, name);
    const dst = path.join(releaseDir, name);
    copy(src, dst);
  }
}

const buildRoot = path.join(root);
rm(path.join(releaseDir, "package"));
const manifestSrc = path.join(releaseDir, "manifest");
const manifestTxt = readText(manifestSrc);
if (!manifestTxt) {
  process.stderr.write("manifest not found in release\n");
  process.exit(1);
}
const info = parseManifest(manifestTxt);
const appName = info.appname || "app";
const version = info.version || "0.0.0";
const packDir = path.join(buildRoot, appName);
rm(packDir);
ensure(packDir);
for (const name of fs.readdirSync(releaseDir)) {
  if (name === "package") continue;
  if (name.endsWith('.fpk')) continue;
  copy(path.join(releaseDir, name), path.join(packDir, name));
}

const fnpackCmd = process.platform === "win32" ? path.join(root, "scripts", "fnpack.exe") : "fnpack";
const ok = run(fnpackCmd, ["build"], packDir);
if (!ok) {
  process.stderr.write("fnpack build failed or fnpack not found in PATH\n");
  process.exit(1);
}
const srcFpk = path.join(packDir, `${appName}.fpk`);
const dstFpkVer = path.join(buildRoot, `${appName}-${version}.fpk`);
if (fs.existsSync(srcFpk)) {
  fs.copyFileSync(srcFpk, dstFpkVer);
  try { fs.unlinkSync(srcFpk); } catch {}
}
rm(packDir);

process.stdout.write(`Release built at ${releaseDir}\n`);

结语

感谢内测群的小伙伴;也感谢那些让我少走弯路的优秀项目。愿飞牛生态越来越好,我们在平台上写出更多“跑得稳、用得顺”的好插件。