From a95a7d5c389470326e8dd8d25c0cdeba2180e923 Mon Sep 17 00:00:00 2001 From: CODEX Date: Thu, 4 Jun 2026 22:36:15 +0300 Subject: [PATCH] beam: add per-model viewer settings --- frontend/dcViewer.css | 171 ++++++++-- frontend/dcViewer.js | 567 ++++++++++++++++++++++++++++++-- frontend/index.html | 23 +- frontend/src/ui/glassSelect.js | 216 ++++++++++++ frontend/src/ui/settingsMenu.js | 22 +- server/index.js | 152 +++++++++ 6 files changed, 1070 insertions(+), 81 deletions(-) create mode 100644 frontend/src/ui/glassSelect.js diff --git a/frontend/dcViewer.css b/frontend/dcViewer.css index 9e7b1ab..c855a10 100644 --- a/frontend/dcViewer.css +++ b/frontend/dcViewer.css @@ -40,6 +40,9 @@ --measure-label: #ffea00; --measure-label-text: #292929; --measure-dot: #ffea00; + --nodedc-glass-control-bg: var(--modal-focus); + --nodedc-glass-control-hover: color-mix(in srgb, var(--modal-focus) 86%, white 8%); + --nodedc-glass-control-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.01); --tb-size: 25px; --tb-gap: 10px; --tb-radius: 7px; @@ -449,9 +452,17 @@ header { } #saveProjectSection { + display: flex; + flex-direction: column; + gap: 8px; margin-top: auto; } +.secondary-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .log { background: var(--modal-focus); border-radius: var(--modal-radius, 12px); @@ -591,52 +602,154 @@ header { margin-right: 6px; } -.display-mode-panel { +.inspector-controls { + display: grid; + gap: 8px; margin-top: 10px; } -.display-mode-title { - margin-bottom: 7px; +.inspector-control-row { + display: grid; + grid-template-columns: minmax(0, 0.86fr) minmax(0, 1.14fr); + gap: 10px; + align-items: center; + min-height: 42px; +} + +.inspector-control-label { + min-width: 0; color: var(--muted); font-size: 12px; - font-weight: 700; + font-weight: 760; + line-height: 1.15; } -.display-mode-buttons { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 6px; -} - -.display-mode-buttons button { +.inspector-select-host { min-width: 0; - border: 1px solid var(--modal-border); - border-radius: 9px; - background: var(--modal-focus); - color: var(--muted); - cursor: pointer; - font: inherit; - font-size: 11px; - font-weight: 700; - line-height: 1; +} + +.nodedc-select { + position: relative; + min-width: 0; + width: 100%; +} + +.nodedc-select__control { + display: grid; + grid-template-columns: minmax(0, 1fr) 42px; + gap: 8px; + align-items: center; + min-width: 0; +} + +.nodedc-select__value, +.nodedc-select__toggle { + height: 42px; + border: 0; + border-radius: 14px; + background: var(--nodedc-glass-control-bg); + box-shadow: var(--nodedc-glass-control-shadow); + color: rgba(245, 245, 250, 0.78); outline: none; - padding: 8px 6px; - transition: background 120ms ease, border-color 120ms ease, color 120ms ease; + transition: background 140ms ease, color 140ms ease; } -.display-mode-buttons button:hover, -.display-mode-buttons button.active { - border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 14%, var(--modal-focus)); - color: var(--text); +.nodedc-select__value { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + padding: 0 12px; + font-size: 12px; + font-weight: 760; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; } -.display-mode-buttons button:focus, -.display-mode-buttons button:focus-visible { +.nodedc-select__toggle { + display: grid; + place-items: center; + padding: 0; + cursor: pointer; +} + +.nodedc-select__value:hover, +.nodedc-select__toggle:hover, +.nodedc-select__toggle[aria-expanded="true"] { + background: var(--nodedc-glass-control-hover); + color: rgba(255, 255, 255, 0.96); +} + +.nodedc-select__toggle:focus, +.nodedc-select__toggle:focus-visible, +.nodedc-select__value:focus, +.nodedc-select__value:focus-visible, +.nodedc-select__option:focus, +.nodedc-select__option:focus-visible { outline: none; box-shadow: none; } +.nodedc-select__chevron { + width: 8px; + height: 8px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + opacity: 0.72; + transform: translateY(-2px) rotate(45deg); +} + +.nodedc-dropdown-surface.nodedc-select__menu { + z-index: 30060; + display: grid; + gap: 2px; + max-height: 232px; + overflow: auto; + border: 0; + border-radius: 20px; + background: color-mix(in srgb, var(--modal-bg) var(--modal-bg-alpha, 100%), transparent); + padding: 7px; + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.48), inset 0 1px 0 rgba(255, 255, 255, 0.055); + scrollbar-width: none; +} + +.nodedc-dropdown-surface.nodedc-select__menu::-webkit-scrollbar { + display: none; +} + +.nodedc-dropdown-option.nodedc-select__option { + display: flex; + align-items: center; + min-height: 42px; + width: 100%; + border: 0; + border-radius: 14px; + background: transparent; + color: rgba(255, 255, 255, 0.72); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 760; + line-height: 1; + outline: none; + padding: 0 12px; + text-align: left; + transition: background 140ms ease, color 140ms ease; +} + +.nodedc-dropdown-option.nodedc-select__option:hover, +.nodedc-dropdown-option.nodedc-select__option:focus-visible { + background: var(--nodedc-glass-control-hover); + color: rgba(255, 255, 255, 0.96); +} + +.nodedc-dropdown-option.nodedc-select__option[data-selected="true"] { + background: var(--modal-focus); + color: rgba(255, 255, 255, 0.96); +} + .examples-bar { position: fixed; left: 0; diff --git a/frontend/dcViewer.js b/frontend/dcViewer.js index e2a5b6e..a905569 100644 --- a/frontend/dcViewer.js +++ b/frontend/dcViewer.js @@ -6,6 +6,7 @@ import { toolbarConfig, applyToolbarVars } from "./src/ui/toolbar.js"; import { createTemplatesMenu } from "./src/ui/templatesMenu.js"; import { createSettingsMenu } from "./src/ui/settingsMenu.js"; import { createMeasurementModal } from "./src/ui/measurementModal.js"; +import { createGlassSelect } from "./src/ui/glassSelect.js"; import "./theme.js"; import { TransformGizmo } from "./transformGizmo.js"; import { createObjectModal } from "./src/ui/objectModal.js"; @@ -36,13 +37,18 @@ const templatesButton = document.querySelector('[data-action="templates"]'); const projectionToggle = document.querySelector('[data-action="projection"]'); const treeContainer = document.getElementById("treeContainer"); const displayModeControl = document.getElementById("displayModeControl"); -const displayModeButtons = document.querySelectorAll("[data-display-mode]"); +const displayModeSelectHost = document.getElementById("displayModeSelect"); +const lightingModeControl = document.getElementById("lightingModeControl"); +const lightingModeSelectHost = document.getElementById("lightingModeSelect"); +const navigationAxisControl = document.getElementById("navigationAxisControl"); +const navigationAxisSelectHost = document.getElementById("navigationAxisSelect"); const logList = document.getElementById("logList"); const loaderEl = document.getElementById("loader"); const navCubeCanvas = document.getElementById("navCube"); const mainCanvas = document.getElementById("viewerCanvas"); const saveProjectSection = document.getElementById("saveProjectSection"); const saveProjectButton = document.getElementById("saveProjectButton"); +const saveModelSettingsButton = document.getElementById("saveModelSettingsButton"); const saveProjectBackdrop = document.getElementById("saveProjectBackdrop"); const saveProjectModal = document.getElementById("saveProjectModal"); const saveProjectClose = document.getElementById("saveProjectClose"); @@ -98,6 +104,7 @@ const applyPanelOffsets = () => { }; const API_BASE = "/api/projects"; +const MODEL_SETTINGS_API = "/api/model-settings"; const acceptByType = { xkt: ".xkt", @@ -147,6 +154,48 @@ const templateFallbacks = DEFAULT_EXAMPLES.map((ex) => ({ type: "template", updatedAt: null })); + +const DISPLAY_MODE_OPTIONS = [ + { value: "source", label: "Исходный" }, + { value: "clay", label: "Clay" }, + { value: "white", label: "Белый" }, + { value: "contrast", label: "Контраст" }, + { value: "edges", label: "Рёбра" }, + { value: "xray", label: "Рентген" }, +]; + +const NAVIGATION_AXIS_OPTIONS = [ + { value: "auto", label: "Авто" }, + { value: "x-up", label: "X" }, + { value: "y-up", label: "Y" }, + { value: "z-up", label: "Z" }, +]; + +const LIGHTING_MODE_OPTIONS = [ + { value: "standard", label: "Стандарт" }, + { value: "soft", label: "Мягкий" }, + { value: "ambient", label: "Без солнца" }, + { value: "technical", label: "Тех." }, +]; + +const NAVIGATION_AXIS_PRESETS = { + "y-up": { + worldAxis: [1, 0, 0, 0, 1, 0, 0, 0, 1], + up: [0, 1, 0], + forward: [0, 0, 1], + }, + "z-up": { + worldAxis: [1, 0, 0, 0, 0, 1, 0, -1, 0], + up: [0, 0, 1], + forward: [0, -1, 0], + }, + "x-up": { + worldAxis: [0, 1, 0, 1, 0, 0, 0, 0, -1], + up: [1, 0, 0], + forward: [0, 0, -1], + }, +}; + let templatesMenu = null; let settingsMenu = null; let measurementModal = null; @@ -165,6 +214,13 @@ const transformStore = {}; // modelId -> objectId -> {position, rotation, scale} const modelTypes = {}; // modelId -> normalized type const sceneModels = {}; // modelId -> SceneModel let activeDisplayMode = "source"; +let activeLightingMode = "ambient"; +let activeNavigationAxis = "auto"; +let displayModeSelect = null; +let lightingModeSelect = null; +let navigationAxisSelect = null; +let defaultCameraWorldAxis = null; +let defaultCameraUp = null; let currentSelection = null; // { modelId, objectId } const customTrees = new Map(); // modelId -> element let navCubePlugin = null; @@ -232,39 +288,111 @@ const updateProjectionButton = () => { } }; -const setDisplayModeButtonState = () => { - displayModeButtons.forEach((button) => { - button.classList.toggle("active", button.dataset.displayMode === activeDisplayMode); - }); +const normalizeDisplayMode = (mode) => ( + DISPLAY_MODE_OPTIONS.some((option) => option.value === mode) ? mode : "source" +); + +const normalizeLightingMode = (mode) => ( + LIGHTING_MODE_OPTIONS.some((option) => option.value === mode) ? mode : "ambient" +); + +const normalizeNavigationAxis = (axis) => ( + NAVIGATION_AXIS_OPTIONS.some((option) => option.value === axis) ? axis : "auto" +); + +const setDisplayModeControlState = () => { + activeDisplayMode = normalizeDisplayMode(activeDisplayMode); + displayModeSelect?.setValue(activeDisplayMode); }; -const getDisplayModeColorize = (mode) => { +const setLightingModeControlState = () => { + activeLightingMode = normalizeLightingMode(activeLightingMode); + lightingModeSelect?.setValue(activeLightingMode); +}; + +const setNavigationAxisControlState = () => { + activeNavigationAxis = normalizeNavigationAxis(activeNavigationAxis); + navigationAxisSelect?.setValue(activeNavigationAxis); +}; + +const normalizeModelSettingsSrc = (src) => { + if (!src || typeof src !== "string") return ""; + let value = src.trim(); + if (!value) return ""; + try { + const parsed = new URL(value, window.location.href); + if (parsed.origin === window.location.origin || /^https?:/i.test(value)) { + value = parsed.pathname; + } + } catch (_) { + // keep raw value + } + value = value.replace(/^\/+/, ""); + return value.startsWith("uploads/") ? value : ""; +}; + +const getModelSettingsSrc = (entry) => { + const meta = entry?.meta || {}; + const candidates = [ + meta.settingsSrc, + meta.sourceSrc, + meta.originalSrc, + meta.conversion?.sourceSrc, + meta.manifest?.sourceSrc, + meta.artifactSrc, + entry?.src, + ]; + for (const candidate of candidates) { + const normalized = normalizeModelSettingsSrc(candidate); + if (normalized) return normalized; + } + return ""; +}; + +const getPrimaryModelEntry = () => ( + activeModels.find((entry) => entry?.model?.visible !== false) || activeModels[0] || null +); + +const hasModelSettingsTarget = () => !!getModelSettingsSrc(getPrimaryModelEntry()); + +const getDisplayModePreset = (mode) => { switch (mode) { + case "clay": + return { colorize: [0.86, 0.86, 0.82], opacity: 1, edges: false, xrayed: false }; case "white": - return [1, 1, 1]; + return { colorize: [1, 1, 1], opacity: 1, edges: false, xrayed: false }; case "contrast": - return [0.78, 0.82, 1]; + return { colorize: [0.78, 0.82, 1], opacity: 1, edges: false, xrayed: false }; + case "edges": + return { colorize: null, opacity: 1, edges: true, xrayed: false }; + case "xray": + return { colorize: [0.72, 0.9, 1], opacity: 1, edges: true, xrayed: true }; case "source": default: - return null; + return { colorize: null, opacity: 1, edges: "base", xrayed: false }; } }; +const getBaseEdgesForModel = (modelId) => { + const modelType = modelTypes[modelId]; + if (modelType === "gltf") return false; + return !!toggleEdges?.checked; +}; + const applyDisplayModeToModel = (modelId) => { const model = sceneModels[modelId] || viewer?.scene?.models?.[modelId]; if (!model) return; - const colorize = getDisplayModeColorize(activeDisplayMode); + const preset = getDisplayModePreset(normalizeDisplayMode(activeDisplayMode)); const objects = Object.values(model.objects || {}); const targets = objects.length ? objects : [model]; targets.forEach((entity) => { if (!entity) return; try { - entity.colorize = colorize; - entity.opacity = 1; - if (activeDisplayMode !== "source") { - entity.edges = false; - } + entity.xrayed = !!preset.xrayed; + entity.colorize = preset.colorize; + entity.opacity = preset.opacity; + entity.edges = preset.edges === "base" ? getBaseEdgesForModel(modelId) : !!preset.edges; } catch (err) { console.warn("display mode update failed", err); } @@ -274,10 +402,165 @@ const applyDisplayModeToModel = (modelId) => { }; const applyDisplayMode = () => { - setDisplayModeButtonState(); + setDisplayModeControlState(); Object.keys(sceneModels).forEach((modelId) => applyDisplayModeToModel(modelId)); }; +const createSceneLight = (type, cfg) => { + if (!viewer?.scene || !sdkRef) return null; + if (type === "ambient" && sdkRef.AmbientLight) { + return new sdkRef.AmbientLight(viewer.scene, cfg); + } + if (type === "dir" && sdkRef.DirLight) { + return new sdkRef.DirLight(viewer.scene, cfg); + } + return null; +}; + +const resetSceneLighting = () => { + if (!viewer?.scene) return; + viewer.scene.clearLights?.(); +}; + +const applyLightingMode = ({ announce = false } = {}) => { + activeLightingMode = normalizeLightingMode(activeLightingMode); + setLightingModeControlState(); + if (!viewer?.scene || !sdkRef) return; + + const scene = viewer.scene; + resetSceneLighting(); + scene.pbrEnabled = false; + scene.colorTextureEnabled = true; + if (scene.sao) { + scene.sao.enabled = false; + } + + switch (activeLightingMode) { + case "standard": + createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.7 }); + createSceneLight("dir", { + dir: [0.8, -0.5, -0.5], + color: [0.67, 0.67, 1.0], + intensity: 0.7, + space: "world", + }); + createSceneLight("dir", { + dir: [-0.8, -1.0, 0.5], + color: [1.0, 1.0, 0.9], + intensity: 0.9, + space: "world", + }); + break; + case "soft": + createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.82 }); + createSceneLight("dir", { + dir: [0.7, -0.65, -0.55], + color: [1.0, 1.0, 1.0], + intensity: 0.22, + space: "view", + }); + createSceneLight("dir", { + dir: [-0.6, -0.35, 0.7], + color: [0.82, 0.88, 1.0], + intensity: 0.16, + space: "view", + }); + break; + case "technical": + createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.62 }); + createSceneLight("dir", { + dir: [0.9, -0.45, -0.35], + color: [0.95, 0.98, 1.0], + intensity: 0.34, + space: "view", + }); + createSceneLight("dir", { + dir: [-0.55, -0.75, 0.45], + color: [1.0, 0.96, 0.88], + intensity: 0.24, + space: "view", + }); + if (scene.sao) { + scene.sao.enabled = true; + scene.sao.intensity = 0.08; + scene.sao.bias = 0.7; + scene.sao.kernelRadius = 48; + scene.sao.numSamples = 20; + } + break; + case "ambient": + default: + createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.92 }); + break; + } + + scene.glRedraw?.(); + if (announce) { + const label = LIGHTING_MODE_OPTIONS.find((option) => option.value === activeLightingMode)?.label || "Свет"; + setStatus(`Свет: ${label}`); + } +}; + +const cloneVec3 = (vec, fallback = [0, 0, 0]) => ( + Array.isArray(vec) || ArrayBuffer.isView(vec) ? Array.from(vec).slice(0, 3) : [...fallback] +); + +const normalizeVec3 = (vec, fallback = [0, 0, 1]) => { + const [x, y, z] = cloneVec3(vec, fallback); + const length = Math.hypot(x, y, z); + return length > 0.00001 ? [x / length, y / length, z / length] : [...fallback]; +}; + +const dotVec3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +const getNavigationAxisPreset = (axis) => { + if (axis === "auto") { + const worldAxis = defaultCameraWorldAxis || NAVIGATION_AXIS_PRESETS["y-up"].worldAxis; + return { + worldAxis, + up: defaultCameraUp || NAVIGATION_AXIS_PRESETS["y-up"].up, + forward: worldAxis.length >= 9 ? worldAxis.slice(6, 9) : NAVIGATION_AXIS_PRESETS["y-up"].forward, + }; + } + return NAVIGATION_AXIS_PRESETS[axis] || NAVIGATION_AXIS_PRESETS["y-up"]; +}; + +const applyNavigationAxis = ({ resetAuto = true, announce = false } = {}) => { + activeNavigationAxis = normalizeNavigationAxis(activeNavigationAxis); + setNavigationAxisControlState(); + if (!viewer?.camera) return; + if (activeNavigationAxis === "auto" && !resetAuto) return; + + const preset = getNavigationAxisPreset(activeNavigationAxis); + const camera = viewer.camera; + const up = normalizeVec3(preset.up, [0, 1, 0]); + const eye = cloneVec3(camera.eye, [0, 0, 10]); + const look = cloneVec3(camera.look, [0, 0, 0]); + const eyeVector = [eye[0] - look[0], eye[1] - look[1], eye[2] - look[2]]; + const viewDirection = normalizeVec3(eyeVector, preset.forward); + const distance = Math.hypot(eyeVector[0], eyeVector[1], eyeVector[2]) || 10; + + camera.worldAxis = [...preset.worldAxis]; + camera.gimbalLock = true; + + if (Math.abs(dotVec3(viewDirection, up)) > 0.96) { + const forward = normalizeVec3(preset.forward, [0, 0, 1]); + camera.eye = [ + look[0] + forward[0] * distance, + look[1] + forward[1] * distance, + look[2] + forward[2] * distance, + ]; + } + camera.up = [...up]; + viewer.scene?.glRedraw?.(); + rearmCameraControl(); + + if (announce) { + const label = NAVIGATION_AXIS_OPTIONS.find((option) => option.value === activeNavigationAxis)?.label || "Авто"; + setStatus(`Ось навигации: ${label}`); + } +}; + const toggleProjection = () => { if (!viewer?.camera) return; const cam = viewer.camera; @@ -569,6 +852,9 @@ const updatePanelsVisibility = () => { if (saveProjectButton) { saveProjectButton.disabled = !hasModels; } + if (saveModelSettingsButton) { + saveModelSettingsButton.disabled = !hasModels || !hasModelSettingsTarget(); + } if (deleteProjectButton) { deleteProjectButton.disabled = !(currentProjectMeta || lastAttemptedProjectId); } @@ -1845,7 +2131,10 @@ const buildProjectSnapshot = async (name) => { type: modelType, meta: { label: meta.label || entry.name || model.id, - metadataPath: meta.metadataPath || null + metadataPath: meta.metadataPath || null, + settingsSrc: getModelSettingsSrc(entry) || null, + sourceSrc: meta.sourceSrc || meta.conversion?.sourceSrc || meta.manifest?.sourceSrc || null, + artifactSrc: meta.artifactSrc || null }, visible: model.visible !== false }; @@ -1890,6 +2179,8 @@ const buildProjectSnapshot = async (name) => { edges: !!toggleEdges?.checked, addMode: !!addMode?.checked, displayMode: activeDisplayMode, + lightingMode: activeLightingMode, + navigationAxis: activeNavigationAxis, selection: { ids: lastSelection.ids || [], modelId: lastSelection.modelId || null, @@ -1927,15 +2218,129 @@ const applyDesignState = (design) => { } }; +const applyCameraSnapshot = (cameraState) => { + if (!viewer?.camera || !cameraState || typeof cameraState !== "object") return; + const { eye, look, up, projection } = cameraState; + if (Array.isArray(eye) && eye.length === 3) viewer.camera.eye = [...eye]; + if (Array.isArray(look) && look.length === 3) viewer.camera.look = [...look]; + if (Array.isArray(up) && up.length === 3) viewer.camera.up = [...up]; + if (projection) viewer.camera.projection = projection; + updateProjectionButton(); + viewer.scene?.glRedraw?.(); +}; + +const buildModelViewerSettings = () => ({ + version: 1, + updatedAt: new Date().toISOString(), + viewerState: viewer ? { + camera: viewer.camera ? { + eye: snapshotVec(viewer.camera.eye), + look: snapshotVec(viewer.camera.look), + up: snapshotVec(viewer.camera.up), + projection: viewer.camera.projection + } : null, + transparent: !!toggleTransparent?.checked, + edges: !!toggleEdges?.checked, + displayMode: activeDisplayMode, + lightingMode: activeLightingMode, + navigationAxis: activeNavigationAxis + } : {}, + design: settingsMenu?.getState ? settingsMenu.getState() : {} +}); + +const applyModelViewerSettings = (settings, { applyCamera = true, applyDesign = true } = {}) => { + if (!settings || typeof settings !== "object") return; + const viewerState = settings.viewerState && typeof settings.viewerState === "object" + ? settings.viewerState + : {}; + + if (applyDesign && settings.design && typeof settings.design === "object") { + applyDesignState(settings.design); + } + if (!viewer) return; + + if (typeof viewerState.transparent === "boolean" && toggleTransparent) { + toggleTransparent.checked = viewerState.transparent; + viewer.transparent = viewerState.transparent; + if (viewer.scene?.canvas) { + viewer.scene.canvas.transparent = viewerState.transparent; + } + } + if (typeof viewerState.edges === "boolean" && toggleEdges) { + suppressEdgeReload = true; + toggleEdges.checked = viewerState.edges; + suppressEdgeReload = false; + } + if (typeof viewerState.displayMode === "string") { + activeDisplayMode = viewerState.displayMode; + applyDisplayMode(); + } + if (typeof viewerState.lightingMode === "string") { + activeLightingMode = viewerState.lightingMode; + applyLightingMode(); + } + if (typeof viewerState.navigationAxis === "string") { + activeNavigationAxis = viewerState.navigationAxis; + applyNavigationAxis({ resetAuto: false }); + } + if (applyCamera) { + applyCameraSnapshot(viewerState.camera); + } + rearmCameraControl(); +}; + +const fetchModelSettings = async (src) => { + const normalized = normalizeModelSettingsSrc(src); + if (!normalized) return null; + const params = new URLSearchParams({ src: normalized }); + return fetchJSON(`${MODEL_SETTINGS_API}?${params.toString()}`); +}; + +const saveCurrentModelSettings = async () => { + const entry = getPrimaryModelEntry(); + const settingsSrc = getModelSettingsSrc(entry); + if (!entry || !settingsSrc) { + setStatus("Настройки можно сохранить только для модели из Beam-хранилища"); + return; + } + + const previousLabel = saveModelSettingsButton?.textContent || "Сохранить настройки"; + if (saveModelSettingsButton) { + saveModelSettingsButton.disabled = true; + saveModelSettingsButton.textContent = "Сохраняем..."; + } + + try { + const payload = { viewerSettings: buildModelViewerSettings() }; + const params = new URLSearchParams({ src: settingsSrc }); + const saved = await fetchJSON(`${MODEL_SETTINGS_API}?${params.toString()}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + entry.meta = { + ...(entry.meta || {}), + settingsSrc: saved?.sourceSrc || settingsSrc, + sourceSrc: saved?.sourceSrc || entry.meta?.sourceSrc || null, + viewerSettings: saved?.viewerSettings || payload.viewerSettings + }; + setStatus("Настройки модели сохранены"); + log(`Настройки модели сохранены: ${entry.name || entry.model?.id || settingsSrc}`, false); + } catch (err) { + console.error(err); + showError(err.message || "Не удалось сохранить настройки модели"); + } finally { + if (saveModelSettingsButton) { + saveModelSettingsButton.textContent = previousLabel; + } + updatePanelsVisibility(); + } +}; + const applyViewerState = (state) => { if (!viewer || !state || typeof state !== "object") return; if (state.camera && viewer.camera) { - const { eye, look, up, projection } = state.camera; - if (Array.isArray(eye) && eye.length === 3) viewer.camera.eye = [...eye]; - if (Array.isArray(look) && look.length === 3) viewer.camera.look = [...look]; - if (Array.isArray(up) && up.length === 3) viewer.camera.up = [...up]; - if (projection) viewer.camera.projection = projection; - updateProjectionButton(); + applyCameraSnapshot(state.camera); } if (typeof state.transparent === "boolean" && toggleTransparent) { toggleTransparent.checked = state.transparent; @@ -1954,6 +2359,14 @@ const applyViewerState = (state) => { activeDisplayMode = state.displayMode; applyDisplayMode(); } + if (typeof state.lightingMode === "string") { + activeLightingMode = state.lightingMode; + applyLightingMode(); + } + if (typeof state.navigationAxis === "string") { + activeNavigationAxis = state.navigationAxis; + applyNavigationAxis({ resetAuto: false }); + } if (state.transforms && typeof state.transforms === "object") { Object.keys(transformStore).forEach((k) => delete transformStore[k]); Object.assign(transformStore, safeClone(state.transforms)); @@ -2052,6 +2465,8 @@ const initViewer = async () => { dtxEnabled: true, colorTextureEnabled: true, }); + defaultCameraWorldAxis = snapshotVec(viewer.camera?.worldAxis) || [...NAVIGATION_AXIS_PRESETS["y-up"].worldAxis]; + defaultCameraUp = snapshotVec(viewer.camera?.up) || [...NAVIGATION_AXIS_PRESETS["y-up"].up]; if (viewer.scene?.highlightMaterial) { const hexToRGB = (hex) => { const h = hex.replace("#", ""); @@ -2124,6 +2539,7 @@ const initViewer = async () => { if (viewer.scene?.canvas) { viewer.scene.canvas.transparent = toggleTransparent.checked; } + applyLightingMode(); updateProjectionButton(); if (mainCanvas && !mainCanvas.hasAttribute("tabindex")) { @@ -2362,6 +2778,7 @@ const initViewer = async () => { const loadModel = (options) => { try { const { type, url, name = "", replace = true, id: forcedId, meta, edges } = options; + const modelSettings = meta?.viewerSettings; const normalizedType = normalizeType(type, typeof url === "string" ? url : ""); const inferredType = normalizedType || normalizeType(guessTypeFromName(typeof url === "string" ? url : "")); if (!inferredType || !acceptByType[inferredType]) { @@ -2373,6 +2790,10 @@ const initViewer = async () => { destroyAllModels({ preserveMeta: isLoadingSavedProject }); } + if (modelSettings) { + applyModelViewerSettings(modelSettings, { applyCamera: false }); + } + setStatus(`Загрузка: ${name || inferredType}...`); setLoading(true); const isBlob = typeof url === "string" && url.startsWith("blob:"); @@ -2418,9 +2839,13 @@ const initViewer = async () => { currentModel.on("loaded", () => { setTimeout(() => applyStoredTransformsToModel(id), 0); setTimeout(() => applyStoredTransformsToModel(id), 60); - if (!isLoadingSavedProject) { + const hasModelCamera = !!modelSettings?.viewerState?.camera; + if (!isLoadingSavedProject && !hasModelCamera) { viewer.cameraFlight.flyTo({ aabb: viewer.scene.getAABB({}) }); } + if (modelSettings) { + setTimeout(() => applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: false }), 0); + } setStatus(`Загружено: ${name || inferredType}`); dropZone.classList.add("hidden"); setLoading(false); @@ -2484,7 +2909,7 @@ const initViewer = async () => { model.on("error", (err) => reject(new Error(err?.message || err))); }); - const loadStartupModelFromQuery = () => { + const loadStartupModelFromQuery = async () => { const params = new URLSearchParams(window.location.search); const src = params.get("url") || params.get("src"); if (!src) return; @@ -2495,9 +2920,44 @@ const initViewer = async () => { showError("Не удалось определить формат модели из URL"); return; } + + let settingsSrc = params.get("settingsSrc") || params.get("sourceSrc") || params.get("originalSrc") || src; + let modelSettings = null; + try { + const settingsResponse = await fetchModelSettings(settingsSrc); + modelSettings = settingsResponse?.viewerSettings || null; + settingsSrc = settingsResponse?.sourceSrc || settingsSrc; + if (modelSettings) { + applyModelViewerSettings(modelSettings, { applyCamera: false }); + } + } catch (err) { + console.warn("model settings unavailable", err); + } + if (params.has("displayMode")) { activeDisplayMode = params.get("displayMode") || "source"; - setDisplayModeButtonState(); + setDisplayModeControlState(); + } + if (params.has("lightingMode") || params.has("light")) { + activeLightingMode = params.get("lightingMode") || params.get("light") || "ambient"; + applyLightingMode(); + } + if (params.has("navigationAxis") || params.has("axis")) { + activeNavigationAxis = params.get("navigationAxis") || params.get("axis") || "auto"; + applyNavigationAxis({ resetAuto: false }); + } + + if (modelSettings?.viewerState) { + modelSettings = { + ...modelSettings, + viewerState: { + ...modelSettings.viewerState, + displayMode: activeDisplayMode, + lightingMode: activeLightingMode, + navigationAxis: activeNavigationAxis, + edges: params.has("edges") ? params.get("edges") !== "false" : modelSettings.viewerState.edges + } + }; } loadModel({ @@ -2509,6 +2969,9 @@ const initViewer = async () => { meta: { label: name, source: "query", + settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src), + artifactSrc: normalizeModelSettingsSrc(src), + viewerSettings: modelSettings, }, }); }; @@ -2797,15 +3260,47 @@ const loadProjectSnapshot = async (project) => { rebuildNavCube(sdk); applyHighlightTheme(); applyMeasureTheme(); - if (displayModeControl) { - displayModeButtons.forEach((button) => { - button.addEventListener("click", () => { - activeDisplayMode = button.dataset.displayMode || "source"; + if (displayModeControl && displayModeSelectHost) { + displayModeSelect = createGlassSelect({ + host: displayModeSelectHost, + value: activeDisplayMode, + options: DISPLAY_MODE_OPTIONS, + ariaLabel: "Режим отображения", + onChange: (value) => { + activeDisplayMode = value; applyDisplayMode(); focusCanvasAndControls(); - }); + }, }); - setDisplayModeButtonState(); + setDisplayModeControlState(); + } + if (lightingModeControl && lightingModeSelectHost) { + lightingModeSelect = createGlassSelect({ + host: lightingModeSelectHost, + value: activeLightingMode, + options: LIGHTING_MODE_OPTIONS, + ariaLabel: "Свет", + onChange: (value) => { + activeLightingMode = value; + applyLightingMode({ announce: true }); + focusCanvasAndControls(); + }, + }); + setLightingModeControlState(); + } + if (navigationAxisControl && navigationAxisSelectHost) { + navigationAxisSelect = createGlassSelect({ + host: navigationAxisSelectHost, + value: activeNavigationAxis, + options: NAVIGATION_AXIS_OPTIONS, + ariaLabel: "Ось навигации", + onChange: (value) => { + activeNavigationAxis = value; + applyNavigationAxis({ resetAuto: true, announce: true }); + focusCanvasAndControls(); + }, + }); + setNavigationAxisControlState(); } loadProjectsList().catch(() => { templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" }); @@ -2918,6 +3413,9 @@ const loadProjectSnapshot = async (project) => { if (saveProjectButton) { saveProjectButton.addEventListener("click", openSaveModal); } + if (saveModelSettingsButton) { + saveModelSettingsButton.addEventListener("click", saveCurrentModelSettings); + } saveProjectCancel?.addEventListener("click", closeSaveModal); saveProjectClose?.addEventListener("click", closeSaveModal); saveProjectConfirm?.addEventListener("click", submitSaveProject); @@ -3186,7 +3684,10 @@ const loadProjectSnapshot = async (project) => { }); } - loadStartupModelFromQuery(); + loadStartupModelFromQuery().catch((err) => { + console.warn("startup model load failed", err); + showError(err.message || "Не удалось загрузить модель из URL"); + }); }; document.addEventListener("DOMContentLoaded", () => { diff --git a/frontend/index.html b/frontend/index.html index e8632bf..79ca4d3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -87,7 +87,7 @@ - +
@@ -128,12 +128,18 @@

Инспектор

-
-
Режим отображения
-
- - - +
+
+
Режим отображения
+
+
+
+
Свет
+
+
+
@@ -178,6 +184,7 @@
+
@@ -231,6 +238,6 @@ - + diff --git a/frontend/src/ui/glassSelect.js b/frontend/src/ui/glassSelect.js new file mode 100644 index 0000000..68ec87f --- /dev/null +++ b/frontend/src/ui/glassSelect.js @@ -0,0 +1,216 @@ +const closeEventName = "nodedc-select-open"; + +const makeSelectId = () => `ndc-select-${Math.random().toString(16).slice(2)}`; + +export function createGlassSelect({ + host, + value, + options = [], + ariaLabel = "Выбрать", + disabled = false, + onChange = null, +}) { + if (!host) return null; + + const selectId = makeSelectId(); + let currentValue = value; + let currentOptions = [...options]; + let open = false; + let menu = null; + let menuRect = { top: 0, left: 0, width: 0 }; + + host.innerHTML = ""; + + const root = document.createElement("div"); + root.className = "nodedc-select"; + + const control = document.createElement("div"); + control.className = "nodedc-select__control"; + + const valueEl = document.createElement("div"); + valueEl.className = "nodedc-select__value"; + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "nodedc-select__toggle"; + toggle.setAttribute("aria-label", ariaLabel); + toggle.setAttribute("aria-haspopup", "listbox"); + toggle.disabled = !!disabled; + + const chevron = document.createElement("span"); + chevron.className = "nodedc-select__chevron"; + chevron.setAttribute("aria-hidden", "true"); + toggle.appendChild(chevron); + + control.appendChild(valueEl); + control.appendChild(toggle); + root.appendChild(control); + host.appendChild(root); + + const selectedOption = () => ( + currentOptions.find((option) => option.value === currentValue) || + currentOptions[0] || + null + ); + + const updateValue = () => { + const selected = selectedOption(); + valueEl.textContent = selected?.label || ""; + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + }; + + const updateMenuRect = () => { + const rect = root.getBoundingClientRect(); + const toggleWidth = 42; + const gap = 8; + const menuMaxHeight = 232; + const bottomTop = rect.bottom + 6; + const top = bottomTop + menuMaxHeight > window.innerHeight - 12 + ? Math.max(12, rect.top - menuMaxHeight - 6) + : bottomTop; + + menuRect = { + top, + left: rect.left, + width: Math.max(180, rect.width - toggleWidth - gap), + }; + + if (menu) { + menu.style.top = `${menuRect.top}px`; + menu.style.left = `${menuRect.left}px`; + menu.style.width = `${menuRect.width}px`; + } + }; + + const renderMenuOptions = () => { + if (!menu) return; + menu.innerHTML = ""; + currentOptions.forEach((option) => { + const item = document.createElement("button"); + item.type = "button"; + item.className = "nodedc-dropdown-option nodedc-select__option"; + item.dataset.value = option.value; + if (option.value === currentValue) { + item.dataset.selected = "true"; + } + item.setAttribute("role", "option"); + item.setAttribute("aria-selected", option.value === currentValue ? "true" : "false"); + item.textContent = option.label; + item.addEventListener("click", () => { + setValue(option.value, { emit: true }); + closeMenu(); + }); + menu.appendChild(item); + }); + }; + + const openMenu = () => { + if (toggle.disabled || open) return; + open = true; + updateValue(); + updateMenuRect(); + window.dispatchEvent(new CustomEvent(closeEventName, { detail: { id: selectId } })); + + menu = document.createElement("div"); + menu.className = "nodedc-dropdown-surface nodedc-select__menu"; + menu.setAttribute("role", "listbox"); + menu.style.position = "fixed"; + menu.style.top = `${menuRect.top}px`; + menu.style.left = `${menuRect.left}px`; + menu.style.width = `${menuRect.width}px`; + renderMenuOptions(); + document.body.appendChild(menu); + + document.addEventListener("mousedown", handleDocumentPointerDown); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener(closeEventName, handleOtherSelectOpen); + window.addEventListener("resize", updateMenuRect); + window.addEventListener("scroll", updateMenuRect, true); + }; + + function closeMenu() { + if (!open) return; + open = false; + updateValue(); + if (menu) { + menu.remove(); + menu = null; + } + document.removeEventListener("mousedown", handleDocumentPointerDown); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener(closeEventName, handleOtherSelectOpen); + window.removeEventListener("resize", updateMenuRect); + window.removeEventListener("scroll", updateMenuRect, true); + } + + function handleDocumentPointerDown(event) { + const target = event.target; + if ( + target instanceof Node && + !root.contains(target) && + (!menu || !menu.contains(target)) + ) { + closeMenu(); + } + } + + function handleKeyDown(event) { + if (event.key === "Escape") closeMenu(); + } + + function handleOtherSelectOpen(event) { + if (event?.detail?.id !== selectId) closeMenu(); + } + + function setValue(nextValue, { emit = false } = {}) { + const nextOption = currentOptions.find((option) => option.value === nextValue); + if (!nextOption) return; + const changed = currentValue !== nextValue; + currentValue = nextValue; + updateValue(); + renderMenuOptions(); + if (emit && changed && typeof onChange === "function") { + onChange(nextValue); + } + } + + const setOptions = (nextOptions = [], nextValue = currentValue) => { + currentOptions = [...nextOptions]; + currentValue = currentOptions.some((option) => option.value === nextValue) + ? nextValue + : currentOptions[0]?.value; + updateValue(); + renderMenuOptions(); + }; + + const setDisabled = (nextDisabled) => { + toggle.disabled = !!nextDisabled; + if (toggle.disabled) closeMenu(); + }; + + const destroy = () => { + closeMenu(); + root.remove(); + }; + + toggle.addEventListener("click", () => { + if (open) { + closeMenu(); + } else { + openMenu(); + } + }); + valueEl.addEventListener("click", openMenu); + + updateValue(); + + return { + root, + setValue, + setOptions, + setDisabled, + close: closeMenu, + destroy, + getValue: () => currentValue, + }; +} diff --git a/frontend/src/ui/settingsMenu.js b/frontend/src/ui/settingsMenu.js index ec1bd07..74cbd95 100644 --- a/frontend/src/ui/settingsMenu.js +++ b/frontend/src/ui/settingsMenu.js @@ -1,7 +1,7 @@ import { createSliderControl } from "./controls/slider.js"; const defaultSettings = { - themeVersion: "v3-yellow", + themeVersion: "v4-beam-lime", bgOuter: "#0f0f0f", canvasTop: "#1c1c1c", canvasBottom: "#1c1c1c", @@ -15,14 +15,14 @@ const defaultSettings = { projectBtnFill: "#1c1c1c", projectBtnBorder: "#233044", projectBtnHover: "rgba(255,234,0,0.7)", - modalBtnPrimary: "#ffea00", + modalBtnPrimary: "#c4ff67", modalBtnSecondary: "#292929", - loaderColor: "#ffea00", + loaderColor: "#c4ff67", templateGlow: 80, toolbarBg: "#1c1c1c", - toolbarBgActive: "#ffea00", + toolbarBgActive: "#c4ff67", toolbarBorder: "#233044", - toolbarOutline: "#ffea00", + toolbarOutline: "#c4ff67", modalBg: "#1c1c1c", modalBgAlpha: 1, modalFocus: "#292929", @@ -35,19 +35,19 @@ const defaultSettings = { text: "#e6edf3", panel: "#1c1c1c", panel2: "#292929", - highlight: "#ffea00", + highlight: "#c4ff67", cubeText: "#242424", cubeEdges: "#e0e7ff", - plus: "#ffea00", - navCubeBase: "#ffea00", + plus: "#c4ff67", + navCubeBase: "#ffffff", navCubeHover: "#ffffff", navCubeText: "#242424", navCubeEdge: "#e0e7ff", navCubeEdgeAlpha: 0.6, - measureLine: "#ffea00", - measureLabel: "#ffea00", + measureLine: "#c4ff67", + measureLabel: "#c4ff67", measureLabelText: "#292929", - measureDot: "#ffea00", + measureDot: "#c4ff67", }; const STORAGE_KEY = "bimdc-settings-v3"; diff --git a/server/index.js b/server/index.js index beac572..bc40b1e 100644 --- a/server/index.js +++ b/server/index.js @@ -230,6 +230,77 @@ const resolveUploadSrc = (src) => { return resolved; }; +const normalizeUploadSrcValue = (src) => { + const resolved = resolveUploadSrc(src); + return resolved ? srcFromUploadPath(resolved) : null; +}; + +const findManifestForModelSrc = async (src) => { + const uploadPath = resolveUploadSrc(src); + if (!uploadPath) { + return null; + } + const sourceSrc = srcFromUploadPath(uploadPath); + const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(uploadPath).toLowerCase()); + if (sourceFormat) { + return { + manifestPath: manifestPathForSource(uploadPath), + modelPath: uploadPath, + sourceSrc + }; + } + + const directManifestPath = manifestPathForSource(uploadPath); + if (existsSync(directManifestPath)) { + return { + manifestPath: directManifestPath, + modelPath: uploadPath, + sourceSrc + }; + } + + let entries = []; + try { + entries = await fs.readdir(path.dirname(uploadPath)); + } catch (_) { + return { + manifestPath: directManifestPath, + modelPath: uploadPath, + sourceSrc + }; + } + + for (const entry of entries) { + if (!entry.endsWith(".beam.json")) { + continue; + } + const candidatePath = path.join(path.dirname(uploadPath), entry); + const manifest = await readJSONFile(candidatePath); + if (!manifest || typeof manifest !== "object") { + continue; + } + const relatedSrcs = [ + manifest.sourceSrc, + manifest.artifactSrc, + manifest.fallbackArtifactSrc, + manifest.metadataSrc + ].map(normalizeUploadSrcValue).filter(Boolean); + if (relatedSrcs.includes(sourceSrc)) { + return { + manifestPath: candidatePath, + modelPath: uploadPath, + sourceSrc: normalizeUploadSrcValue(manifest.sourceSrc) || sourceSrc + }; + } + } + + return { + manifestPath: directManifestPath, + modelPath: uploadPath, + sourceSrc + }; +}; + const readJSONFile = async (filePath) => { try { const raw = await fs.readFile(filePath, "utf8"); @@ -347,6 +418,79 @@ const handleConversionStatus = async (res, searchParams) => { sendJSON(res, 200, manifest); }; +const handleGetModelSettings = async (res, searchParams) => { + const manifestRef = await findManifestForModelSrc(searchParams.get("src")); + if (!manifestRef) { + sendText(res, 400, "Invalid model path"); + return; + } + if (!existsSync(manifestRef.modelPath)) { + sendText(res, 404, "Model file not found"); + return; + } + + const manifest = await readJSONFile(manifestRef.manifestPath); + sendJSON(res, 200, { + sourceSrc: manifest?.sourceSrc || manifestRef.sourceSrc, + artifactSrc: manifest?.artifactSrc || null, + fallbackArtifactSrc: manifest?.fallbackArtifactSrc || null, + viewerSettings: manifest?.viewerSettings || null + }); +}; + +const handlePutModelSettings = async (req, res, searchParams) => { + const manifestRef = await findManifestForModelSrc(searchParams.get("src")); + if (!manifestRef) { + sendText(res, 400, "Invalid model path"); + return; + } + if (!existsSync(manifestRef.modelPath)) { + sendText(res, 404, "Model file not found"); + return; + } + + let payload = {}; + try { + payload = await parseJSONBody(req); + } catch (err) { + sendText(res, 400, err.message || "Invalid JSON"); + return; + } + + const viewerSettings = payload?.viewerSettings; + if (!viewerSettings || typeof viewerSettings !== "object" || Array.isArray(viewerSettings)) { + sendText(res, 400, "viewerSettings object is required"); + return; + } + + const existing = await readJSONFile(manifestRef.manifestPath) || {}; + const now = nowIso(); + const nextManifest = { + ...existing, + sourceSrc: existing.sourceSrc || manifestRef.sourceSrc, + status: existing.status || "ready", + viewerSettings: { + ...viewerSettings, + updatedAt: viewerSettings.updatedAt || now + }, + viewerSettingsUpdatedAt: now, + updatedAt: existing.updatedAt || now + }; + + try { + await writeJSONFile(manifestRef.manifestPath, nextManifest); + sendJSON(res, 200, { + sourceSrc: nextManifest.sourceSrc, + artifactSrc: nextManifest.artifactSrc || null, + fallbackArtifactSrc: nextManifest.fallbackArtifactSrc || null, + viewerSettings: nextManifest.viewerSettings + }); + } catch (err) { + console.error("[server] failed to save model settings", err); + sendText(res, 500, "Failed to save model settings"); + } +}; + const buildProjectPayload = (body) => { const now = new Date().toISOString(); const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`; @@ -634,6 +778,14 @@ const requestHandler = async (req, res) => { return handleConversionStatus(res, url.searchParams); } + if (req.method === "GET" && url.pathname === "/api/model-settings") { + return handleGetModelSettings(res, url.searchParams); + } + + if (req.method === "PUT" && url.pathname === "/api/model-settings") { + return handlePutModelSettings(req, res, url.searchParams); + } + await serveStatic(req, res, url); };