Initial import of NODEDC BIM Viewer
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Frontend
|
||||
|
||||
Статический клиент переехал в директорию `frontend/`. Точка входа — `index.html` (использует модульный `dcViewer.js` и стили/ассеты из этой папки).
|
||||
|
||||
Быстрый запуск
|
||||
- Совместно с API: `npm --prefix server start` (по умолчанию PORT=8080; если занят, сервер попробует 8081/8082/8083). Открыть http://localhost:<порт>.
|
||||
- Только фронт (без API): `npx http-server frontend -p 9001` и открыть http://localhost:9001
|
||||
|
||||
API-запросы идут на тот же origin (`/api/projects`), поэтому при отдельном поднятии фронта без сервера API работать не будет.
|
||||
|
||||
Снимок проекта собирается на фронте (модели, состояние камеры/виджетов, трансформы, дизайн) и отправляется POST `/api/projects`.
|
||||
@@ -0,0 +1 @@
|
||||
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280.05 54.55"><defs><style>.cls-1{fill:#e2e1e1;}</style></defs><path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"/><polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/><path class="cls-1" d="M115,18.56V1h5.63c5.51,0,8.86,3.52,8.86,8.8s-3.35,8.81-8.86,8.81Zm2.19-2h3.44c4.28,0,6.62-2.69,6.62-6.79S124.94,3,120.66,3h-3.44Z"/><path class="cls-1" d="M131.64,9.75c0-5.33,3.29-8.95,8.4-8.95,3.9,0,6.74,2.26,7.4,5.91h-2.32a5.07,5.07,0,0,0-5.18-3.9c-3.7,0-6.06,2.87-6.06,6.94s2.26,6.94,6,6.94a5.05,5.05,0,0,0,5.26-3.89h2.31c-.65,3.64-3.57,5.91-7.55,5.91C134.81,18.71,131.64,15.14,131.64,9.75Z"/><path class="cls-1" d="M156.65,1h6.24c3.75,0,5.84,1.81,5.84,4.65A3.82,3.82,0,0,1,166,9.43a3.94,3.94,0,0,1,3.5,4.1c0,3.11-2.21,5-6.39,5h-6.42Zm2.19,2V8.59H163c2.27,0,3.55-1,3.55-2.84S165.28,2.94,163,2.94Zm0,7.62v6h4.23c2.72,0,4.15-1.13,4.15-2.94,0-2-1.53-3.07-4.15-3.07Z"/><path class="cls-1" d="M172.21,1h2.19V18.56h-2.19Z"/><path class="cls-1" d="M177.82,1h2.77l6.24,14.61L193,1h2.72V18.56h-2.14V4.67l-5.84,13.89H185.8L180,4.67V18.56h-2.14Z"/><path class="cls-1" d="M204.25,1h2.31L212,16,217.3,1h2.27l-6.34,17.61h-2.64Z"/><path class="cls-1" d="M220.94,2.23a1.33,1.33,0,1,1,2.66,0,1.33,1.33,0,1,1-2.66,0Zm.28,3.5h2.06V18.56h-2.06Z"/><path class="cls-1" d="M225.65,12.14c0-3.9,2.46-6.56,6.08-6.56s6,2.29,6.09,6.08c0,.28,0,.58,0,.89h-10v.17a3.93,3.93,0,0,0,4.05,4.15,3.52,3.52,0,0,0,3.65-2.61h2.08A5.45,5.45,0,0,1,232,18.71C228.16,18.71,225.65,16.07,225.65,12.14Zm10-1.31a3.57,3.57,0,0,0-3.85-3.44,3.81,3.81,0,0,0-3.83,3.44Z"/><path class="cls-1" d="M238.56,5.73h2.16l3.19,10.44,3.38-10.44h1.91l3.19,10.44,3.35-10.44h2.09l-4.28,12.83h-2.26L248.19,9,245,18.56h-2.26Z"/><path class="cls-1" d="M258.57,12.14c0-3.9,2.46-6.56,6.08-6.56s6,2.29,6.09,6.08c0,.28,0,.58-.05.89h-10v.17a3.93,3.93,0,0,0,4,4.15,3.52,3.52,0,0,0,3.65-2.61h2.08a5.45,5.45,0,0,1-5.58,4.45C261.08,18.71,258.57,16.07,258.57,12.14Zm10-1.31a3.57,3.57,0,0,0-3.85-3.44,3.81,3.81,0,0,0-3.83,3.44Z"/><path class="cls-1" d="M279.63,5.73v2h-1c-2.7,0-3.42,2.24-3.42,4.28v6.54h-2.07V5.73h1.79l.28,1.93a4,4,0,0,1,3.87-1.93Z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"examples": [
|
||||
{
|
||||
"id": "ifc-xkt",
|
||||
"name": "IFC / XKT пример",
|
||||
"description": "Стандартная BIM-модель Schependomlaan.",
|
||||
"type": "xkt",
|
||||
"url": "./examples/ifc-xkt/Schependomlaan.ifc.xkt"
|
||||
},
|
||||
{
|
||||
"id": "glb",
|
||||
"name": "GLB пример",
|
||||
"description": "Городской фрагмент MAP в формате glTF/GLB.",
|
||||
"type": "gltf",
|
||||
"url": "./examples/glb/MAP.glb"
|
||||
},
|
||||
{
|
||||
"id": "pointcloud",
|
||||
"name": "Point Cloud пример",
|
||||
"description": "Облако точек Nalls Pumpkin Hill (LAZ).",
|
||||
"type": "las",
|
||||
"url": "./examples/pointcloud/Nalls_Pumpkin_Hill.laz"
|
||||
},
|
||||
{
|
||||
"id": "bim",
|
||||
"name": "BIM (.bim) пример",
|
||||
"description": "Тестовая BIM-модель BlenderHouse в формате .bim.",
|
||||
"type": "bim",
|
||||
"url": "./examples/bim/BlenderHouse.bim"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"examples": [
|
||||
{
|
||||
"id": "ifc-xkt",
|
||||
"name": "IFC / XKT пример",
|
||||
"description": "Стандартная BIM-модель Schependomlaan.",
|
||||
"type": "xkt",
|
||||
"url": "./examples/ifc-xkt/Schependomlaan.ifc.xkt"
|
||||
},
|
||||
{
|
||||
"id": "glb",
|
||||
"name": "GLB пример",
|
||||
"description": "Городской фрагмент MAP в формате glTF/GLB.",
|
||||
"type": "gltf",
|
||||
"url": "./examples/glb/MAP.glb"
|
||||
},
|
||||
{
|
||||
"id": "pointcloud",
|
||||
"name": "Point Cloud пример",
|
||||
"description": "Облако точек Nalls Pumpkin Hill (LAZ).",
|
||||
"type": "las",
|
||||
"url": "./examples/pointcloud/Nalls_Pumpkin_Hill.laz"
|
||||
},
|
||||
{
|
||||
"id": "bim",
|
||||
"name": "BIM (.bim) пример",
|
||||
"description": "Тестовая BIM-модель BlenderHouse в формате .bim.",
|
||||
"type": "bim",
|
||||
"url": "./examples/bim/BlenderHouse.bim"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,228 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BIM DC Viewer</title>
|
||||
<link rel="icon" href="./assets/logo.svg">
|
||||
<style id="preload-hidden">body{opacity:0;}</style>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap">
|
||||
<script src="./theme.js"></script>
|
||||
<script>
|
||||
// Apply persisted theme ASAP to avoid initial flash of defaults
|
||||
(function () {
|
||||
const STORAGE_KEY = "bimdc-settings-v3";
|
||||
const LEGACY_KEYS = ["bimdc-settings", "bimdc-settings-v2"];
|
||||
try {
|
||||
LEGACY_KEYS.forEach((k) => localStorage.removeItem(k));
|
||||
|
||||
const project = window.__BIMDC_THEME__ || {};
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
let stored = null;
|
||||
try {
|
||||
stored = raw ? JSON.parse(raw) : null;
|
||||
} catch (e) {
|
||||
stored = null;
|
||||
}
|
||||
const storedVersion = stored && stored.themeVersion;
|
||||
const projectVersion = project && project.themeVersion;
|
||||
if (!stored || storedVersion !== projectVersion) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
|
||||
stored = { ...project };
|
||||
}
|
||||
const s = { ...(project || {}), ...(stored || {}) };
|
||||
if (!Object.keys(s).length) return;
|
||||
const root = document.documentElement;
|
||||
const set = (k, v) => v !== undefined && root.style.setProperty(k, v);
|
||||
set("--bg-outer", s.bgOuter);
|
||||
set("--canvas-top", s.canvasTop);
|
||||
set("--canvas-bottom", s.canvasBottom);
|
||||
set("--loading-top", s.loadingTop);
|
||||
set("--loading-bottom", s.loadingBottom);
|
||||
set("--accent", s.accent);
|
||||
set("--accent-2", s.accent2);
|
||||
set("--template-fill", s.templateFill);
|
||||
set("--template-outline", s.templateOutline);
|
||||
set("--template-outline-hover", s.templateOutlineHover);
|
||||
set("--template-glow-strength", `${s.templateGlow}%`);
|
||||
set("--toolbar-bg", s.toolbarBg);
|
||||
set("--toolbar-bg-active", s.toolbarBgActive);
|
||||
set("--toolbar-border", s.toolbarBorder);
|
||||
set("--toolbar-outline", s.toolbarOutline);
|
||||
set("--modal-bg", s.modalBg);
|
||||
set("--modal-focus", s.modalFocus);
|
||||
set("--modal-border", s.modalBorder);
|
||||
set("--modal-element", s.modalElement);
|
||||
set("--modal-element-outline", s.modalElementOutline);
|
||||
set("--modal-radius", s.modalRadius);
|
||||
set("--project-btn-fill", s.projectBtnFill);
|
||||
set("--project-btn-border", s.projectBtnBorder);
|
||||
set("--project-btn-hover", s.projectBtnHover);
|
||||
set("--modal-btn-primary", s.modalBtnPrimary);
|
||||
set("--modal-btn-secondary", s.modalBtnSecondary);
|
||||
set("--loader-color", s.loaderColor);
|
||||
set("--border", s.border);
|
||||
set("--text", s.text);
|
||||
set("--panel", s.panel);
|
||||
set("--panel-2", s.panel2);
|
||||
set("--highlight-color", s.highlight);
|
||||
set("--cube-text", s.navCubeText || s.cubeText);
|
||||
set("--cube-edges", s.navCubeBase || s.cubeEdges);
|
||||
set("--cube-base", s.navCubeBase || s.cubeEdges);
|
||||
set("--cube-hover", s.navCubeHover);
|
||||
set("--cube-edge-color", s.navCubeEdge);
|
||||
set("--cube-edge-alpha", s.navCubeEdgeAlpha);
|
||||
set("--plus-color", s.plus);
|
||||
set("--measure-line", s.measureLine);
|
||||
set("--measure-label", s.measureLabel);
|
||||
set("--measure-label-text", s.measureLabelText);
|
||||
set("--measure-dot", s.measureDot);
|
||||
} catch (e) {
|
||||
// ignore corrupted settings
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="./dcViewer.css?v=3">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<button class="logo-btn" id="homeButton" title="Вернуться к старту"></button>
|
||||
<div class="toolbar"></div>
|
||||
<div class="hidden-controls" style="display:none">
|
||||
<input type="checkbox" id="toggleEdges" checked>
|
||||
<input type="checkbox" id="toggleTransparent" checked>
|
||||
<input type="checkbox" id="addMode" checked>
|
||||
<button id="fitView"></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<section class="viewer-wrapper">
|
||||
<canvas id="viewerCanvas"></canvas>
|
||||
<canvas id="navCube"></canvas>
|
||||
<div class="drop-hint" id="dropZone">
|
||||
<div class="drop-center">
|
||||
<div class="drop-plus">+</div>
|
||||
<div class="drop-formats">
|
||||
<div class="muted">IFC · BIM · LAS · LAZ</div>
|
||||
<div class="muted">GLB · GLTF · OBJ · STL</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-bar" id="status">
|
||||
<span class="dot"></span>
|
||||
<span id="statusText">Готов к загрузке</span>
|
||||
</div>
|
||||
<div class="loader hidden" id="loader">
|
||||
<div class="loader__figure"></div>
|
||||
<p class="loader__label">DC</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="side-panel">
|
||||
<div class="panel-section">
|
||||
<h3>Инспектор</h3>
|
||||
<div id="treeContainer" class="tree"></div>
|
||||
</div>
|
||||
<div class="panel-section" id="projectNameSection">
|
||||
<h3>Название проекта</h3>
|
||||
<input type="text" id="projectNameInput" class="project-name-input" placeholder="Проект" autocomplete="off">
|
||||
<div class="error-text inline-error" id="projectNameError"></div>
|
||||
</div>
|
||||
<div class="panel-section">
|
||||
<h3>Глобальные координаты</h3>
|
||||
<div class="muted" style="margin-bottom:6px;">Центр выделения (мировые)</div>
|
||||
<div class="transform-grid">
|
||||
<label>X <input type="text" id="globalX"></label>
|
||||
<label>Y <input type="text" id="globalY"></label>
|
||||
<label>Z <input type="text" id="globalZ"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-section">
|
||||
<h3>Трансформации</h3>
|
||||
<div class="muted" style="margin-bottom:6px;">Перемещение</div>
|
||||
<div class="transform-grid">
|
||||
<label>X <input type="text" id="posX"></label>
|
||||
<label>Y <input type="text" id="posY"></label>
|
||||
<label>Z <input type="text" id="posZ"></label>
|
||||
</div>
|
||||
<div class="muted" style="margin:8px 0 6px;">Вращение (deg)</div>
|
||||
<div class="transform-grid">
|
||||
<label>X <input type="text" id="rotX"></label>
|
||||
<label>Y <input type="text" id="rotY"></label>
|
||||
<label>Z <input type="text" id="rotZ"></label>
|
||||
</div>
|
||||
<div class="muted" style="margin:8px 0 6px;">Масштаб</div>
|
||||
<div class="transform-grid">
|
||||
<label>X <input type="text" id="scaleX"></label>
|
||||
<label>Y <input type="text" id="scaleY"></label>
|
||||
<label>Z <input type="text" id="scaleZ"></label>
|
||||
</div>
|
||||
<small id="transformHint" class="muted">Выберите объект в инспекторе или сцене, чтобы редактировать.</small>
|
||||
</div>
|
||||
<div class="panel-section">
|
||||
<h3>Журнал</h3>
|
||||
<div id="logList" class="log"></div>
|
||||
</div>
|
||||
<div class="panel-section" id="saveProjectSection">
|
||||
<button class="primary-btn" id="saveProjectButton" type="button">Сохранить проект</button>
|
||||
<button class="secondary-btn danger-btn" id="deleteProjectButton" type="button">Удалить проект</button>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div id="templatesPanel" class="templates-panel hidden"></div>
|
||||
<div id="settingsPanel" class="settings-panel hidden"></div>
|
||||
<div id="measureModalBackdrop" class="modal-backdrop hidden"></div>
|
||||
<div id="measureModal" class="templates-panel measurement-modal hidden"></div>
|
||||
<div id="objectModalBackdrop" class="modal-backdrop hidden"></div>
|
||||
<div id="objectModal" class="templates-panel measurement-modal hidden"></div>
|
||||
<div id="saveProjectBackdrop" class="modal-backdrop hidden"></div>
|
||||
<div id="saveProjectModal" class="save-project-modal hidden">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Сохранить проект</div>
|
||||
<button class="modal-close" id="saveProjectClose" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
<label for="saveProjectName" class="input-label">Имя проекта</label>
|
||||
<input type="text" id="saveProjectName" placeholder="Новый проект" autocomplete="off">
|
||||
<div class="input-hint" id="saveProjectHint">Введите имя, чтобы сохранить проект в хранилище</div>
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" id="saveProjectCancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="saveProjectConfirm" type="button" disabled>Сохранить</button>
|
||||
<button class="secondary-btn" id="saveProjectDuplicate" type="button" disabled>Сохранить как новый</button>
|
||||
</div>
|
||||
<div class="error-text" id="saveProjectError"></div>
|
||||
</div>
|
||||
<div id="deleteProjectBackdrop" class="modal-backdrop hidden"></div>
|
||||
<div id="deleteProjectModal" class="save-project-modal hidden">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Удалить проект</div>
|
||||
<button class="modal-close" id="deleteProjectClose" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
<div class="input-hint">Вы уверены, что хотите удалить проект?</div>
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" id="deleteProjectCancel" type="button">Отмена</button>
|
||||
<button class="primary-btn danger-btn" id="deleteProjectConfirm" type="button">Удалить</button>
|
||||
</div>
|
||||
<div class="error-text" id="deleteProjectError"></div>
|
||||
</div>
|
||||
|
||||
<div id="bottomToolbar" class="bottom-toolbar">
|
||||
<button class="tb-btn" data-action="settings" title="Настройки">⚙</button>
|
||||
<button class="tb-btn" data-action="templates" title="Примеры">📂</button>
|
||||
<button class="tb-btn" data-action="measure" title="Линейка">📏</button>
|
||||
<button class="tb-btn" data-action="projection" title="Перспектива / аксонометрия">A</button>
|
||||
<button class="tb-btn" data-action="add" title="Добавить файл">+</button>
|
||||
</div>
|
||||
|
||||
<input id="fileInput" class="hidden" type="file"
|
||||
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
|
||||
|
||||
<script type="module" src="./dcViewer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
|
||||
export function createSliderControl({ label = "", value = 0, min = 0, max = 100, step = 1, onChange }) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "slider-row";
|
||||
|
||||
const lbl = document.createElement("label");
|
||||
lbl.textContent = label;
|
||||
lbl.className = "slider-label";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "range";
|
||||
input.className = "slider-input";
|
||||
input.min = min;
|
||||
input.max = max;
|
||||
input.step = step;
|
||||
input.value = value;
|
||||
|
||||
const updateFill = () => {
|
||||
const percent = ((input.value - input.min) / (input.max - input.min)) * 100;
|
||||
input.style.setProperty("--slider-percent", `${percent}%`);
|
||||
};
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
updateFill();
|
||||
const v = Number(input.value);
|
||||
onChange?.(v);
|
||||
});
|
||||
|
||||
updateFill();
|
||||
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(input);
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function createLogo({
|
||||
size = 160, // ширина контейнера (px)
|
||||
aspect = 4, // соотношение width / height контейнера
|
||||
offsetY = 0,
|
||||
paddingX = 0,
|
||||
imageUrl = "./assets/logo.svg",
|
||||
} = {}) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "ue-logo";
|
||||
wrapper.style.width = `${size}px`;
|
||||
wrapper.style.height = `${size / aspect}px`;
|
||||
wrapper.style.marginLeft = `${paddingX}px`;
|
||||
wrapper.style.marginRight = `${paddingX}px`;
|
||||
wrapper.style.transform = `translateY(${offsetY}px)`;
|
||||
const img = document.createElement("img");
|
||||
img.src = imageUrl;
|
||||
img.alt = "logo";
|
||||
img.style.width = "100%";
|
||||
img.style.height = "100%";
|
||||
img.style.objectFit = "contain";
|
||||
img.style.display = "block";
|
||||
wrapper.appendChild(img);
|
||||
return wrapper;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
export function createMeasurementModal({ panel, backdrop, onDelete, onToggleVisibility, onClose }) {
|
||||
let activeMeasurementId = null;
|
||||
let currentVisible = true;
|
||||
|
||||
const hide = () => {
|
||||
if (panel) {
|
||||
panel.classList.add("hidden");
|
||||
panel.style.display = "";
|
||||
}
|
||||
if (backdrop) {
|
||||
backdrop.classList.add("hidden");
|
||||
backdrop.style.display = "";
|
||||
}
|
||||
activeMeasurementId = null;
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
if (!panel) return;
|
||||
panel.innerHTML = "";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "templates-header";
|
||||
header.innerHTML = `<span class="templates-title">Размер</span>`;
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "templates-close";
|
||||
closeBtn.textContent = "×";
|
||||
closeBtn.addEventListener("click", hide);
|
||||
header.appendChild(closeBtn);
|
||||
panel.appendChild(header);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "template-list";
|
||||
panel.appendChild(list);
|
||||
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "example-card";
|
||||
deleteBtn.textContent = "Удалить размер";
|
||||
deleteBtn.addEventListener("click", () => {
|
||||
if (!activeMeasurementId) return;
|
||||
onDelete?.(activeMeasurementId);
|
||||
});
|
||||
list.appendChild(deleteBtn);
|
||||
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.className = "example-card";
|
||||
toggleBtn.textContent = currentVisible ? "Скрыть размер" : "Показать размер";
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
if (!activeMeasurementId) return;
|
||||
const nextVisible = !currentVisible;
|
||||
onToggleVisibility?.(activeMeasurementId, nextVisible);
|
||||
currentVisible = nextVisible;
|
||||
toggleBtn.textContent = currentVisible ? "Скрыть размер" : "Показать размер";
|
||||
});
|
||||
list.appendChild(toggleBtn);
|
||||
};
|
||||
|
||||
const show = ({ measurementId, visible = true } = {}) => {
|
||||
activeMeasurementId = measurementId || null;
|
||||
currentVisible = visible !== false;
|
||||
render();
|
||||
if (panel) {
|
||||
panel.classList.remove("hidden");
|
||||
panel.style.display = "grid";
|
||||
}
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove("hidden");
|
||||
backdrop.style.display = "block";
|
||||
}
|
||||
};
|
||||
|
||||
const setVisibility = (visible) => {
|
||||
currentVisible = !!visible;
|
||||
render();
|
||||
if (panel && !panel.classList.contains("hidden")) {
|
||||
panel.style.display = "grid";
|
||||
}
|
||||
};
|
||||
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener("click", hide);
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
hide,
|
||||
setVisibility,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export function createObjectModal({ panel, backdrop, onDelete, onClose }) {
|
||||
let activeTarget = null;
|
||||
|
||||
const hide = () => {
|
||||
if (panel) {
|
||||
panel.classList.add("hidden");
|
||||
panel.style.display = "";
|
||||
}
|
||||
if (backdrop) {
|
||||
backdrop.classList.add("hidden");
|
||||
backdrop.style.display = "";
|
||||
}
|
||||
activeTarget = null;
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
if (!panel) return;
|
||||
panel.innerHTML = "";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "templates-header";
|
||||
header.innerHTML = `<span class="templates-title">Объект</span>`;
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "templates-close";
|
||||
closeBtn.textContent = "×";
|
||||
closeBtn.addEventListener("click", hide);
|
||||
header.appendChild(closeBtn);
|
||||
panel.appendChild(header);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "template-list";
|
||||
panel.appendChild(list);
|
||||
|
||||
if (activeTarget?.label) {
|
||||
const label = document.createElement("div");
|
||||
label.className = "muted";
|
||||
label.style.margin = "0 0 8px";
|
||||
label.textContent = activeTarget.label;
|
||||
list.appendChild(label);
|
||||
}
|
||||
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "example-card danger-btn";
|
||||
deleteBtn.textContent = "Удалить объект";
|
||||
deleteBtn.addEventListener("click", () => {
|
||||
if (!activeTarget?.modelId) return;
|
||||
onDelete?.(activeTarget);
|
||||
});
|
||||
list.appendChild(deleteBtn);
|
||||
};
|
||||
|
||||
const show = ({ modelId, label } = {}) => {
|
||||
activeTarget = { modelId, label };
|
||||
render();
|
||||
if (panel) {
|
||||
panel.classList.remove("hidden");
|
||||
panel.style.display = "grid";
|
||||
}
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove("hidden");
|
||||
backdrop.style.display = "block";
|
||||
}
|
||||
};
|
||||
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener("click", hide);
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
hide,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { createSliderControl } from "./controls/slider.js";
|
||||
|
||||
const defaultSettings = {
|
||||
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: "#233044",
|
||||
projectBtnHover: "rgba(255,234,0,0.7)",
|
||||
modalBtnPrimary: "#ffea00",
|
||||
modalBtnSecondary: "#292929",
|
||||
loaderColor: "#ffea00",
|
||||
templateGlow: 80,
|
||||
toolbarBg: "#1c1c1c",
|
||||
toolbarBgActive: "#ffea00",
|
||||
toolbarBorder: "#233044",
|
||||
toolbarOutline: "#ffea00",
|
||||
modalBg: "#1c1c1c",
|
||||
modalBgAlpha: 1,
|
||||
modalFocus: "#292929",
|
||||
modalFocusAlpha: 1,
|
||||
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",
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "bimdc-settings-v3";
|
||||
const LEGACY_KEYS = ["bimdc-settings", "bimdc-settings-v2"];
|
||||
const projectSettings = typeof window !== "undefined" && window.__BIMDC_THEME__
|
||||
? { ...defaultSettings, ...window.__BIMDC_THEME__ }
|
||||
: { ...defaultSettings };
|
||||
|
||||
const applySettings = (s) => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--bg-outer", s.bgOuter);
|
||||
root.style.setProperty("--canvas-top", s.canvasTop);
|
||||
root.style.setProperty("--canvas-bottom", s.canvasBottom);
|
||||
root.style.setProperty("--loading-top", s.loadingTop);
|
||||
root.style.setProperty("--loading-bottom", s.loadingBottom);
|
||||
root.style.setProperty("--accent", s.accent);
|
||||
root.style.setProperty("--accent-2", s.accent2);
|
||||
root.style.setProperty("--template-fill", s.templateFill);
|
||||
root.style.setProperty("--template-outline", s.templateOutline);
|
||||
root.style.setProperty("--template-outline-hover", s.templateOutlineHover);
|
||||
root.style.setProperty("--project-btn-fill", s.projectBtnFill);
|
||||
root.style.setProperty("--project-btn-border", s.projectBtnBorder);
|
||||
root.style.setProperty("--project-btn-hover", s.projectBtnHover);
|
||||
root.style.setProperty("--modal-btn-primary", s.modalBtnPrimary);
|
||||
root.style.setProperty("--modal-btn-secondary", s.modalBtnSecondary);
|
||||
root.style.setProperty("--loader-color", s.loaderColor);
|
||||
root.style.setProperty("--template-glow-strength", `${s.templateGlow}%`);
|
||||
root.style.setProperty("--toolbar-bg", s.toolbarBg);
|
||||
root.style.setProperty("--toolbar-bg-active", s.toolbarBgActive);
|
||||
root.style.setProperty("--toolbar-border", s.toolbarBorder);
|
||||
root.style.setProperty("--toolbar-outline", s.toolbarOutline);
|
||||
const bgAlphaPct = `${(s.modalBgAlpha ?? 1) * 100}%`;
|
||||
const focusAlphaPct = `${(s.modalFocusAlpha ?? 1) * 100}%`;
|
||||
root.style.setProperty("--modal-bg", s.modalBg);
|
||||
root.style.setProperty("--modal-bg-alpha", bgAlphaPct);
|
||||
root.style.setProperty("--modal-focus", s.modalFocus);
|
||||
root.style.setProperty("--modal-focus-alpha", focusAlphaPct);
|
||||
root.style.setProperty("--modal-border", s.modalBorder);
|
||||
root.style.setProperty("--modal-element", s.modalElement);
|
||||
root.style.setProperty("--modal-element-outline", s.modalElementOutline);
|
||||
root.style.setProperty("--modal-radius", s.modalRadius);
|
||||
root.style.setProperty("--border", s.border);
|
||||
root.style.setProperty("--text", s.text);
|
||||
root.style.setProperty("--panel", s.panel);
|
||||
root.style.setProperty("--panel-2", s.panel2);
|
||||
root.style.setProperty("--highlight-color", s.highlight);
|
||||
root.style.setProperty("--cube-text", s.cubeText);
|
||||
root.style.setProperty("--cube-edges", s.navCubeBase || s.cubeEdges);
|
||||
root.style.setProperty("--cube-base", s.navCubeBase || s.cubeEdges);
|
||||
root.style.setProperty("--cube-hover", s.navCubeHover);
|
||||
root.style.setProperty("--cube-text", s.navCubeText || s.cubeText);
|
||||
root.style.setProperty("--cube-edge-color", s.navCubeEdge);
|
||||
root.style.setProperty("--cube-edge-alpha", s.navCubeEdgeAlpha);
|
||||
root.style.setProperty("--plus-color", s.plus);
|
||||
root.style.setProperty("--template-fill", s.templateFill);
|
||||
root.style.setProperty("--template-outline", s.templateOutline);
|
||||
root.style.setProperty("--measure-line", s.measureLine);
|
||||
root.style.setProperty("--measure-label", s.measureLabel);
|
||||
root.style.setProperty("--measure-label-text", s.measureLabelText);
|
||||
root.style.setProperty("--measure-dot", s.measureDot);
|
||||
// подсветка объектов
|
||||
root.style.setProperty("--highlight-color", s.highlight);
|
||||
};
|
||||
|
||||
const saveSettings = (s) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = () => {
|
||||
try {
|
||||
// purge legacy keys so старые палитры не мешали
|
||||
LEGACY_KEYS.forEach((k) => localStorage.removeItem(k));
|
||||
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
// зашиваем проектные настройки обратно в storage, чтобы при чистом кэше они сразу подхватывались
|
||||
saveSettings(projectSettings);
|
||||
return { ...projectSettings };
|
||||
}
|
||||
const stored = JSON.parse(raw);
|
||||
if (stored.themeVersion && stored.themeVersion !== projectSettings.themeVersion) {
|
||||
saveSettings(projectSettings);
|
||||
return { ...projectSettings };
|
||||
}
|
||||
return { ...projectSettings, ...stored };
|
||||
} catch (e) {
|
||||
saveSettings(projectSettings);
|
||||
return { ...projectSettings };
|
||||
}
|
||||
};
|
||||
|
||||
export function createSettingsMenu({ panel, controls = {}, onChange = null, onShow, onHide }) {
|
||||
let state = loadSettings();
|
||||
applySettings(state);
|
||||
|
||||
const getState = () => ({ ...state });
|
||||
|
||||
const applyStateExternal = (nextState = {}) => {
|
||||
state = { ...state, ...nextState };
|
||||
applySettings(state);
|
||||
saveSettings(state);
|
||||
if (typeof onChange === "function") {
|
||||
onChange({ key: null, state });
|
||||
}
|
||||
if (panel && !panel.classList.contains("hidden")) {
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
if (panel) {
|
||||
panel.classList.add("hidden");
|
||||
panel.style.display = "";
|
||||
}
|
||||
onHide?.();
|
||||
};
|
||||
|
||||
const setVal = (key, val) => {
|
||||
state = { ...state, [key]: val };
|
||||
applySettings(state);
|
||||
saveSettings(state);
|
||||
if (typeof onChange === "function") {
|
||||
onChange({ key, value: val, state });
|
||||
}
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
if (!panel) return;
|
||||
panel.innerHTML = "";
|
||||
const header = document.createElement("div");
|
||||
header.className = "settings-header";
|
||||
header.innerHTML = `<span class="settings-title">Настройки</span>`;
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "settings-close";
|
||||
closeBtn.textContent = "—";
|
||||
closeBtn.addEventListener("click", hide);
|
||||
header.appendChild(closeBtn);
|
||||
panel.appendChild(header);
|
||||
|
||||
const section = (title) => {
|
||||
const box = document.createElement("div");
|
||||
box.className = "settings-section";
|
||||
const t = document.createElement("div");
|
||||
t.className = "section-title";
|
||||
t.textContent = title;
|
||||
box.appendChild(t);
|
||||
return box;
|
||||
};
|
||||
|
||||
const addColor = (parent, label, key) => {
|
||||
const isHex = (val) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test((val || "").trim());
|
||||
const row = document.createElement("div");
|
||||
row.className = "color-row";
|
||||
const lbl = document.createElement("label");
|
||||
lbl.textContent = label;
|
||||
const input = document.createElement("input");
|
||||
input.type = "color";
|
||||
input.value = isHex(state[key]) ? state[key] : "#ffffff";
|
||||
const hex = document.createElement("input");
|
||||
hex.type = "text";
|
||||
hex.value = state[key];
|
||||
input.addEventListener("change", (e) => {
|
||||
hex.value = e.target.value;
|
||||
setVal(key, e.target.value);
|
||||
});
|
||||
hex.addEventListener("change", (e) => {
|
||||
input.value = e.target.value;
|
||||
setVal(key, e.target.value);
|
||||
});
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(input);
|
||||
row.appendChild(hex);
|
||||
parent.appendChild(row);
|
||||
};
|
||||
|
||||
const font = section("Фон");
|
||||
addColor(font, "Фон внешний", "bgOuter");
|
||||
addColor(font, "Фон холста верх", "canvasTop");
|
||||
addColor(font, "Фон холста низ", "canvasBottom");
|
||||
addColor(font, "Фон загрузки верх", "loadingTop");
|
||||
addColor(font, "Фон загрузки низ", "loadingBottom");
|
||||
panel.appendChild(font);
|
||||
|
||||
const buttons = section("Кнопки");
|
||||
addColor(buttons, "Плюс", "plus");
|
||||
addColor(buttons, "Кнопки (заливка)", "templateFill");
|
||||
addColor(buttons, "Кнопки (бордер)", "templateOutline");
|
||||
addColor(buttons, "Кнопки (outline hover)", "templateOutlineHover");
|
||||
const brightnessSlider = createSliderControl({
|
||||
label: "Яркость подсветки",
|
||||
value: Number(state.templateGlow ?? 80),
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
onChange: (val) => setVal("templateGlow", val),
|
||||
});
|
||||
buttons.appendChild(brightnessSlider);
|
||||
panel.appendChild(buttons);
|
||||
|
||||
const projectButtons = section("Кнопки проектов");
|
||||
addColor(projectButtons, "Проекты (заливка)", "projectBtnFill");
|
||||
addColor(projectButtons, "Проекты (бордер)", "projectBtnBorder");
|
||||
addColor(projectButtons, "Проекты (hover)", "projectBtnHover");
|
||||
panel.appendChild(projectButtons);
|
||||
|
||||
const modalButtons = section("Кнопки модалок");
|
||||
addColor(modalButtons, "Модальная primary", "modalBtnPrimary");
|
||||
addColor(modalButtons, "Модальная secondary", "modalBtnSecondary");
|
||||
addColor(modalButtons, "Лоудер", "loaderColor");
|
||||
panel.appendChild(modalButtons);
|
||||
|
||||
const toolbar = section("Toolbar");
|
||||
addColor(toolbar, "Фон", "toolbarBg");
|
||||
addColor(toolbar, "Актив", "toolbarBgActive");
|
||||
addColor(toolbar, "Бордер", "toolbarBorder");
|
||||
addColor(toolbar, "Outline", "toolbarOutline");
|
||||
panel.appendChild(toolbar);
|
||||
|
||||
const navCube = section("NavCube");
|
||||
addColor(navCube, "Грани", "navCubeBase");
|
||||
addColor(navCube, "Hover", "navCubeHover");
|
||||
addColor(navCube, "Текст", "navCubeText");
|
||||
addColor(navCube, "Рёбра", "navCubeEdge");
|
||||
const edgeAlphaRow = document.createElement("div");
|
||||
edgeAlphaRow.className = "color-row";
|
||||
const edgeAlphaLbl = document.createElement("label");
|
||||
edgeAlphaLbl.textContent = "Прозрачность рёбер (0-1)";
|
||||
const edgeAlphaInput = document.createElement("input");
|
||||
edgeAlphaInput.type = "number";
|
||||
edgeAlphaInput.step = "0.05";
|
||||
edgeAlphaInput.min = "0";
|
||||
edgeAlphaInput.max = "1";
|
||||
edgeAlphaInput.value = state.navCubeEdgeAlpha;
|
||||
edgeAlphaInput.addEventListener("change", () => {
|
||||
const v = Math.min(1, Math.max(0, parseFloat(edgeAlphaInput.value) || 0));
|
||||
edgeAlphaInput.value = v;
|
||||
setVal("navCubeEdgeAlpha", v);
|
||||
});
|
||||
edgeAlphaRow.appendChild(edgeAlphaLbl);
|
||||
edgeAlphaRow.appendChild(edgeAlphaInput);
|
||||
edgeAlphaRow.appendChild(document.createElement("div"));
|
||||
navCube.appendChild(edgeAlphaRow);
|
||||
panel.appendChild(navCube);
|
||||
|
||||
const measure = section("Линейка");
|
||||
addColor(measure, "Линия/бордер", "measureLine");
|
||||
addColor(measure, "Плашка", "measureLabel");
|
||||
addColor(measure, "Текст плашки", "measureLabelText");
|
||||
addColor(measure, "Кружок", "measureDot");
|
||||
panel.appendChild(measure);
|
||||
|
||||
const viewport = section("Viewport");
|
||||
const addCheck = (label, checked, handler) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "checkbox-row";
|
||||
const chk = document.createElement("input");
|
||||
chk.type = "checkbox";
|
||||
chk.checked = checked;
|
||||
chk.addEventListener("change", () => handler(chk.checked));
|
||||
const lbl = document.createElement("label");
|
||||
lbl.textContent = label;
|
||||
row.appendChild(chk);
|
||||
row.appendChild(lbl);
|
||||
viewport.appendChild(row);
|
||||
};
|
||||
if (controls.toggleEdges) addCheck("Рёбра", controls.toggleEdges.checked, (v) => { controls.toggleEdges.checked = v; controls.toggleEdges.dispatchEvent(new Event("change")); });
|
||||
if (controls.toggleTransparent) addCheck("Прозрачный фон", controls.toggleTransparent.checked, (v) => { controls.toggleTransparent.checked = v; controls.toggleTransparent.dispatchEvent(new Event("change")); });
|
||||
if (controls.addMode) addCheck("Добавлять в сцену", controls.addMode.checked, (v) => { controls.addMode.checked = v; });
|
||||
panel.appendChild(viewport);
|
||||
|
||||
const modals = section("Модальные окна");
|
||||
addColor(modals, "Основной фон", "modalBg");
|
||||
addColor(modals, "Фокусный фон", "modalFocus");
|
||||
const modalBgAlphaRow = createSliderControl({
|
||||
label: "Прозрачность основного фона",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
value: parseFloat(state.modalBgAlpha ?? 1),
|
||||
onChange: (val) => {
|
||||
const num = Math.max(0, Math.min(1, parseFloat(val)));
|
||||
setVal("modalBgAlpha", num);
|
||||
document.documentElement.style.setProperty("--modal-bg-alpha", `${num * 100}%`);
|
||||
},
|
||||
});
|
||||
modals.appendChild(modalBgAlphaRow);
|
||||
|
||||
const modalFocusAlphaRow = createSliderControl({
|
||||
label: "Прозрачность фокусного фона",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
value: parseFloat(state.modalFocusAlpha ?? 1),
|
||||
onChange: (val) => {
|
||||
const num = Math.max(0, Math.min(1, parseFloat(val)));
|
||||
setVal("modalFocusAlpha", num);
|
||||
document.documentElement.style.setProperty("--modal-focus-alpha", `${num * 100}%`);
|
||||
},
|
||||
});
|
||||
modals.appendChild(modalFocusAlphaRow);
|
||||
|
||||
addColor(modals, "Аутлайн", "modalBorder");
|
||||
addColor(modals, "Фон элемента", "modalElement");
|
||||
addColor(modals, "Аутлайн элемента", "modalElementOutline");
|
||||
const radiusRow = document.createElement("div");
|
||||
radiusRow.className = "color-row";
|
||||
const radiusLbl = document.createElement("label");
|
||||
radiusLbl.textContent = "Радиус";
|
||||
const radiusInput = document.createElement("input");
|
||||
radiusInput.type = "number";
|
||||
radiusInput.value = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--modal-radius")) || 14;
|
||||
radiusInput.addEventListener("change", () => {
|
||||
const val = `${radiusInput.value}px`;
|
||||
document.documentElement.style.setProperty("--modal-radius", val);
|
||||
setVal("modalRadius", val);
|
||||
});
|
||||
radiusRow.appendChild(radiusLbl);
|
||||
radiusRow.appendChild(radiusInput);
|
||||
radiusRow.appendChild(document.createElement("div"));
|
||||
modals.appendChild(radiusRow);
|
||||
panel.appendChild(modals);
|
||||
|
||||
const cube = section("Выделение объектов");
|
||||
addColor(cube, "Подсветка объектов", "highlight");
|
||||
panel.appendChild(cube);
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
render();
|
||||
if (panel) {
|
||||
panel.classList.remove("hidden");
|
||||
panel.style.display = "grid";
|
||||
}
|
||||
onShow?.();
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (!panel) return;
|
||||
if (panel.classList.contains("hidden")) {
|
||||
show();
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
return { render, show, toggle, hide, getState, applyState: applyStateExternal };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
export function createTemplatesMenu({ panel, fetchProjects, openProject, onShow, onHide }) {
|
||||
let state = { templates: [], projects: [], loading: false, error: null };
|
||||
|
||||
const setState = (next) => {
|
||||
state = { ...state, ...next };
|
||||
render();
|
||||
};
|
||||
|
||||
const renderEmpty = (parent, text) => {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "templates-empty";
|
||||
empty.textContent = text;
|
||||
parent.appendChild(empty);
|
||||
};
|
||||
|
||||
const renderSection = (title, items, emptyText) => {
|
||||
const section = document.createElement("div");
|
||||
section.className = "templates-section";
|
||||
const subtitle = document.createElement("div");
|
||||
subtitle.className = "templates-subtitle";
|
||||
subtitle.textContent = title;
|
||||
section.appendChild(subtitle);
|
||||
if (!items || !items.length) {
|
||||
renderEmpty(section, emptyText);
|
||||
} else {
|
||||
items.forEach((item) => {
|
||||
const card = document.createElement("button");
|
||||
card.className = "example-card template-card";
|
||||
card.type = "button";
|
||||
card.addEventListener("click", () => openProject?.(item.id, item.type));
|
||||
|
||||
const titleEl = document.createElement("div");
|
||||
titleEl.className = "card-title";
|
||||
titleEl.textContent = item.name || item.id;
|
||||
card.appendChild(titleEl);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "card-meta";
|
||||
const typePill = document.createElement("span");
|
||||
typePill.className = "card-pill";
|
||||
typePill.dataset.kind = item.type || "project";
|
||||
typePill.textContent = item.type === "template" ? "Шаблон" : "Проект";
|
||||
meta.appendChild(typePill);
|
||||
if (item.updatedAt) {
|
||||
const updated = document.createElement("span");
|
||||
updated.textContent = new Date(item.updatedAt).toLocaleDateString();
|
||||
meta.appendChild(updated);
|
||||
}
|
||||
card.appendChild(meta);
|
||||
|
||||
section.appendChild(card);
|
||||
});
|
||||
}
|
||||
return section;
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
if (!panel) return;
|
||||
panel.innerHTML = "";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "templates-header";
|
||||
header.innerHTML = `<span class="templates-title">Проекты и шаблоны</span>`;
|
||||
const actions = document.createElement("div");
|
||||
actions.style.display = "flex";
|
||||
actions.style.gap = "6px";
|
||||
const refresh = document.createElement("button");
|
||||
refresh.className = "templates-close";
|
||||
refresh.title = "Обновить список";
|
||||
refresh.textContent = "↻";
|
||||
refresh.addEventListener("click", () => load());
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "templates-close";
|
||||
closeBtn.textContent = "×";
|
||||
closeBtn.addEventListener("click", hide);
|
||||
actions.appendChild(refresh);
|
||||
actions.appendChild(closeBtn);
|
||||
header.appendChild(actions);
|
||||
panel.appendChild(header);
|
||||
|
||||
if (state.loading) {
|
||||
const loadingEl = document.createElement("div");
|
||||
loadingEl.className = "templates-empty";
|
||||
loadingEl.textContent = "Загрузка списка проектов...";
|
||||
panel.appendChild(loadingEl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
const errorEl = document.createElement("div");
|
||||
errorEl.className = "templates-empty";
|
||||
errorEl.style.color = "var(--error)";
|
||||
errorEl.textContent = state.error;
|
||||
panel.appendChild(errorEl);
|
||||
}
|
||||
|
||||
panel.appendChild(renderSection("Шаблоны", state.templates, "Нет шаблонов"));
|
||||
panel.appendChild(renderSection("Мои проекты", state.projects, "Нет сохранённых проектов"));
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!fetchProjects) return;
|
||||
setState({ loading: true, error: null });
|
||||
try {
|
||||
const data = await fetchProjects();
|
||||
setState({
|
||||
loading: false,
|
||||
templates: data?.templates || [],
|
||||
projects: data?.projects || [],
|
||||
error: null
|
||||
});
|
||||
} catch (err) {
|
||||
setState({ loading: false, error: err?.message || "Не удалось загрузить проекты" });
|
||||
}
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
if (panel) {
|
||||
panel.classList.add("hidden");
|
||||
panel.style.display = "";
|
||||
}
|
||||
onHide?.();
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
render();
|
||||
if (panel) {
|
||||
panel.classList.remove("hidden");
|
||||
panel.style.display = "grid";
|
||||
}
|
||||
load();
|
||||
onShow?.();
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (!panel) return;
|
||||
if (panel.classList.contains("hidden")) {
|
||||
show();
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const setData = (data = {}) => {
|
||||
setState({
|
||||
templates: data.templates || [],
|
||||
projects: data.projects || [],
|
||||
loading: false,
|
||||
error: data.error || null
|
||||
});
|
||||
};
|
||||
|
||||
return { render, show, hide, toggle, reload: load, setData };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const toolbarConfig = {
|
||||
size: 25,
|
||||
gap: 10,
|
||||
radius: 7,
|
||||
paddingY: 6,
|
||||
iconPadding: 5,
|
||||
iconSize: 13,
|
||||
};
|
||||
|
||||
const applyToolbarVars = () => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--tb-size", `${toolbarConfig.size}px`);
|
||||
root.style.setProperty("--tb-gap", `${toolbarConfig.gap}px`);
|
||||
root.style.setProperty("--tb-radius", `${toolbarConfig.radius}px`);
|
||||
root.style.setProperty("--tb-pad-y", `${toolbarConfig.paddingY}px`);
|
||||
root.style.setProperty("--tb-icon-pad", `${toolbarConfig.iconPadding}px`);
|
||||
root.style.setProperty("--tb-icon-size", `${toolbarConfig.iconSize}px`);
|
||||
};
|
||||
|
||||
export { toolbarConfig, applyToolbarVars };
|
||||
@@ -0,0 +1,59 @@
|
||||
// Базовые цвета/настройки проекта. При очистке localStorage эти значения остаются.
|
||||
// Чтобы изменить палитру по умолчанию — правь этот объект и закоммить изменения.
|
||||
const PROJECT_THEME = {
|
||||
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",
|
||||
modalBgAlpha: 1,
|
||||
modalFocus: "#292929",
|
||||
modalFocusAlpha: 1,
|
||||
modalBorder: "#292929",
|
||||
modalElement: "#292929",
|
||||
modalElementOutline: "#2b2b2b",
|
||||
modalRadius: "14px",
|
||||
projectBtnFill: "#1c1c1c",
|
||||
projectBtnBorder: "#233044",
|
||||
projectBtnHover: "rgba(255,234,0,0.7)",
|
||||
modalBtnPrimary: "#ffea00",
|
||||
modalBtnSecondary: "#292929",
|
||||
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",
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.__BIMDC_THEME__ = PROJECT_THEME;
|
||||
}
|
||||
|
||||
// Support both module and browser usage without syntax errors in classic scripts
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = PROJECT_THEME;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Минимальный плагин трансформаций: хранит выбранный объект, даёт доступ к pos/rot/scale,
|
||||
// реагирует на клики по дереву. Трансформации применяются на уровне SceneModel.
|
||||
|
||||
export class TransformGizmo {
|
||||
constructor({ viewer, sdk, treeView, transformStore, onTargetChange, onTransformChange, sceneModels }) {
|
||||
this.viewer = viewer;
|
||||
this.scene = viewer.scene;
|
||||
this.sdk = sdk;
|
||||
this.treeView = treeView;
|
||||
this.transformStore = transformStore || {};
|
||||
this.sceneModels = sceneModels || {};
|
||||
this.onTargetChange = onTargetChange;
|
||||
this.onTransformChange = onTransformChange;
|
||||
|
||||
this.mode = "translate"; // translate | rotate | scale
|
||||
this.target = null; // { modelId, objectId }
|
||||
|
||||
this._bindTree();
|
||||
}
|
||||
|
||||
setMode(next) {
|
||||
if (!["translate", "rotate", "scale"].includes(next)) return;
|
||||
this.mode = next;
|
||||
this._log(`Режим гизмы: ${next}`);
|
||||
}
|
||||
|
||||
clearTarget() {
|
||||
if (this.target?.objectId) {
|
||||
this.scene.setObjectsHighlighted([this.target.objectId], false);
|
||||
}
|
||||
this.scene.setObjectsHighlighted(this.scene.highlightedObjectIds || [], false);
|
||||
this.target = null;
|
||||
this.onTargetChange?.(null);
|
||||
this._log("Выбор очищен");
|
||||
}
|
||||
|
||||
setTarget({ modelId, objectId }) {
|
||||
if (!modelId) return;
|
||||
|
||||
this.scene.setObjectsHighlighted(this.scene.highlightedObjectIds || [], false);
|
||||
|
||||
const model = this.sceneModels ? this.sceneModels[modelId] || this.scene.models[modelId] : this.scene.models[modelId];
|
||||
if (!model) {
|
||||
this._log(`Модель ${modelId} не найдена`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если указан объект
|
||||
if (objectId) {
|
||||
const entity = this.scene.objects[objectId];
|
||||
if (!entity) {
|
||||
this._log(`Объект ${objectId} не найден`, true);
|
||||
} else {
|
||||
const saved = this.transformStore?.[modelId]?.[objectId];
|
||||
if (saved) {
|
||||
if (saved.position) model.position = saved.position;
|
||||
if (saved.rotation) model.rotation = saved.rotation;
|
||||
if (saved.scale) model.scale = saved.scale;
|
||||
}
|
||||
this.onTargetChange?.({
|
||||
modelId,
|
||||
objectId,
|
||||
transform: {
|
||||
position: [...model.position],
|
||||
rotation: [...model.rotation],
|
||||
scale: model.scale ? [...model.scale] : [1, 1, 1],
|
||||
},
|
||||
});
|
||||
this.scene.setObjectsHighlighted([objectId], true);
|
||||
this.target = { modelId, objectId };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Если objectId нет, работаем всей моделью
|
||||
const ids = Object.keys(model.objects || {});
|
||||
if (ids.length) {
|
||||
this.scene.setObjectsHighlighted(ids, true);
|
||||
}
|
||||
this.onTargetChange?.({
|
||||
modelId,
|
||||
objectId: null,
|
||||
transform: {
|
||||
position: [...model.position],
|
||||
rotation: [...model.rotation],
|
||||
scale: model.scale ? [...model.scale] : [1, 1, 1],
|
||||
},
|
||||
});
|
||||
this.target = { modelId, objectId: null };
|
||||
}
|
||||
|
||||
handleTreeNodeClick(evt) {
|
||||
const node = evt?.treeViewNode || evt?.node || evt;
|
||||
const objectId = node?.objectId || node?.id || node?.object?.id || node?.entityId || node?.objectIdGiven;
|
||||
const modelId = node?.modelId || node?.model?.id || node?.modelID || node?.model?.modelId || node?.modelIdGiven;
|
||||
if (objectId && modelId) {
|
||||
this.setTarget({ modelId, objectId });
|
||||
} else if (modelId) {
|
||||
// нет objectId - подсветим всю модель
|
||||
const model = this.scene.models[modelId];
|
||||
if (model) {
|
||||
const ids = Object.keys(model.objects || {});
|
||||
this.scene.setObjectsHighlighted(this.scene.highlightedObjectIds || [], false);
|
||||
this.scene.setObjectsHighlighted(ids, true);
|
||||
this.target = { modelId, objectId: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTransform({ position, rotation, scale }) {
|
||||
if (!this.target?.modelId) return;
|
||||
const model = this.sceneModels ? this.sceneModels[this.target.modelId] || this.scene.models[this.target.modelId] : this.scene.models[this.target.modelId];
|
||||
if (!model) return;
|
||||
if (position) model.position = position;
|
||||
if (rotation) model.rotation = rotation;
|
||||
if (scale) model.scale = scale;
|
||||
// сохраняем в store
|
||||
if (!this.transformStore[this.target.modelId]) {
|
||||
this.transformStore[this.target.modelId] = {};
|
||||
}
|
||||
const key = this.target.objectId || "__model__";
|
||||
this.transformStore[this.target.modelId][key] = {
|
||||
position: [...model.position],
|
||||
rotation: [...model.rotation],
|
||||
scale: model.scale ? [...model.scale] : [1, 1, 1],
|
||||
};
|
||||
this.onTransformChange?.({
|
||||
modelId: this.target.modelId,
|
||||
objectId: this.target.objectId,
|
||||
transform: {
|
||||
position: [...model.position],
|
||||
rotation: [...model.rotation],
|
||||
scale: model.scale ? [...model.scale] : [1, 1, 1],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_bindTree() {
|
||||
if (this.treeView?.on) {
|
||||
this.treeView.on("nodeTitleClicked", (e) => this.handleTreeNodeClick(e));
|
||||
// подстрахуемся: некоторые деревья бросают другой эвент
|
||||
this.treeView.on("nodeClicked", (e) => this.handleTreeNodeClick(e));
|
||||
}
|
||||
}
|
||||
|
||||
_log(msg, isError = false) {
|
||||
if (isError) {
|
||||
console.error("[TransformGizmo]", msg);
|
||||
} else {
|
||||
console.log("[TransformGizmo]", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user