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 @@ - +