Initial import of NODEDC BIM Viewer
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Server
|
||||
|
||||
Лёгкий файловый backend без внешних зависимостей.
|
||||
|
||||
Быстрый старт
|
||||
- Сервер: `npm --prefix server start` (PORT=8080 по умолчанию). Если порт занят, сервер попробует 8081/8082/8083 автоматически.
|
||||
- Ручной выбор порта: `PORT=8088 npm --prefix server start`
|
||||
- Клиент отдельно (только статика, без API): `npx http-server frontend -p 9001` и открыть http://localhost:9001
|
||||
|
||||
Статика: раздаётся из `../frontend/dist`, при отсутствии сборки — из `../frontend`.
|
||||
|
||||
Хранилище: `server/data/projects/index.json` (список) + `server/data/projects/proj_<id>.json` (полный проект). При старте создаются при необходимости.
|
||||
|
||||
API:
|
||||
- `GET /api/projects[?type=project|template]` — укороченный список из `index.json`.
|
||||
- `GET /api/projects/:id` — полный JSON проекта.
|
||||
- `POST /api/projects` — создать проект, генерируется `id`, `createdAt`, `updatedAt`, сохраняется файл и обновляется индекс.
|
||||
|
||||
Поле `ownerId` пока всегда `null`, но оставлено для будущих пользователей/шаринга.
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"id": "proj_e5355c03-983b-4e2f-800c-f57638e9732f",
|
||||
"name": "СКОЛКОВО - LOFT КВАРТАЛ",
|
||||
"type": "project",
|
||||
"ownerId": null,
|
||||
"createdAt": "2025-12-09T15:34:20.896Z",
|
||||
"updatedAt": "2025-12-18T15:54:18.262Z"
|
||||
},
|
||||
{
|
||||
"id": "proj_d992cce8-c689-49f8-be13-c3fee3247218",
|
||||
"name": "P5003 - Металлокаркас 09.07.2025",
|
||||
"type": "project",
|
||||
"ownerId": null,
|
||||
"createdAt": "2025-12-09T15:06:15.733Z",
|
||||
"updatedAt": "2025-12-09T15:06:15.733Z"
|
||||
},
|
||||
{
|
||||
"id": "proj_605b2cad-e310-4967-a2ee-c16678255cc4",
|
||||
"name": "МИД LAS",
|
||||
"type": "project",
|
||||
"ownerId": null,
|
||||
"createdAt": "2025-12-08T23:44:33.594Z",
|
||||
"updatedAt": "2025-12-19T01:24:59.236Z"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,863 @@
|
||||
{
|
||||
"id": "proj_605b2cad-e310-4967-a2ee-c16678255cc4",
|
||||
"name": "МИД LAS",
|
||||
"type": "project",
|
||||
"createdAt": "2025-12-08T23:44:33.594Z",
|
||||
"updatedAt": "2025-12-19T01:24:59.236Z",
|
||||
"ownerId": null,
|
||||
"shares": [],
|
||||
"models": [
|
||||
{
|
||||
"id": "model-1",
|
||||
"type": "las",
|
||||
"meta": {
|
||||
"label": "1.las.subsampled.subsampled.las",
|
||||
"metadataPath": null
|
||||
},
|
||||
"visible": true,
|
||||
"src": "uploads/a37cb27f-e557-4d00-8698-bd6014b7445b/1.las.subsampled.subsampled.las"
|
||||
},
|
||||
{
|
||||
"id": "__40",
|
||||
"type": "las",
|
||||
"meta": {
|
||||
"label": "5_4_Novinskiy_COMP.las",
|
||||
"metadataPath": null
|
||||
},
|
||||
"visible": true,
|
||||
"src": "uploads/proj_605b2cad-e310-4967-a2ee-c16678255cc4/5_4_Novinskiy_COMP.las"
|
||||
},
|
||||
{
|
||||
"id": "03c92cdc-b969-46aa-8193-24d7effdec8a",
|
||||
"type": "las",
|
||||
"meta": {
|
||||
"label": "64_SMOL_RESAMPLE__LAS2.las",
|
||||
"metadataPath": null
|
||||
},
|
||||
"visible": true,
|
||||
"src": "uploads/proj_605b2cad-e310-4967-a2ee-c16678255cc4/64_SMOL_RESAMPLE__LAS2.las"
|
||||
},
|
||||
{
|
||||
"id": "model-2",
|
||||
"type": "las",
|
||||
"meta": {
|
||||
"label": "6_3_Smolenskiy_bulvar_SAM.las",
|
||||
"metadataPath": null
|
||||
},
|
||||
"visible": true,
|
||||
"src": "uploads/proj_605b2cad-e310-4967-a2ee-c16678255cc4/6_3_Smolenskiy_bulvar_SAM.las"
|
||||
},
|
||||
{
|
||||
"id": "model-3",
|
||||
"type": "las",
|
||||
"meta": {
|
||||
"label": "6_1_Smolenskiy_bulvar_SAM.las",
|
||||
"metadataPath": null
|
||||
},
|
||||
"visible": true,
|
||||
"src": "uploads/proj_605b2cad-e310-4967-a2ee-c16678255cc4/6_1_Smolenskiy_bulvar_SAM.las"
|
||||
}
|
||||
],
|
||||
"viewerState": {
|
||||
"camera": {
|
||||
"eye": [
|
||||
3.290525254203974,
|
||||
94.2468740377458,
|
||||
-91.99238708709487
|
||||
],
|
||||
"look": [
|
||||
62.67477802832284,
|
||||
17.213604529191198,
|
||||
12.108512669839874
|
||||
],
|
||||
"up": [
|
||||
0.2679151175928213,
|
||||
0.8412159002309669,
|
||||
0.46965657555689466
|
||||
],
|
||||
"projection": "ortho"
|
||||
},
|
||||
"transparent": true,
|
||||
"edges": true,
|
||||
"addMode": true,
|
||||
"selection": {
|
||||
"ids": [
|
||||
"a15f0848-72a2-4625-934c-554462aadf50"
|
||||
],
|
||||
"modelId": "__40"
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"id": "model-1",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"id": "__40",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"id": "03c92cdc-b969-46aa-8193-24d7effdec8a",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"id": "model-2",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"id": "model-3",
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"transforms": {
|
||||
"model-1": {
|
||||
"__model__": {
|
||||
"position": [
|
||||
-161.792,
|
||||
-25.79200002885279,
|
||||
18.37489995300601
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"764b1dc2-51c0-4cfc-b90d-68c2a586436c": {
|
||||
"position": [
|
||||
-161.8,
|
||||
-25.79200002885279,
|
||||
18.374899953006015
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"49e0e645-3dc4-4f56-ae66-a344450742b0": {
|
||||
"position": [
|
||||
-161.792,
|
||||
-25.79200002885279,
|
||||
118.37489995300602
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"65180f0f-af67-4ff1-984e-9bb2d4c79ca9": {
|
||||
"position": [
|
||||
-161.792,
|
||||
-25.79200002885279,
|
||||
118.37489995300602
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"22f3b437-f0ad-4cbb-996b-1341085831fe": {
|
||||
"position": [
|
||||
-161.792,
|
||||
-25.79200002885279,
|
||||
180
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"3aa81bcd-5c75-4373-a98a-3b8bbca490a6": {
|
||||
"position": [
|
||||
-161.792,
|
||||
-25.79200002885279,
|
||||
108.37489995300601
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"1cd92b36-a933-48b8-b121-b0168b8f8268": {
|
||||
"position": [
|
||||
-161.792,
|
||||
-25.79200002885279,
|
||||
118.37489995300601
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"e20cf78d-c2b0-42d0-9c33-bcd3e290f772": {
|
||||
"position": [
|
||||
-161.792,
|
||||
-25.79200002885279,
|
||||
18.37489995300601
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"5086e5df-fb27-47f0-8427-e88da4cdedaf": {
|
||||
"position": [
|
||||
-161.792,
|
||||
-25.79200002885279,
|
||||
18.37489995300601
|
||||
],
|
||||
"rotation": [
|
||||
90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"1d3bb865-3262-4699-902a-2ab4b8e04e71": {
|
||||
"position": [
|
||||
-161.792,
|
||||
-25.79200002885279,
|
||||
18.37489995300601
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"5e0e1983-72f3-418b-a54a-c15b42b6c0f1": {
|
||||
"position": [
|
||||
-161.792,
|
||||
-25.79200002885279,
|
||||
18.37489995300601
|
||||
],
|
||||
"rotation": [
|
||||
90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"b504c943-9ca6-47c9-b964-04f7d2c160d7": {
|
||||
"position": [
|
||||
-161.792,
|
||||
-25.79200002885279,
|
||||
18.37489995300601
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"960718d6-e758-438e-9b53-ddb5a138096b": {
|
||||
"position": [
|
||||
-161.792,
|
||||
-25.79200002885279,
|
||||
18.37489995300601
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"__40": {
|
||||
"__model__": {
|
||||
"position": [
|
||||
198.3,
|
||||
-25.79200002885279,
|
||||
-5
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
-18
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"eec1c0de-e7a0-4ef2-8101-2ced3e0c9f00": {
|
||||
"position": [
|
||||
198,
|
||||
-25.79200002885279,
|
||||
-7
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
-15
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"b0bd4754-ac80-428c-bbdb-ff453f71bb69": {
|
||||
"position": [
|
||||
197.4,
|
||||
-25.8,
|
||||
-5.8
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
-17
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"981415c6-7f89-426c-91d8-05a30bbf5949": {
|
||||
"position": [
|
||||
198,
|
||||
-25.79200002885279,
|
||||
-5
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
-19
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"9eb2f67e-af0c-4e89-86cb-3574eeb1d00a": {
|
||||
"position": [
|
||||
198,
|
||||
-25.79200002885279,
|
||||
5
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
-19
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"a15f0848-72a2-4625-934c-554462aadf50": {
|
||||
"position": [
|
||||
198.3,
|
||||
-25.79200002885279,
|
||||
-5
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
-18
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"03c92cdc-b969-46aa-8193-24d7effdec8a": {
|
||||
"__model__": {
|
||||
"position": [
|
||||
-737,
|
||||
-24,
|
||||
81
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
2
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"f0ad97c7-862d-44d8-a949-ccca79f6182e": {
|
||||
"position": [
|
||||
-739,
|
||||
-24,
|
||||
61
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"3596d42f-b80e-4d49-b841-e59a1ee5cd5c": {
|
||||
"position": [
|
||||
-739,
|
||||
-23,
|
||||
61
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"189b283c-7435-4383-8c8e-29d951461172": {
|
||||
"position": [
|
||||
-738.792,
|
||||
-23.79200002885279,
|
||||
61
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"da16c0a9-ff27-42d1-87ec-5f1bd4f45ca0": {
|
||||
"position": [
|
||||
-737,
|
||||
-24,
|
||||
81
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
2
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"d08220d6-420e-4506-ae2b-a9677cf6d65f": {
|
||||
"position": [
|
||||
-737,
|
||||
-24,
|
||||
81
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
2
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"8822f7a5-795f-4ef9-8047-bbbca730dc16": {
|
||||
"position": [
|
||||
-737,
|
||||
-24,
|
||||
81
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"cc6645d1-c57f-4eff-b87f-84d20c8107ab": {
|
||||
"position": [
|
||||
-737,
|
||||
-24,
|
||||
81
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
2
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"model-2": {
|
||||
"8aeba79e-530c-428b-bc7c-1f6b2f192178": {
|
||||
"position": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"__model__": {
|
||||
"position": [
|
||||
-492,
|
||||
-21,
|
||||
-27
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
52
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"ccfb97b0-2036-43f6-9d5e-5d283614edc5": {
|
||||
"position": [
|
||||
-490,
|
||||
-19,
|
||||
-21
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
51
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"8f7edf5b-a247-493f-b47b-019edf5409d1": {
|
||||
"position": [
|
||||
-493,
|
||||
-21,
|
||||
-30
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
54
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"ba4f5127-d048-4791-b787-6a6d82bb3d3d": {
|
||||
"position": [
|
||||
-493,
|
||||
-21,
|
||||
-30
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
54
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"ff4b0e14-0307-43ed-aadb-3d0c7af7bc5c": {
|
||||
"position": [
|
||||
-493,
|
||||
-21,
|
||||
-30
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
54
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"af708d8a-8d0c-4241-8a04-1a8e8d0ef889": {
|
||||
"position": [
|
||||
-492,
|
||||
-21,
|
||||
-27
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
0,
|
||||
52
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"model-3": {
|
||||
"581cee62-591e-43a3-951f-952bfce7fe76": {
|
||||
"position": [
|
||||
-4,
|
||||
-18,
|
||||
-47
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
1,
|
||||
180
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"__model__": {
|
||||
"position": [
|
||||
-6,
|
||||
-26,
|
||||
-52
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
2,
|
||||
180
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"925996f1-05b9-428a-8811-179fa1ff71c4": {
|
||||
"position": [
|
||||
-9,
|
||||
-26,
|
||||
-51
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
2,
|
||||
180
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"eccea883-ac6a-480e-ba62-01b0847498fc": {
|
||||
"position": [
|
||||
-9,
|
||||
-26,
|
||||
-51
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
2,
|
||||
180
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"c209454d-0a7e-48c0-a610-06fe1a3ae233": {
|
||||
"position": [
|
||||
-9,
|
||||
-26,
|
||||
-51
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
2,
|
||||
180
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"f97f46b9-09c4-4c79-aae4-def5f982caf1": {
|
||||
"position": [
|
||||
-6,
|
||||
-26,
|
||||
-52
|
||||
],
|
||||
"rotation": [
|
||||
-90,
|
||||
2,
|
||||
180
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"design": {
|
||||
"themeVersion": "v3-yellow",
|
||||
"bgOuter": "#121319",
|
||||
"canvasTop": "#121319",
|
||||
"canvasBottom": "#121319",
|
||||
"loadingTop": "#121319",
|
||||
"loadingBottom": "#121319",
|
||||
"accent": "#ffea00",
|
||||
"accent2": "#ffea00",
|
||||
"templateFill": "#1f1f1f",
|
||||
"templateOutline": "#1c1c1c",
|
||||
"templateOutlineHover": "#2e2e2e",
|
||||
"projectBtnFill": "#1c1c1c",
|
||||
"projectBtnBorder": "#1c1c1c",
|
||||
"projectBtnHover": "#292929",
|
||||
"modalBtnPrimary": "#befd02",
|
||||
"modalBtnSecondary": "#292929",
|
||||
"loaderColor": "#befd02",
|
||||
"templateGlow": 39,
|
||||
"toolbarBg": "#1c1c1c",
|
||||
"toolbarBgActive": "#befd02",
|
||||
"toolbarBorder": "#233044",
|
||||
"toolbarOutline": "#befd02",
|
||||
"modalBg": "#1a1a1a",
|
||||
"modalBgAlpha": 0.95,
|
||||
"modalFocus": "#141414",
|
||||
"modalFocusAlpha": 0.55,
|
||||
"modalBorder": "#141414",
|
||||
"modalElement": "#141414",
|
||||
"modalElementOutline": "#141414",
|
||||
"modalRadius": "14px",
|
||||
"border": "#233044",
|
||||
"text": "#e6edf3",
|
||||
"panel": "#1c1c1c",
|
||||
"panel2": "#292929",
|
||||
"highlight": "#befd02",
|
||||
"cubeText": "#242424",
|
||||
"cubeEdges": "#e0e7ff",
|
||||
"plus": "#befd02",
|
||||
"navCubeBase": "#ffffff",
|
||||
"navCubeHover": "#ffffff",
|
||||
"navCubeText": "#242424",
|
||||
"navCubeEdge": "#e0e7ff",
|
||||
"navCubeEdgeAlpha": 0.6,
|
||||
"measureLine": "#befd02",
|
||||
"measureLabel": "#befd02",
|
||||
"measureLabelText": "#292929",
|
||||
"measureDot": "#befd02",
|
||||
"clipPerspectiveNear": 0.01,
|
||||
"clipPerspectiveFar": 100000,
|
||||
"clipOrthoNear": 0.01,
|
||||
"clipOrthoFar": 100000,
|
||||
"clipPerspective": 4000,
|
||||
"clipOrtho": 5000
|
||||
},
|
||||
"meta": {
|
||||
"description": "",
|
||||
"tags": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"id": "proj_d992cce8-c689-49f8-be13-c3fee3247218",
|
||||
"name": "P5003 - Металлокаркас 09.07.2025",
|
||||
"type": "project",
|
||||
"createdAt": "2025-12-09T15:06:15.733Z",
|
||||
"updatedAt": "2025-12-09T15:06:15.733Z",
|
||||
"ownerId": null,
|
||||
"shares": [],
|
||||
"models": [
|
||||
{
|
||||
"id": "model-1",
|
||||
"type": "xkt",
|
||||
"meta": {
|
||||
"label": "P5003.40-1.ifc.xkt",
|
||||
"metadataPath": null
|
||||
},
|
||||
"visible": true,
|
||||
"src": "uploads/8da2015f-7253-47bd-8080-4c92467c57eb/P5003.40-1.ifc.xkt"
|
||||
}
|
||||
],
|
||||
"viewerState": {
|
||||
"camera": {
|
||||
"eye": [
|
||||
2177739.6705636266,
|
||||
85.26740285024928,
|
||||
-584710.4811701652
|
||||
],
|
||||
"look": [
|
||||
2177765.1888946714,
|
||||
52.024988327600454,
|
||||
-584662.7980467239
|
||||
],
|
||||
"up": [
|
||||
0.24708334537413393,
|
||||
0.8519312359162147,
|
||||
0.4616957761437695
|
||||
],
|
||||
"projection": "perspective"
|
||||
},
|
||||
"transparent": true,
|
||||
"edges": true,
|
||||
"addMode": true,
|
||||
"selection": {
|
||||
"ids": [],
|
||||
"modelId": null
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"id": "model-1",
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"transforms": {
|
||||
"model-1": {
|
||||
"__model__": {
|
||||
"position": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"design": {
|
||||
"themeVersion": "v3-yellow",
|
||||
"bgOuter": "#0f0f0f",
|
||||
"canvasTop": "#1c1c1c",
|
||||
"canvasBottom": "#1c1c1c",
|
||||
"loadingTop": "#1c1c1c",
|
||||
"loadingBottom": "#1c1c1c",
|
||||
"accent": "#ffea00",
|
||||
"accent2": "#ffea00",
|
||||
"templateFill": "#1c1c1c",
|
||||
"templateOutline": "#233044",
|
||||
"templateOutlineHover": "rgba(255,234,0,0.7)",
|
||||
"templateGlow": 80,
|
||||
"toolbarBg": "#1c1c1c",
|
||||
"toolbarBgActive": "#ffea00",
|
||||
"toolbarBorder": "#233044",
|
||||
"toolbarOutline": "#ffea00",
|
||||
"modalBg": "#1c1c1c",
|
||||
"modalFocus": "#292929",
|
||||
"modalBorder": "#292929",
|
||||
"modalElement": "#292929",
|
||||
"modalElementOutline": "#2b2b2b",
|
||||
"modalRadius": "14px",
|
||||
"border": "#233044",
|
||||
"text": "#e6edf3",
|
||||
"panel": "#1c1c1c",
|
||||
"panel2": "#292929",
|
||||
"highlight": "#ffea00",
|
||||
"cubeText": "#242424",
|
||||
"cubeEdges": "#e0e7ff",
|
||||
"plus": "#ffea00",
|
||||
"navCubeBase": "#ffea00",
|
||||
"navCubeHover": "#ffffff",
|
||||
"navCubeText": "#242424",
|
||||
"navCubeEdge": "#e0e7ff",
|
||||
"navCubeEdgeAlpha": 0.6,
|
||||
"measureLine": "#ffea00",
|
||||
"measureLabel": "#ffea00",
|
||||
"measureLabelText": "#292929",
|
||||
"measureDot": "#ffea00"
|
||||
},
|
||||
"meta": {
|
||||
"description": "",
|
||||
"tags": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"id": "proj_e5355c03-983b-4e2f-800c-f57638e9732f",
|
||||
"name": "СКОЛКОВО - LOFT КВАРТАЛ",
|
||||
"type": "project",
|
||||
"createdAt": "2025-12-09T15:34:20.896Z",
|
||||
"updatedAt": "2025-12-18T15:54:18.262Z",
|
||||
"ownerId": null,
|
||||
"shares": [],
|
||||
"models": [
|
||||
{
|
||||
"id": "model-1",
|
||||
"type": "gltf",
|
||||
"meta": {
|
||||
"label": "SK_LOFT.glb",
|
||||
"metadataPath": null
|
||||
},
|
||||
"visible": true,
|
||||
"src": "uploads/3fd1f5a5-6073-49d8-b8a3-ee73db24fe97/SK_LOFT.glb"
|
||||
}
|
||||
],
|
||||
"viewerState": {
|
||||
"camera": {
|
||||
"eye": [
|
||||
86.09374214401373,
|
||||
85.39576390662415,
|
||||
-80.17903586625827
|
||||
],
|
||||
"look": [
|
||||
-163.28399976923995,
|
||||
-285.2166475367679,
|
||||
80.87648559581864
|
||||
],
|
||||
"up": [
|
||||
-0.6556388781270804,
|
||||
0.6251750694667361,
|
||||
0.42343097903380306
|
||||
],
|
||||
"projection": "perspective"
|
||||
},
|
||||
"transparent": true,
|
||||
"edges": true,
|
||||
"addMode": true,
|
||||
"selection": {
|
||||
"ids": [
|
||||
"_loft_"
|
||||
],
|
||||
"modelId": "model-1"
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"id": "model-1",
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"transforms": {
|
||||
"model-1": {
|
||||
"__model__": {
|
||||
"position": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"_loft_": {
|
||||
"position": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"design": {
|
||||
"themeVersion": "v3-yellow",
|
||||
"bgOuter": "#0f0f0f",
|
||||
"canvasTop": "#1c1c1c",
|
||||
"canvasBottom": "#1c1c1c",
|
||||
"loadingTop": "#1c1c1c",
|
||||
"loadingBottom": "#1c1c1c",
|
||||
"accent": "#ffea00",
|
||||
"accent2": "#ffea00",
|
||||
"templateFill": "#1c1c1c",
|
||||
"templateOutline": "#233044",
|
||||
"templateOutlineHover": "rgba(255,234,0,0.7)",
|
||||
"projectBtnFill": "#1c1c1c",
|
||||
"projectBtnBorder": "#1c1c1c",
|
||||
"projectBtnHover": "#292929",
|
||||
"modalBtnPrimary": "#befd02",
|
||||
"modalBtnSecondary": "#292929",
|
||||
"loaderColor": "#befd02",
|
||||
"templateGlow": 80,
|
||||
"toolbarBg": "#1c1c1c",
|
||||
"toolbarBgActive": "#ffea00",
|
||||
"toolbarBorder": "#233044",
|
||||
"toolbarOutline": "#ffea00",
|
||||
"modalBg": "#1c1c1c",
|
||||
"modalBgAlpha": 0.95,
|
||||
"modalFocus": "#292929",
|
||||
"modalFocusAlpha": 0.55,
|
||||
"modalBorder": "#292929",
|
||||
"modalElement": "#292929",
|
||||
"modalElementOutline": "#2b2b2b",
|
||||
"modalRadius": "14px",
|
||||
"border": "#233044",
|
||||
"text": "#e6edf3",
|
||||
"panel": "#1c1c1c",
|
||||
"panel2": "#292929",
|
||||
"highlight": "#ffea00",
|
||||
"cubeText": "#242424",
|
||||
"cubeEdges": "#e0e7ff",
|
||||
"plus": "#ffea00",
|
||||
"navCubeBase": "#ffea00",
|
||||
"navCubeHover": "#ffffff",
|
||||
"navCubeText": "#242424",
|
||||
"navCubeEdge": "#e0e7ff",
|
||||
"navCubeEdgeAlpha": 0.6,
|
||||
"measureLine": "#ffea00",
|
||||
"measureLabel": "#ffea00",
|
||||
"measureLabelText": "#292929",
|
||||
"measureDot": "#ffea00"
|
||||
},
|
||||
"meta": {
|
||||
"description": "",
|
||||
"tags": []
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
+557
@@ -0,0 +1,557 @@
|
||||
import http from "http";
|
||||
import {createReadStream, createWriteStream, existsSync} from "fs";
|
||||
import {promises as fs} from "fs";
|
||||
import path from "path";
|
||||
import {fileURLToPath} from "url";
|
||||
import crypto from "crypto";
|
||||
import {pipeline} from "stream/promises";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const DATA_DIR = path.join(__dirname, "data", "projects");
|
||||
const UPLOADS_DIR = path.join(__dirname, "data", "uploads");
|
||||
const INDEX_FILE = path.join(DATA_DIR, "index.json");
|
||||
const FRONTEND_DIST = path.join(__dirname, "..", "frontend", "dist");
|
||||
const FRONTEND_ROOT = path.join(__dirname, "..", "frontend");
|
||||
const PORT = parseInt(process.env.PORT, 10) || 8080;
|
||||
|
||||
const MIME_TYPES = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".glb": "model/gltf-binary",
|
||||
".gltf": "model/gltf+json",
|
||||
".xkt": "application/octet-stream",
|
||||
".las": "application/octet-stream",
|
||||
".laz": "application/octet-stream",
|
||||
".bim": "application/octet-stream",
|
||||
".wasm": "application/wasm",
|
||||
".txt": "text/plain"
|
||||
};
|
||||
|
||||
const ensureStorage = async () => {
|
||||
await fs.mkdir(DATA_DIR, {recursive: true});
|
||||
await fs.mkdir(UPLOADS_DIR, {recursive: true});
|
||||
try {
|
||||
await fs.access(INDEX_FILE);
|
||||
} catch (err) {
|
||||
await fs.writeFile(INDEX_FILE, "[]", "utf8");
|
||||
}
|
||||
};
|
||||
|
||||
const readIndex = async () => {
|
||||
try {
|
||||
const raw = await fs.readFile(INDEX_FILE, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.error("[server] failed to read index.json", err);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeIndex = async (items) => {
|
||||
await fs.writeFile(INDEX_FILE, JSON.stringify(items, null, 2), "utf8");
|
||||
};
|
||||
|
||||
const sendJSON = (res, status, payload) => {
|
||||
setCors(res);
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
const sendText = (res, status, message) => {
|
||||
setCors(res);
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(message);
|
||||
};
|
||||
|
||||
const setCors = (res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
};
|
||||
|
||||
const parseJSONBody = async (req) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (!chunks.length) {
|
||||
return {};
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if (!raw.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
};
|
||||
|
||||
const safeProjectId = (id) => {
|
||||
if (typeof id !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = id.trim();
|
||||
if (!/^[-_a-zA-Z0-9]+$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const STATIC_ROOTS = [
|
||||
{prefix: "/uploads/", dir: UPLOADS_DIR},
|
||||
{prefix: "/data/", dir: path.join(__dirname, "data")},
|
||||
{prefix: "/", dir: FRONTEND_DIST},
|
||||
{prefix: "/", dir: FRONTEND_ROOT}
|
||||
];
|
||||
|
||||
const resolveStaticPath = async (pathname) => {
|
||||
for (const {prefix, dir} of STATIC_ROOTS) {
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
const rel = pathname.slice(prefix.length);
|
||||
const resolved = path.resolve(dir, rel);
|
||||
if (!resolved.startsWith(dir)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stats = await fs.stat(resolved);
|
||||
if (stats.isFile()) {
|
||||
return resolved;
|
||||
}
|
||||
} catch (_) {
|
||||
// keep searching
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const serveStatic = async (req, res, url) => {
|
||||
const pathname = url.pathname || "/";
|
||||
const targetPath = await resolveStaticPath(pathname);
|
||||
if (!targetPath) {
|
||||
const shouldFallback = pathname === "/" || !path.extname(pathname);
|
||||
if (shouldFallback) {
|
||||
const fallbackIndex = await resolveStaticPath("/index.html");
|
||||
if (fallbackIndex) {
|
||||
return serveStatic(req, res, new URL("/index.html", url));
|
||||
}
|
||||
}
|
||||
sendText(res, 404, "Not Found");
|
||||
return;
|
||||
}
|
||||
const ext = path.extname(targetPath).toLowerCase();
|
||||
const mime = MIME_TYPES[ext] || "application/octet-stream";
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", mime);
|
||||
createReadStream(targetPath).pipe(res).on("error", (err) => {
|
||||
console.error("[server] failed to stream file", err);
|
||||
if (!res.headersSent) {
|
||||
sendText(res, 500, "Failed to read file");
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const decodeBase64 = (b64) => {
|
||||
try {
|
||||
return Buffer.from(b64, "base64");
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const sanitizeFilename = (name, fallback) => {
|
||||
if (!name || typeof name !== "string") {
|
||||
return fallback;
|
||||
}
|
||||
const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
return safe || fallback;
|
||||
};
|
||||
|
||||
const processUploads = async (project) => {
|
||||
if (!Array.isArray(project.models)) {
|
||||
return;
|
||||
}
|
||||
const projectDir = path.join(UPLOADS_DIR, project.id);
|
||||
await fs.mkdir(projectDir, {recursive: true});
|
||||
for (let i = 0; i < project.models.length; i++) {
|
||||
const model = project.models[i];
|
||||
if (!model || !model.srcData) {
|
||||
continue;
|
||||
}
|
||||
const data = decodeBase64(model.srcData);
|
||||
if (!data) {
|
||||
throw new Error("Invalid srcData for model upload");
|
||||
}
|
||||
const ext = path.extname(model.srcFilename || "");
|
||||
const baseName = sanitizeFilename(model.srcFilename || `${model.id || "model"}${ext || ".bin"}`, `${model.id || "model"}.bin`);
|
||||
const filePath = path.join(projectDir, baseName);
|
||||
await fs.writeFile(filePath, data);
|
||||
model.src = path.join("uploads", project.id, baseName).replace(/\\/g, "/");
|
||||
delete model.srcData;
|
||||
delete model.srcFilename;
|
||||
delete model.srcMime;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRawUpload = async (req, res, url) => {
|
||||
const filenameParam = url.searchParams.get("filename");
|
||||
const filenameHeader = req.headers["x-filename"];
|
||||
const rawName = filenameParam || (Array.isArray(filenameHeader) ? filenameHeader[0] : filenameHeader) || "upload.bin";
|
||||
const safeName = sanitizeFilename(rawName, "upload.bin");
|
||||
// if client passes projectId in query, group uploads per project
|
||||
const projectId = safeProjectId(url.searchParams.get("projectId"));
|
||||
const uploadId = projectId || (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2));
|
||||
const dir = path.join(UPLOADS_DIR, uploadId);
|
||||
await fs.mkdir(dir, {recursive: true});
|
||||
const targetPath = path.join(dir, safeName);
|
||||
const writeStream = createWriteStream(targetPath);
|
||||
try {
|
||||
await pipeline(req, writeStream);
|
||||
} catch (err) {
|
||||
console.error("[server] upload failed", err);
|
||||
sendText(res, 500, "Upload failed");
|
||||
return;
|
||||
}
|
||||
const src = path.join("uploads", uploadId, safeName).replace(/\\/g, "/");
|
||||
sendJSON(res, 201, {src});
|
||||
};
|
||||
|
||||
const buildProjectPayload = (body) => {
|
||||
const now = new Date().toISOString();
|
||||
const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`;
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
if (!name) {
|
||||
throw new Error("Project name is required");
|
||||
}
|
||||
const type = body.type === "template" ? "template" : "project";
|
||||
const models = Array.isArray(body.models) ? body.models : [];
|
||||
const viewerState = body.viewerState && typeof body.viewerState === "object" ? body.viewerState : {};
|
||||
const design = body.design && typeof body.design === "object" ? body.design : {};
|
||||
const meta = body.meta && typeof body.meta === "object" ? body.meta : {description: "", tags: []};
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
ownerId: null,
|
||||
shares: [],
|
||||
models,
|
||||
viewerState,
|
||||
design,
|
||||
meta
|
||||
};
|
||||
};
|
||||
|
||||
const buildUpdatedProject = (body, existing) => {
|
||||
const now = new Date().toISOString();
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
if (!name) {
|
||||
throw new Error("Project name is required");
|
||||
}
|
||||
const type = body.type === "template" ? "template" : (body.type === "project" ? "project" : (existing.type || "project"));
|
||||
const models = Array.isArray(body.models) ? body.models : (existing.models || []);
|
||||
const viewerState = body.viewerState && typeof body.viewerState === "object" ? body.viewerState : (existing.viewerState || {});
|
||||
const design = body.design && typeof body.design === "object" ? body.design : (existing.design || {});
|
||||
const meta = body.meta && typeof body.meta === "object" ? body.meta : (existing.meta || {description: "", tags: []});
|
||||
|
||||
return {
|
||||
id: existing.id,
|
||||
name,
|
||||
type,
|
||||
createdAt: existing.createdAt || now,
|
||||
updatedAt: now,
|
||||
ownerId: existing.ownerId ?? null,
|
||||
shares: Array.isArray(existing.shares) ? existing.shares : [],
|
||||
models,
|
||||
viewerState,
|
||||
design,
|
||||
meta
|
||||
};
|
||||
};
|
||||
|
||||
const handleGetProjects = async (res, searchParams) => {
|
||||
const filterType = searchParams.get("type");
|
||||
const items = await readIndex();
|
||||
const filtered = filterType ? items.filter((item) => item.type === filterType) : items;
|
||||
sendJSON(res, 200, filtered);
|
||||
};
|
||||
|
||||
const handleGetProject = async (res, projectId) => {
|
||||
const safeId = safeProjectId(projectId);
|
||||
if (!safeId) {
|
||||
sendText(res, 400, "Invalid project id");
|
||||
return;
|
||||
}
|
||||
const projectPath = path.join(DATA_DIR, `${safeId}.json`);
|
||||
if (!existsSync(projectPath)) {
|
||||
sendText(res, 404, "Project not found");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = await fs.readFile(projectPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
sendJSON(res, 200, parsed);
|
||||
} catch (err) {
|
||||
console.error("[server] failed to read project file", err);
|
||||
sendText(res, 500, "Failed to read project");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = async (req, res) => {
|
||||
let payload;
|
||||
try {
|
||||
payload = await parseJSONBody(req);
|
||||
} catch (err) {
|
||||
sendText(res, 400, err.message || "Invalid request body");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const project = buildProjectPayload(payload);
|
||||
await processUploads(project);
|
||||
const projectPath = path.join(DATA_DIR, `${project.id}.json`);
|
||||
await fs.writeFile(projectPath, JSON.stringify(project, null, 2), "utf8");
|
||||
|
||||
const indexItems = await readIndex();
|
||||
const summary = {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
type: project.type,
|
||||
ownerId: project.ownerId,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt
|
||||
};
|
||||
const nextIndex = [summary, ...indexItems.filter((item) => item.id !== project.id)];
|
||||
await writeIndex(nextIndex);
|
||||
|
||||
sendJSON(res, 201, project);
|
||||
} catch (err) {
|
||||
console.error("[server] failed to create project", err);
|
||||
sendText(res, 400, err.message || "Failed to create project");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePatchProject = async (req, res, projectId) => {
|
||||
const safeId = safeProjectId(projectId);
|
||||
if (!safeId) {
|
||||
sendText(res, 400, "Invalid project id");
|
||||
return;
|
||||
}
|
||||
const projectPath = path.join(DATA_DIR, `${safeId}.json`);
|
||||
if (!existsSync(projectPath)) {
|
||||
sendText(res, 404, "Project not found");
|
||||
return;
|
||||
}
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await parseJSONBody(req);
|
||||
} catch (err) {
|
||||
sendText(res, 400, err.message || "Invalid JSON");
|
||||
return;
|
||||
}
|
||||
if (!payload || typeof payload !== "object") {
|
||||
sendText(res, 400, "Invalid payload");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = await fs.readFile(projectPath, "utf8");
|
||||
const project = JSON.parse(raw);
|
||||
const now = new Date().toISOString();
|
||||
if (typeof payload.name === "string") {
|
||||
project.name = payload.name.trim();
|
||||
}
|
||||
if (payload.type === "template" || payload.type === "project") {
|
||||
project.type = payload.type;
|
||||
}
|
||||
project.updatedAt = now;
|
||||
await fs.writeFile(projectPath, JSON.stringify(project, null, 2), "utf8");
|
||||
|
||||
const items = await readIndex();
|
||||
const nextIndex = items.map((item) => {
|
||||
if (item.id !== safeId) return item;
|
||||
return {
|
||||
...item,
|
||||
name: project.name,
|
||||
type: project.type,
|
||||
updatedAt: project.updatedAt
|
||||
};
|
||||
});
|
||||
await writeIndex(nextIndex);
|
||||
sendJSON(res, 200, project);
|
||||
} catch (err) {
|
||||
console.error("[server] failed to patch project", err);
|
||||
sendText(res, 500, "Failed to update project");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePutProject = async (req, res, projectId) => {
|
||||
const safeId = safeProjectId(projectId);
|
||||
if (!safeId) {
|
||||
sendText(res, 400, "Invalid project id");
|
||||
return;
|
||||
}
|
||||
const projectPath = path.join(DATA_DIR, `${safeId}.json`);
|
||||
if (!existsSync(projectPath)) {
|
||||
sendText(res, 404, "Project not found");
|
||||
return;
|
||||
}
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await parseJSONBody(req);
|
||||
} catch (err) {
|
||||
sendText(res, 400, err.message || "Invalid JSON");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = await fs.readFile(projectPath, "utf8");
|
||||
const existing = JSON.parse(raw);
|
||||
const updated = buildUpdatedProject(payload, { ...existing, id: safeId });
|
||||
await fs.writeFile(projectPath, JSON.stringify(updated, null, 2), "utf8");
|
||||
const items = await readIndex();
|
||||
const nextIndex = items.map((item) => {
|
||||
if (item.id !== safeId) return item;
|
||||
return {
|
||||
...item,
|
||||
name: updated.name,
|
||||
type: updated.type,
|
||||
updatedAt: updated.updatedAt
|
||||
};
|
||||
});
|
||||
await writeIndex(nextIndex);
|
||||
sendJSON(res, 200, updated);
|
||||
} catch (err) {
|
||||
console.error("[server] failed to save project", err);
|
||||
sendText(res, 500, "Failed to save project");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async (res, projectId) => {
|
||||
const safeId = safeProjectId(projectId);
|
||||
if (!safeId) {
|
||||
sendText(res, 400, "Invalid project id");
|
||||
return;
|
||||
}
|
||||
const projectPath = path.join(DATA_DIR, `${safeId}.json`);
|
||||
if (!existsSync(projectPath)) {
|
||||
sendText(res, 404, "Project not found");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.unlink(projectPath);
|
||||
} catch (err) {
|
||||
console.error("[server] failed to delete project file", err);
|
||||
}
|
||||
// remove uploads folder if exists
|
||||
const uploadDir = path.join(UPLOADS_DIR, safeId);
|
||||
try {
|
||||
const stat = await fs.stat(uploadDir);
|
||||
if (stat.isDirectory()) {
|
||||
await fs.rm(uploadDir, {recursive: true, force: true});
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
const items = await readIndex();
|
||||
const filtered = items.filter((item) => item.id !== safeId);
|
||||
await writeIndex(filtered);
|
||||
sendJSON(res, 200, {deleted: true, id: safeId});
|
||||
};
|
||||
|
||||
const requestHandler = async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
setCors(res);
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/projects")) {
|
||||
if (req.method === "GET" && url.pathname === "/api/projects") {
|
||||
return handleGetProjects(res, url.searchParams);
|
||||
}
|
||||
if (req.method === "GET" && /^\/api\/projects\/[^/]+/.test(url.pathname)) {
|
||||
const projectId = url.pathname.split("/")[3];
|
||||
return handleGetProject(res, projectId);
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/projects") {
|
||||
return handleCreateProject(req, res);
|
||||
}
|
||||
if (req.method === "PATCH" && /^\/api\/projects\/[^/]+/.test(url.pathname)) {
|
||||
const projectId = url.pathname.split("/")[3];
|
||||
return handlePatchProject(req, res, projectId);
|
||||
}
|
||||
if (req.method === "PUT" && /^\/api\/projects\/[^/]+/.test(url.pathname)) {
|
||||
const projectId = url.pathname.split("/")[3];
|
||||
return handlePutProject(req, res, projectId);
|
||||
}
|
||||
if (req.method === "DELETE" && /^\/api\/projects\/[^/]+/.test(url.pathname)) {
|
||||
const projectId = url.pathname.split("/")[3];
|
||||
return handleDeleteProject(res, projectId);
|
||||
}
|
||||
sendText(res, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/uploads") {
|
||||
return handleRawUpload(req, res, url);
|
||||
}
|
||||
|
||||
await serveStatic(req, res, url);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
await ensureStorage();
|
||||
|
||||
const startServer = (port, retries = 3) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
requestHandler(req, res).catch((err) => {
|
||||
console.error("[server] unhandled error", err);
|
||||
sendText(res, 500, "Internal Server Error");
|
||||
});
|
||||
});
|
||||
|
||||
server.on("error", (err) => {
|
||||
if (err.code === "EADDRINUSE" && retries > 0) {
|
||||
const nextPort = port + 1;
|
||||
console.warn(`[server] port ${port} is busy, trying ${nextPort}...`);
|
||||
startServer(nextPort, retries - 1);
|
||||
} else {
|
||||
console.error("[server] failed to start", err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`[server] listening on http://localhost:${port}`);
|
||||
});
|
||||
};
|
||||
|
||||
startServer(PORT);
|
||||
};
|
||||
|
||||
start().catch((err) => {
|
||||
console.error("[server] failed to start", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "xeokit-bim-viewer-server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"description": "Lightweight file-based API for xeokit BIM viewer projects",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user