beam: add per-model viewer settings
This commit is contained in:
parent
09b1173697
commit
a95a7d5c38
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
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";
|
||||
|
|
|
|||
152
server/index.js
152
server/index.js
|
|
@ -230,6 +230,77 @@ const resolveUploadSrc = (src) => {
|
|||
return resolved;
|
||||
};
|
||||
|
||||
const normalizeUploadSrcValue = (src) => {
|
||||
const resolved = resolveUploadSrc(src);
|
||||
return resolved ? srcFromUploadPath(resolved) : null;
|
||||
};
|
||||
|
||||
const findManifestForModelSrc = async (src) => {
|
||||
const uploadPath = resolveUploadSrc(src);
|
||||
if (!uploadPath) {
|
||||
return null;
|
||||
}
|
||||
const sourceSrc = srcFromUploadPath(uploadPath);
|
||||
const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(uploadPath).toLowerCase());
|
||||
if (sourceFormat) {
|
||||
return {
|
||||
manifestPath: manifestPathForSource(uploadPath),
|
||||
modelPath: uploadPath,
|
||||
sourceSrc
|
||||
};
|
||||
}
|
||||
|
||||
const directManifestPath = manifestPathForSource(uploadPath);
|
||||
if (existsSync(directManifestPath)) {
|
||||
return {
|
||||
manifestPath: directManifestPath,
|
||||
modelPath: uploadPath,
|
||||
sourceSrc
|
||||
};
|
||||
}
|
||||
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(path.dirname(uploadPath));
|
||||
} catch (_) {
|
||||
return {
|
||||
manifestPath: directManifestPath,
|
||||
modelPath: uploadPath,
|
||||
sourceSrc
|
||||
};
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(".beam.json")) {
|
||||
continue;
|
||||
}
|
||||
const candidatePath = path.join(path.dirname(uploadPath), entry);
|
||||
const manifest = await readJSONFile(candidatePath);
|
||||
if (!manifest || typeof manifest !== "object") {
|
||||
continue;
|
||||
}
|
||||
const relatedSrcs = [
|
||||
manifest.sourceSrc,
|
||||
manifest.artifactSrc,
|
||||
manifest.fallbackArtifactSrc,
|
||||
manifest.metadataSrc
|
||||
].map(normalizeUploadSrcValue).filter(Boolean);
|
||||
if (relatedSrcs.includes(sourceSrc)) {
|
||||
return {
|
||||
manifestPath: candidatePath,
|
||||
modelPath: uploadPath,
|
||||
sourceSrc: normalizeUploadSrcValue(manifest.sourceSrc) || sourceSrc
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
manifestPath: directManifestPath,
|
||||
modelPath: uploadPath,
|
||||
sourceSrc
|
||||
};
|
||||
};
|
||||
|
||||
const readJSONFile = async (filePath) => {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
|
|
@ -347,6 +418,79 @@ const handleConversionStatus = async (res, searchParams) => {
|
|||
sendJSON(res, 200, manifest);
|
||||
};
|
||||
|
||||
const handleGetModelSettings = async (res, searchParams) => {
|
||||
const manifestRef = await findManifestForModelSrc(searchParams.get("src"));
|
||||
if (!manifestRef) {
|
||||
sendText(res, 400, "Invalid model path");
|
||||
return;
|
||||
}
|
||||
if (!existsSync(manifestRef.modelPath)) {
|
||||
sendText(res, 404, "Model file not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = await readJSONFile(manifestRef.manifestPath);
|
||||
sendJSON(res, 200, {
|
||||
sourceSrc: manifest?.sourceSrc || manifestRef.sourceSrc,
|
||||
artifactSrc: manifest?.artifactSrc || null,
|
||||
fallbackArtifactSrc: manifest?.fallbackArtifactSrc || null,
|
||||
viewerSettings: manifest?.viewerSettings || null
|
||||
});
|
||||
};
|
||||
|
||||
const handlePutModelSettings = async (req, res, searchParams) => {
|
||||
const manifestRef = await findManifestForModelSrc(searchParams.get("src"));
|
||||
if (!manifestRef) {
|
||||
sendText(res, 400, "Invalid model path");
|
||||
return;
|
||||
}
|
||||
if (!existsSync(manifestRef.modelPath)) {
|
||||
sendText(res, 404, "Model file not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await parseJSONBody(req);
|
||||
} catch (err) {
|
||||
sendText(res, 400, err.message || "Invalid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
const viewerSettings = payload?.viewerSettings;
|
||||
if (!viewerSettings || typeof viewerSettings !== "object" || Array.isArray(viewerSettings)) {
|
||||
sendText(res, 400, "viewerSettings object is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await readJSONFile(manifestRef.manifestPath) || {};
|
||||
const now = nowIso();
|
||||
const nextManifest = {
|
||||
...existing,
|
||||
sourceSrc: existing.sourceSrc || manifestRef.sourceSrc,
|
||||
status: existing.status || "ready",
|
||||
viewerSettings: {
|
||||
...viewerSettings,
|
||||
updatedAt: viewerSettings.updatedAt || now
|
||||
},
|
||||
viewerSettingsUpdatedAt: now,
|
||||
updatedAt: existing.updatedAt || now
|
||||
};
|
||||
|
||||
try {
|
||||
await writeJSONFile(manifestRef.manifestPath, nextManifest);
|
||||
sendJSON(res, 200, {
|
||||
sourceSrc: nextManifest.sourceSrc,
|
||||
artifactSrc: nextManifest.artifactSrc || null,
|
||||
fallbackArtifactSrc: nextManifest.fallbackArtifactSrc || null,
|
||||
viewerSettings: nextManifest.viewerSettings
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[server] failed to save model settings", err);
|
||||
sendText(res, 500, "Failed to save model settings");
|
||||
}
|
||||
};
|
||||
|
||||
const buildProjectPayload = (body) => {
|
||||
const now = new Date().toISOString();
|
||||
const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`;
|
||||
|
|
@ -634,6 +778,14 @@ const requestHandler = async (req, res) => {
|
|||
return handleConversionStatus(res, url.searchParams);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/model-settings") {
|
||||
return handleGetModelSettings(res, url.searchParams);
|
||||
}
|
||||
|
||||
if (req.method === "PUT" && url.pathname === "/api/model-settings") {
|
||||
return handlePutModelSettings(req, res, url.searchParams);
|
||||
}
|
||||
|
||||
await serveStatic(req, res, url);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue