beam: add local converter and STEP viewer flow

This commit is contained in:
CODEX
2026-06-04 19:39:22 +03:00
parent 9aaf82862f
commit 254bec97ab
10 changed files with 625 additions and 10 deletions
+8
View File
@@ -15,5 +15,13 @@ API:
- `GET /api/projects[?type=project|template]` — укороченный список из `index.json`.
- `GET /api/projects/:id` — полный JSON проекта.
- `POST /api/projects` — создать проект, генерируется `id`, `createdAt`, `updatedAt`, сохраняется файл и обновляется индекс.
- `POST /api/uploads?filename=<name>&projectId=<id>` — сохранить оригинальный файл модели в `server/data/uploads`.
- `GET /api/conversions/status?src=<uploads/...>` — получить статус подготовки viewer-артефакта.
Для `.step/.stp` ответ помечается `conversion.status=conversion_required`: оригинал уже доступен для скачивания,
а preview должен появиться после работы `NodeDcBimConverter`, который готовит GLB и metadata/tree.
Локальный Beam compose:
- `docker compose -f docker-compose.beam.yml up ndc-beam-viewer`
- `docker compose -f docker-compose.beam.yml up nodedc-bim-converter`
Поле `ownerId` пока всегда `null`, но оставлено для будущих пользователей/шаринга.
+116
View File
@@ -34,10 +34,19 @@ const MIME_TYPES = {
".las": "application/octet-stream",
".laz": "application/octet-stream",
".bim": "application/octet-stream",
".step": "application/octet-stream",
".stp": "application/octet-stream",
".wasm": "application/wasm",
".txt": "text/plain"
};
const CONVERTIBLE_MODEL_FORMATS = new Map([
[".step", "step"],
[".stp", "step"]
]);
const nowIso = () => new Date().toISOString();
const ensureStorage = async () => {
await fs.mkdir(DATA_DIR, {recursive: true});
await fs.mkdir(UPLOADS_DIR, {recursive: true});
@@ -186,6 +195,54 @@ const sanitizeFilename = (name, fallback) => {
return safe || fallback;
};
const manifestPathForSource = (sourcePath) => path.join(path.dirname(sourcePath), `${path.basename(sourcePath)}.beam.json`);
const srcFromUploadPath = (uploadPath) => {
const relative = path.relative(UPLOADS_DIR, uploadPath).replace(/\\/g, "/");
return path.join("uploads", relative).replace(/\\/g, "/");
};
const resolveUploadSrc = (src) => {
if (!src || typeof src !== "string") {
return null;
}
let value = src.trim();
if (!value) {
return null;
}
try {
if (/^https?:\/\//i.test(value)) {
const parsed = new URL(value);
value = parsed.pathname;
}
} catch (_) {
return null;
}
value = value.replace(/^\/+/, "");
if (!value.startsWith("uploads/")) {
return null;
}
const relative = value.slice("uploads/".length);
const resolved = path.resolve(UPLOADS_DIR, relative);
if (!resolved.startsWith(UPLOADS_DIR)) {
return null;
}
return resolved;
};
const readJSONFile = async (filePath) => {
try {
const raw = await fs.readFile(filePath, "utf8");
return JSON.parse(raw);
} catch (_) {
return null;
}
};
const writeJSONFile = async (filePath, payload) => {
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
};
const processUploads = async (project) => {
if (!Array.isArray(project.models)) {
return;
@@ -232,9 +289,64 @@ const handleRawUpload = async (req, res, url) => {
return;
}
const src = path.join("uploads", uploadId, safeName).replace(/\\/g, "/");
const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(safeName).toLowerCase());
if (sourceFormat) {
const conversion = {
componentTreeRequired: true,
message: "Original STEP uploaded. GLB/component tree is waiting for NodeDcBimConverter.",
sourceFormat,
sourceSrc: src,
status: "conversion_required",
targetFormat: "glb",
updatedAt: nowIso()
};
await writeJSONFile(manifestPathForSource(targetPath), conversion);
sendJSON(res, 201, {
src,
conversion
});
return;
}
sendJSON(res, 201, {src});
};
const handleConversionStatus = async (res, searchParams) => {
const sourcePath = resolveUploadSrc(searchParams.get("src"));
if (!sourcePath) {
sendText(res, 400, "Invalid source path");
return;
}
if (!existsSync(sourcePath)) {
sendText(res, 404, "Source file not found");
return;
}
const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(sourcePath).toLowerCase());
if (!sourceFormat) {
sendJSON(res, 200, {
sourceSrc: srcFromUploadPath(sourcePath),
status: "ready"
});
return;
}
const manifest = await readJSONFile(manifestPathForSource(sourcePath));
if (!manifest) {
sendJSON(res, 200, {
componentTreeRequired: true,
message: "GLB/component tree is waiting for NodeDcBimConverter.",
sourceFormat,
sourceSrc: srcFromUploadPath(sourcePath),
status: "conversion_required",
targetFormat: "glb",
updatedAt: nowIso()
});
return;
}
sendJSON(res, 200, manifest);
};
const buildProjectPayload = (body) => {
const now = new Date().toISOString();
const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`;
@@ -518,6 +630,10 @@ const requestHandler = async (req, res) => {
return handleRawUpload(req, res, url);
}
if (req.method === "GET" && url.pathname === "/api/conversions/status") {
return handleConversionStatus(res, url.searchParams);
}
await serveStatic(req, res, url);
};