beam: add per-model viewer settings
This commit is contained in:
parent
09b1173697
commit
a95a7d5c38
|
|
@ -40,6 +40,9 @@
|
||||||
--measure-label: #ffea00;
|
--measure-label: #ffea00;
|
||||||
--measure-label-text: #292929;
|
--measure-label-text: #292929;
|
||||||
--measure-dot: #ffea00;
|
--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-size: 25px;
|
||||||
--tb-gap: 10px;
|
--tb-gap: 10px;
|
||||||
--tb-radius: 7px;
|
--tb-radius: 7px;
|
||||||
|
|
@ -449,9 +452,17 @@ header {
|
||||||
}
|
}
|
||||||
|
|
||||||
#saveProjectSection {
|
#saveProjectSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondary-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.log {
|
.log {
|
||||||
background: var(--modal-focus);
|
background: var(--modal-focus);
|
||||||
border-radius: var(--modal-radius, 12px);
|
border-radius: var(--modal-radius, 12px);
|
||||||
|
|
@ -591,52 +602,154 @@ header {
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.display-mode-panel {
|
.inspector-controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.display-mode-title {
|
.inspector-control-row {
|
||||||
margin-bottom: 7px;
|
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);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 760;
|
||||||
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.display-mode-buttons {
|
.inspector-select-host {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.display-mode-buttons button {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid var(--modal-border);
|
}
|
||||||
border-radius: 9px;
|
|
||||||
background: var(--modal-focus);
|
.nodedc-select {
|
||||||
color: var(--muted);
|
position: relative;
|
||||||
cursor: pointer;
|
min-width: 0;
|
||||||
font: inherit;
|
width: 100%;
|
||||||
font-size: 11px;
|
}
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
.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;
|
outline: none;
|
||||||
padding: 8px 6px;
|
transition: background 140ms ease, color 140ms ease;
|
||||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.display-mode-buttons button:hover,
|
.nodedc-select__value {
|
||||||
.display-mode-buttons button.active {
|
display: flex;
|
||||||
border-color: var(--accent);
|
align-items: center;
|
||||||
background: color-mix(in srgb, var(--accent) 14%, var(--modal-focus));
|
min-width: 0;
|
||||||
color: var(--text);
|
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,
|
.nodedc-select__toggle {
|
||||||
.display-mode-buttons button:focus-visible {
|
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;
|
outline: none;
|
||||||
box-shadow: 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 {
|
.examples-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { toolbarConfig, applyToolbarVars } from "./src/ui/toolbar.js";
|
||||||
import { createTemplatesMenu } from "./src/ui/templatesMenu.js";
|
import { createTemplatesMenu } from "./src/ui/templatesMenu.js";
|
||||||
import { createSettingsMenu } from "./src/ui/settingsMenu.js";
|
import { createSettingsMenu } from "./src/ui/settingsMenu.js";
|
||||||
import { createMeasurementModal } from "./src/ui/measurementModal.js";
|
import { createMeasurementModal } from "./src/ui/measurementModal.js";
|
||||||
|
import { createGlassSelect } from "./src/ui/glassSelect.js";
|
||||||
import "./theme.js";
|
import "./theme.js";
|
||||||
import { TransformGizmo } from "./transformGizmo.js";
|
import { TransformGizmo } from "./transformGizmo.js";
|
||||||
import { createObjectModal } from "./src/ui/objectModal.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 projectionToggle = document.querySelector('[data-action="projection"]');
|
||||||
const treeContainer = document.getElementById("treeContainer");
|
const treeContainer = document.getElementById("treeContainer");
|
||||||
const displayModeControl = document.getElementById("displayModeControl");
|
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 logList = document.getElementById("logList");
|
||||||
const loaderEl = document.getElementById("loader");
|
const loaderEl = document.getElementById("loader");
|
||||||
const navCubeCanvas = document.getElementById("navCube");
|
const navCubeCanvas = document.getElementById("navCube");
|
||||||
const mainCanvas = document.getElementById("viewerCanvas");
|
const mainCanvas = document.getElementById("viewerCanvas");
|
||||||
const saveProjectSection = document.getElementById("saveProjectSection");
|
const saveProjectSection = document.getElementById("saveProjectSection");
|
||||||
const saveProjectButton = document.getElementById("saveProjectButton");
|
const saveProjectButton = document.getElementById("saveProjectButton");
|
||||||
|
const saveModelSettingsButton = document.getElementById("saveModelSettingsButton");
|
||||||
const saveProjectBackdrop = document.getElementById("saveProjectBackdrop");
|
const saveProjectBackdrop = document.getElementById("saveProjectBackdrop");
|
||||||
const saveProjectModal = document.getElementById("saveProjectModal");
|
const saveProjectModal = document.getElementById("saveProjectModal");
|
||||||
const saveProjectClose = document.getElementById("saveProjectClose");
|
const saveProjectClose = document.getElementById("saveProjectClose");
|
||||||
|
|
@ -98,6 +104,7 @@ const applyPanelOffsets = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_BASE = "/api/projects";
|
const API_BASE = "/api/projects";
|
||||||
|
const MODEL_SETTINGS_API = "/api/model-settings";
|
||||||
|
|
||||||
const acceptByType = {
|
const acceptByType = {
|
||||||
xkt: ".xkt",
|
xkt: ".xkt",
|
||||||
|
|
@ -147,6 +154,48 @@ const templateFallbacks = DEFAULT_EXAMPLES.map((ex) => ({
|
||||||
type: "template",
|
type: "template",
|
||||||
updatedAt: null
|
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 templatesMenu = null;
|
||||||
let settingsMenu = null;
|
let settingsMenu = null;
|
||||||
let measurementModal = null;
|
let measurementModal = null;
|
||||||
|
|
@ -165,6 +214,13 @@ const transformStore = {}; // modelId -> objectId -> {position, rotation, scale}
|
||||||
const modelTypes = {}; // modelId -> normalized type
|
const modelTypes = {}; // modelId -> normalized type
|
||||||
const sceneModels = {}; // modelId -> SceneModel
|
const sceneModels = {}; // modelId -> SceneModel
|
||||||
let activeDisplayMode = "source";
|
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 }
|
let currentSelection = null; // { modelId, objectId }
|
||||||
const customTrees = new Map(); // modelId -> element
|
const customTrees = new Map(); // modelId -> element
|
||||||
let navCubePlugin = null;
|
let navCubePlugin = null;
|
||||||
|
|
@ -232,39 +288,111 @@ const updateProjectionButton = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setDisplayModeButtonState = () => {
|
const normalizeDisplayMode = (mode) => (
|
||||||
displayModeButtons.forEach((button) => {
|
DISPLAY_MODE_OPTIONS.some((option) => option.value === mode) ? mode : "source"
|
||||||
button.classList.toggle("active", button.dataset.displayMode === activeDisplayMode);
|
);
|
||||||
});
|
|
||||||
|
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) {
|
switch (mode) {
|
||||||
|
case "clay":
|
||||||
|
return { colorize: [0.86, 0.86, 0.82], opacity: 1, edges: false, xrayed: false };
|
||||||
case "white":
|
case "white":
|
||||||
return [1, 1, 1];
|
return { colorize: [1, 1, 1], opacity: 1, edges: false, xrayed: false };
|
||||||
case "contrast":
|
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":
|
case "source":
|
||||||
default:
|
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 applyDisplayModeToModel = (modelId) => {
|
||||||
const model = sceneModels[modelId] || viewer?.scene?.models?.[modelId];
|
const model = sceneModels[modelId] || viewer?.scene?.models?.[modelId];
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
const colorize = getDisplayModeColorize(activeDisplayMode);
|
const preset = getDisplayModePreset(normalizeDisplayMode(activeDisplayMode));
|
||||||
const objects = Object.values(model.objects || {});
|
const objects = Object.values(model.objects || {});
|
||||||
const targets = objects.length ? objects : [model];
|
const targets = objects.length ? objects : [model];
|
||||||
|
|
||||||
targets.forEach((entity) => {
|
targets.forEach((entity) => {
|
||||||
if (!entity) return;
|
if (!entity) return;
|
||||||
try {
|
try {
|
||||||
entity.colorize = colorize;
|
entity.xrayed = !!preset.xrayed;
|
||||||
entity.opacity = 1;
|
entity.colorize = preset.colorize;
|
||||||
if (activeDisplayMode !== "source") {
|
entity.opacity = preset.opacity;
|
||||||
entity.edges = false;
|
entity.edges = preset.edges === "base" ? getBaseEdgesForModel(modelId) : !!preset.edges;
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("display mode update failed", err);
|
console.warn("display mode update failed", err);
|
||||||
}
|
}
|
||||||
|
|
@ -274,10 +402,165 @@ const applyDisplayModeToModel = (modelId) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyDisplayMode = () => {
|
const applyDisplayMode = () => {
|
||||||
setDisplayModeButtonState();
|
setDisplayModeControlState();
|
||||||
Object.keys(sceneModels).forEach((modelId) => applyDisplayModeToModel(modelId));
|
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 = () => {
|
const toggleProjection = () => {
|
||||||
if (!viewer?.camera) return;
|
if (!viewer?.camera) return;
|
||||||
const cam = viewer.camera;
|
const cam = viewer.camera;
|
||||||
|
|
@ -569,6 +852,9 @@ const updatePanelsVisibility = () => {
|
||||||
if (saveProjectButton) {
|
if (saveProjectButton) {
|
||||||
saveProjectButton.disabled = !hasModels;
|
saveProjectButton.disabled = !hasModels;
|
||||||
}
|
}
|
||||||
|
if (saveModelSettingsButton) {
|
||||||
|
saveModelSettingsButton.disabled = !hasModels || !hasModelSettingsTarget();
|
||||||
|
}
|
||||||
if (deleteProjectButton) {
|
if (deleteProjectButton) {
|
||||||
deleteProjectButton.disabled = !(currentProjectMeta || lastAttemptedProjectId);
|
deleteProjectButton.disabled = !(currentProjectMeta || lastAttemptedProjectId);
|
||||||
}
|
}
|
||||||
|
|
@ -1845,7 +2131,10 @@ const buildProjectSnapshot = async (name) => {
|
||||||
type: modelType,
|
type: modelType,
|
||||||
meta: {
|
meta: {
|
||||||
label: meta.label || entry.name || model.id,
|
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
|
visible: model.visible !== false
|
||||||
};
|
};
|
||||||
|
|
@ -1890,6 +2179,8 @@ const buildProjectSnapshot = async (name) => {
|
||||||
edges: !!toggleEdges?.checked,
|
edges: !!toggleEdges?.checked,
|
||||||
addMode: !!addMode?.checked,
|
addMode: !!addMode?.checked,
|
||||||
displayMode: activeDisplayMode,
|
displayMode: activeDisplayMode,
|
||||||
|
lightingMode: activeLightingMode,
|
||||||
|
navigationAxis: activeNavigationAxis,
|
||||||
selection: {
|
selection: {
|
||||||
ids: lastSelection.ids || [],
|
ids: lastSelection.ids || [],
|
||||||
modelId: lastSelection.modelId || null,
|
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) => {
|
const applyViewerState = (state) => {
|
||||||
if (!viewer || !state || typeof state !== "object") return;
|
if (!viewer || !state || typeof state !== "object") return;
|
||||||
if (state.camera && viewer.camera) {
|
if (state.camera && viewer.camera) {
|
||||||
const { eye, look, up, projection } = state.camera;
|
applyCameraSnapshot(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();
|
|
||||||
}
|
}
|
||||||
if (typeof state.transparent === "boolean" && toggleTransparent) {
|
if (typeof state.transparent === "boolean" && toggleTransparent) {
|
||||||
toggleTransparent.checked = state.transparent;
|
toggleTransparent.checked = state.transparent;
|
||||||
|
|
@ -1954,6 +2359,14 @@ const applyViewerState = (state) => {
|
||||||
activeDisplayMode = state.displayMode;
|
activeDisplayMode = state.displayMode;
|
||||||
applyDisplayMode();
|
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") {
|
if (state.transforms && typeof state.transforms === "object") {
|
||||||
Object.keys(transformStore).forEach((k) => delete transformStore[k]);
|
Object.keys(transformStore).forEach((k) => delete transformStore[k]);
|
||||||
Object.assign(transformStore, safeClone(state.transforms));
|
Object.assign(transformStore, safeClone(state.transforms));
|
||||||
|
|
@ -2052,6 +2465,8 @@ const initViewer = async () => {
|
||||||
dtxEnabled: true,
|
dtxEnabled: true,
|
||||||
colorTextureEnabled: 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) {
|
if (viewer.scene?.highlightMaterial) {
|
||||||
const hexToRGB = (hex) => {
|
const hexToRGB = (hex) => {
|
||||||
const h = hex.replace("#", "");
|
const h = hex.replace("#", "");
|
||||||
|
|
@ -2124,6 +2539,7 @@ const initViewer = async () => {
|
||||||
if (viewer.scene?.canvas) {
|
if (viewer.scene?.canvas) {
|
||||||
viewer.scene.canvas.transparent = toggleTransparent.checked;
|
viewer.scene.canvas.transparent = toggleTransparent.checked;
|
||||||
}
|
}
|
||||||
|
applyLightingMode();
|
||||||
updateProjectionButton();
|
updateProjectionButton();
|
||||||
|
|
||||||
if (mainCanvas && !mainCanvas.hasAttribute("tabindex")) {
|
if (mainCanvas && !mainCanvas.hasAttribute("tabindex")) {
|
||||||
|
|
@ -2362,6 +2778,7 @@ const initViewer = async () => {
|
||||||
const loadModel = (options) => {
|
const loadModel = (options) => {
|
||||||
try {
|
try {
|
||||||
const { type, url, name = "", replace = true, id: forcedId, meta, edges } = options;
|
const { type, url, name = "", replace = true, id: forcedId, meta, edges } = options;
|
||||||
|
const modelSettings = meta?.viewerSettings;
|
||||||
const normalizedType = normalizeType(type, typeof url === "string" ? url : "");
|
const normalizedType = normalizeType(type, typeof url === "string" ? url : "");
|
||||||
const inferredType = normalizedType || normalizeType(guessTypeFromName(typeof url === "string" ? url : ""));
|
const inferredType = normalizedType || normalizeType(guessTypeFromName(typeof url === "string" ? url : ""));
|
||||||
if (!inferredType || !acceptByType[inferredType]) {
|
if (!inferredType || !acceptByType[inferredType]) {
|
||||||
|
|
@ -2373,6 +2790,10 @@ const initViewer = async () => {
|
||||||
destroyAllModels({ preserveMeta: isLoadingSavedProject });
|
destroyAllModels({ preserveMeta: isLoadingSavedProject });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (modelSettings) {
|
||||||
|
applyModelViewerSettings(modelSettings, { applyCamera: false });
|
||||||
|
}
|
||||||
|
|
||||||
setStatus(`Загрузка: ${name || inferredType}...`);
|
setStatus(`Загрузка: ${name || inferredType}...`);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const isBlob = typeof url === "string" && url.startsWith("blob:");
|
const isBlob = typeof url === "string" && url.startsWith("blob:");
|
||||||
|
|
@ -2418,9 +2839,13 @@ const initViewer = async () => {
|
||||||
currentModel.on("loaded", () => {
|
currentModel.on("loaded", () => {
|
||||||
setTimeout(() => applyStoredTransformsToModel(id), 0);
|
setTimeout(() => applyStoredTransformsToModel(id), 0);
|
||||||
setTimeout(() => applyStoredTransformsToModel(id), 60);
|
setTimeout(() => applyStoredTransformsToModel(id), 60);
|
||||||
if (!isLoadingSavedProject) {
|
const hasModelCamera = !!modelSettings?.viewerState?.camera;
|
||||||
|
if (!isLoadingSavedProject && !hasModelCamera) {
|
||||||
viewer.cameraFlight.flyTo({ aabb: viewer.scene.getAABB({}) });
|
viewer.cameraFlight.flyTo({ aabb: viewer.scene.getAABB({}) });
|
||||||
}
|
}
|
||||||
|
if (modelSettings) {
|
||||||
|
setTimeout(() => applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: false }), 0);
|
||||||
|
}
|
||||||
setStatus(`Загружено: ${name || inferredType}`);
|
setStatus(`Загружено: ${name || inferredType}`);
|
||||||
dropZone.classList.add("hidden");
|
dropZone.classList.add("hidden");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -2484,7 +2909,7 @@ const initViewer = async () => {
|
||||||
model.on("error", (err) => reject(new Error(err?.message || err)));
|
model.on("error", (err) => reject(new Error(err?.message || err)));
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadStartupModelFromQuery = () => {
|
const loadStartupModelFromQuery = async () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const src = params.get("url") || params.get("src");
|
const src = params.get("url") || params.get("src");
|
||||||
if (!src) return;
|
if (!src) return;
|
||||||
|
|
@ -2495,9 +2920,44 @@ const initViewer = async () => {
|
||||||
showError("Не удалось определить формат модели из URL");
|
showError("Не удалось определить формат модели из URL");
|
||||||
return;
|
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")) {
|
if (params.has("displayMode")) {
|
||||||
activeDisplayMode = params.get("displayMode") || "source";
|
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({
|
loadModel({
|
||||||
|
|
@ -2509,6 +2969,9 @@ const initViewer = async () => {
|
||||||
meta: {
|
meta: {
|
||||||
label: name,
|
label: name,
|
||||||
source: "query",
|
source: "query",
|
||||||
|
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src),
|
||||||
|
artifactSrc: normalizeModelSettingsSrc(src),
|
||||||
|
viewerSettings: modelSettings,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -2797,15 +3260,47 @@ const loadProjectSnapshot = async (project) => {
|
||||||
rebuildNavCube(sdk);
|
rebuildNavCube(sdk);
|
||||||
applyHighlightTheme();
|
applyHighlightTheme();
|
||||||
applyMeasureTheme();
|
applyMeasureTheme();
|
||||||
if (displayModeControl) {
|
if (displayModeControl && displayModeSelectHost) {
|
||||||
displayModeButtons.forEach((button) => {
|
displayModeSelect = createGlassSelect({
|
||||||
button.addEventListener("click", () => {
|
host: displayModeSelectHost,
|
||||||
activeDisplayMode = button.dataset.displayMode || "source";
|
value: activeDisplayMode,
|
||||||
|
options: DISPLAY_MODE_OPTIONS,
|
||||||
|
ariaLabel: "Режим отображения",
|
||||||
|
onChange: (value) => {
|
||||||
|
activeDisplayMode = value;
|
||||||
applyDisplayMode();
|
applyDisplayMode();
|
||||||
focusCanvasAndControls();
|
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(() => {
|
loadProjectsList().catch(() => {
|
||||||
templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" });
|
templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" });
|
||||||
|
|
@ -2918,6 +3413,9 @@ const loadProjectSnapshot = async (project) => {
|
||||||
if (saveProjectButton) {
|
if (saveProjectButton) {
|
||||||
saveProjectButton.addEventListener("click", openSaveModal);
|
saveProjectButton.addEventListener("click", openSaveModal);
|
||||||
}
|
}
|
||||||
|
if (saveModelSettingsButton) {
|
||||||
|
saveModelSettingsButton.addEventListener("click", saveCurrentModelSettings);
|
||||||
|
}
|
||||||
saveProjectCancel?.addEventListener("click", closeSaveModal);
|
saveProjectCancel?.addEventListener("click", closeSaveModal);
|
||||||
saveProjectClose?.addEventListener("click", closeSaveModal);
|
saveProjectClose?.addEventListener("click", closeSaveModal);
|
||||||
saveProjectConfirm?.addEventListener("click", submitSaveProject);
|
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", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -128,12 +128,18 @@
|
||||||
<div class="panel-section">
|
<div class="panel-section">
|
||||||
<h3>Инспектор</h3>
|
<h3>Инспектор</h3>
|
||||||
<div id="treeContainer" class="tree"></div>
|
<div id="treeContainer" class="tree"></div>
|
||||||
<div class="display-mode-panel" id="displayModeControl">
|
<div class="inspector-controls">
|
||||||
<div class="display-mode-title">Режим отображения</div>
|
<div class="inspector-control-row" id="displayModeControl">
|
||||||
<div class="display-mode-buttons">
|
<div class="inspector-control-label">Режим отображения</div>
|
||||||
<button type="button" data-display-mode="source" class="active">Исходный</button>
|
<div class="inspector-select-host" id="displayModeSelect"></div>
|
||||||
<button type="button" data-display-mode="white">Белый</button>
|
</div>
|
||||||
<button type="button" data-display-mode="contrast">Контраст</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -178,6 +184,7 @@
|
||||||
<div id="logList" class="log"></div>
|
<div id="logList" class="log"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-section" id="saveProjectSection">
|
<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="primary-btn" id="saveProjectButton" type="button">Сохранить проект</button>
|
||||||
<button class="secondary-btn danger-btn" id="deleteProjectButton" type="button">Удалить проект</button>
|
<button class="secondary-btn danger-btn" id="deleteProjectButton" type="button">Удалить проект</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -231,6 +238,6 @@
|
||||||
<input id="fileInput" class="hidden" type="file"
|
<input id="fileInput" class="hidden" type="file"
|
||||||
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
|
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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createSliderControl } from "./controls/slider.js";
|
import { createSliderControl } from "./controls/slider.js";
|
||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
themeVersion: "v3-yellow",
|
themeVersion: "v4-beam-lime",
|
||||||
bgOuter: "#0f0f0f",
|
bgOuter: "#0f0f0f",
|
||||||
canvasTop: "#1c1c1c",
|
canvasTop: "#1c1c1c",
|
||||||
canvasBottom: "#1c1c1c",
|
canvasBottom: "#1c1c1c",
|
||||||
|
|
@ -15,14 +15,14 @@ const defaultSettings = {
|
||||||
projectBtnFill: "#1c1c1c",
|
projectBtnFill: "#1c1c1c",
|
||||||
projectBtnBorder: "#233044",
|
projectBtnBorder: "#233044",
|
||||||
projectBtnHover: "rgba(255,234,0,0.7)",
|
projectBtnHover: "rgba(255,234,0,0.7)",
|
||||||
modalBtnPrimary: "#ffea00",
|
modalBtnPrimary: "#c4ff67",
|
||||||
modalBtnSecondary: "#292929",
|
modalBtnSecondary: "#292929",
|
||||||
loaderColor: "#ffea00",
|
loaderColor: "#c4ff67",
|
||||||
templateGlow: 80,
|
templateGlow: 80,
|
||||||
toolbarBg: "#1c1c1c",
|
toolbarBg: "#1c1c1c",
|
||||||
toolbarBgActive: "#ffea00",
|
toolbarBgActive: "#c4ff67",
|
||||||
toolbarBorder: "#233044",
|
toolbarBorder: "#233044",
|
||||||
toolbarOutline: "#ffea00",
|
toolbarOutline: "#c4ff67",
|
||||||
modalBg: "#1c1c1c",
|
modalBg: "#1c1c1c",
|
||||||
modalBgAlpha: 1,
|
modalBgAlpha: 1,
|
||||||
modalFocus: "#292929",
|
modalFocus: "#292929",
|
||||||
|
|
@ -35,19 +35,19 @@ const defaultSettings = {
|
||||||
text: "#e6edf3",
|
text: "#e6edf3",
|
||||||
panel: "#1c1c1c",
|
panel: "#1c1c1c",
|
||||||
panel2: "#292929",
|
panel2: "#292929",
|
||||||
highlight: "#ffea00",
|
highlight: "#c4ff67",
|
||||||
cubeText: "#242424",
|
cubeText: "#242424",
|
||||||
cubeEdges: "#e0e7ff",
|
cubeEdges: "#e0e7ff",
|
||||||
plus: "#ffea00",
|
plus: "#c4ff67",
|
||||||
navCubeBase: "#ffea00",
|
navCubeBase: "#ffffff",
|
||||||
navCubeHover: "#ffffff",
|
navCubeHover: "#ffffff",
|
||||||
navCubeText: "#242424",
|
navCubeText: "#242424",
|
||||||
navCubeEdge: "#e0e7ff",
|
navCubeEdge: "#e0e7ff",
|
||||||
navCubeEdgeAlpha: 0.6,
|
navCubeEdgeAlpha: 0.6,
|
||||||
measureLine: "#ffea00",
|
measureLine: "#c4ff67",
|
||||||
measureLabel: "#ffea00",
|
measureLabel: "#c4ff67",
|
||||||
measureLabelText: "#292929",
|
measureLabelText: "#292929",
|
||||||
measureDot: "#ffea00",
|
measureDot: "#c4ff67",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = "bimdc-settings-v3";
|
const STORAGE_KEY = "bimdc-settings-v3";
|
||||||
|
|
|
||||||
152
server/index.js
152
server/index.js
|
|
@ -230,6 +230,77 @@ const resolveUploadSrc = (src) => {
|
||||||
return resolved;
|
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) => {
|
const readJSONFile = async (filePath) => {
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(filePath, "utf8");
|
const raw = await fs.readFile(filePath, "utf8");
|
||||||
|
|
@ -347,6 +418,79 @@ const handleConversionStatus = async (res, searchParams) => {
|
||||||
sendJSON(res, 200, manifest);
|
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 buildProjectPayload = (body) => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`;
|
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);
|
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);
|
await serveStatic(req, res, url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue