beam: add per-model viewer settings

This commit is contained in:
CODEX 2026-06-04 22:36:15 +03:00
parent 09b1173697
commit a95a7d5c38
6 changed files with 1070 additions and 81 deletions

View File

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

View File

@ -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", () => {

View File

@ -87,7 +87,7 @@
<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=4">
<link rel="stylesheet" href="./dcViewer.css?v=5">
</head>
<body>
<header>
@ -128,12 +128,18 @@
<div class="panel-section">
<h3>Инспектор</h3>
<div id="treeContainer" class="tree"></div>
<div class="display-mode-panel" id="displayModeControl">
<div class="display-mode-title">Режим отображения</div>
<div class="display-mode-buttons">
<button type="button" data-display-mode="source" class="active">Исходный</button>
<button type="button" data-display-mode="white">Белый</button>
<button type="button" data-display-mode="contrast">Контраст</button>
<div class="inspector-controls">
<div class="inspector-control-row" id="displayModeControl">
<div class="inspector-control-label">Режим отображения</div>
<div class="inspector-select-host" id="displayModeSelect"></div>
</div>
<div class="inspector-control-row" id="lightingModeControl">
<div class="inspector-control-label">Свет</div>
<div class="inspector-select-host" id="lightingModeSelect"></div>
</div>
<div class="inspector-control-row" id="navigationAxisControl">
<div class="inspector-control-label">Ось навигации</div>
<div class="inspector-select-host" id="navigationAxisSelect"></div>
</div>
</div>
</div>
@ -178,6 +184,7 @@
<div id="logList" class="log"></div>
</div>
<div class="panel-section" id="saveProjectSection">
<button class="secondary-btn model-settings-btn" id="saveModelSettingsButton" type="button">Сохранить настройки</button>
<button class="primary-btn" id="saveProjectButton" type="button">Сохранить проект</button>
<button class="secondary-btn danger-btn" id="deleteProjectButton" type="button">Удалить проект</button>
</div>
@ -231,6 +238,6 @@
<input id="fileInput" class="hidden" type="file"
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
<script type="module" src="./dcViewer.js?v=4"></script>
<script type="module" src="./dcViewer.js?v=5"></script>
</body>
</html>

View File

@ -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,
};
}

View File

@ -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";

View File

@ -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);
};