beam: add local converter and STEP viewer flow
This commit is contained in:
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user