diff --git a/frontend/dcViewer.css b/frontend/dcViewer.css
index a940cc6..db643df 100644
--- a/frontend/dcViewer.css
+++ b/frontend/dcViewer.css
@@ -90,6 +90,10 @@
--section-toolbar-left: calc(12px + var(--tb-size, 25px) + var(--tb-gap, 10px));
--section-toolbar-bottom: 123px;
--tb-size: 25px;
+ --tb-min-size: 25px;
+ --tb-max-size: 56px;
+ --tb-lens-count: 7;
+ --tb-autohide: 0;
--tb-gap: 10px;
--tb-radius: 7px;
--tb-pad-y: 6px;
@@ -2998,17 +3002,41 @@ header {
.bottom-toolbar {
position: fixed;
left: 12px;
- bottom: 12px;
+ top: 50%;
display: flex;
flex-direction: column-reverse;
+ align-items: flex-start;
gap: var(--tb-gap, 8px);
+ width: var(--tb-min-size, var(--tb-size, 25px));
padding: var(--tb-pad-y, 8px) 0;
+ transform: translateY(-50%);
+ transform-origin: left center;
z-index: 15;
+ opacity: 1;
+ transition: transform 220ms ease, opacity 180ms ease;
+ will-change: transform, opacity;
+}
+
+.bottom-toolbar.toolbar-dock-active {
+ width: var(--tb-max-size, var(--tb-min-size, 56px));
+}
+
+.bottom-toolbar.toolbar-autohide {
+ pointer-events: none;
+ opacity: 0;
+ transform: translate(calc(-1 * (var(--tb-max-size, 56px) + 24px)), -50%);
+}
+
+.bottom-toolbar.toolbar-autohide.is-visible {
+ pointer-events: auto;
+ opacity: 1;
+ transform: translateY(-50%);
}
.tb-btn {
- width: var(--tb-size, 52px);
- height: var(--tb-size, 52px);
+ width: var(--tb-min-size, var(--tb-size, 52px));
+ height: var(--tb-min-size, var(--tb-size, 52px));
+ flex: 0 0 auto;
border-radius: var(--tb-radius, 12px);
background: var(--toolbar-bg);
border: 1px solid var(--toolbar-border);
@@ -3020,13 +3048,16 @@ header {
justify-content: center;
padding: var(--tb-icon-pad, 10px);
line-height: 1;
- transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
+ transform: translateY(var(--tb-shift-y, 0px)) scale(var(--tb-scale, 1));
+ transform-origin: left center;
+ transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease, color 120ms ease;
+ will-change: transform;
}
.tb-btn svg {
display: block;
- width: var(--tb-svg-size, 24px);
- height: var(--tb-svg-size, 24px);
+ width: var(--tb-icon-size, 24px);
+ height: var(--tb-icon-size, 24px);
fill: none;
stroke: currentColor;
stroke-width: 1.8;
@@ -3035,7 +3066,6 @@ header {
}
.tb-btn:hover {
- transform: translateY(-1px);
border-color: var(--toolbar-outline, rgba(76, 224, 210, 0.7));
box-shadow: 0 10px 20px color-mix(in srgb, var(--toolbar-outline, rgba(76, 224, 210, 0.7)) 30%, transparent);
}
@@ -3049,7 +3079,6 @@ header {
.tb-btn:disabled {
cursor: default;
opacity: 0.45;
- transform: none;
box-shadow: none;
}
diff --git a/frontend/dcViewer.js b/frontend/dcViewer.js
index c667507..397016c 100644
--- a/frontend/dcViewer.js
+++ b/frontend/dcViewer.js
@@ -2,13 +2,13 @@ const SDK_VERSION = "2.6.78"; // соответствует package.json
const SDK_URL = "./lib/dc-camera-context.es.js?v=3";
import { createLogo } from "./src/ui/logo.js";
-import { toolbarConfig, applyToolbarVars } from "./src/ui/toolbar.js";
+import { applyToolbarVars } from "./src/ui/toolbar.js?v=2";
import { createTemplatesMenu } from "./src/ui/templatesMenu.js?v=3";
-import { createSettingsMenu } from "./src/ui/settingsMenu.js?v=11";
+import { createSettingsMenu } from "./src/ui/settingsMenu.js?v=12";
import { createMeasurementModal } from "./src/ui/measurementModal.js";
import { createGlassSelect } from "./src/ui/glassSelect.js";
import { createSliderControl } from "./src/ui/controls/slider.js";
-import "./theme.js";
+import "./theme.js?v=2";
import { TransformGizmo } from "./transformGizmo.js";
import { createObjectModal } from "./src/ui/objectModal.js";
@@ -2124,6 +2124,238 @@ const clampNumber = (value, min, max, fallback) => {
return Math.max(min, Math.min(max, num));
};
+let toolbarDockHideTimer = null;
+let toolbarDockAnimationFrame = null;
+let toolbarDockLastTs = 0;
+const toolbarDockState = new WeakMap();
+const TOOLBAR_DOCK_BASE_SCALE = 2.25;
+const TOOLBAR_DOCK_BASE_DISTANCE = 110;
+const TOOLBAR_DOCK_BASE_NUDGE = 40;
+const TOOLBAR_DOCK_NUDGE_TIGHTNESS = 0.82;
+const TOOLBAR_DOCK_SPRING = {
+ mass: 0.1,
+ stiffness: 170,
+ damping: 12,
+};
+const TOOLBAR_DOCK_MAX_STEP = 1 / 240;
+
+const getVisibleToolbarButtons = () => {
+ if (!bottomToolbar) return [];
+ return Array.from(bottomToolbar.querySelectorAll(".tb-btn")).filter((btn) => {
+ if (btn.hidden || btn.classList.contains("hidden")) return false;
+ return getComputedStyle(btn).display !== "none";
+ });
+};
+
+const readToolbarDockSettings = () => {
+ const minSize = clampNumber(parseFloat(cssValue("--tb-min-size", "25")), 18, 88, 25);
+ const maxSize = Math.max(minSize, clampNumber(parseFloat(cssValue("--tb-max-size", "56")), 18, 96, 56));
+ const rawLens = Math.round(clampNumber(parseFloat(cssValue("--tb-lens-count", "7")), 1, 15, 7));
+ const lensCount = rawLens % 2 === 0 ? rawLens + 1 : rawLens;
+ return {
+ minSize,
+ maxSize,
+ lensCount,
+ autoHide: cssValue("--tb-autohide", "0") === "1",
+ };
+};
+
+const getToolbarDockState = (btn) => {
+ let state = toolbarDockState.get(btn);
+ if (!state) {
+ state = {
+ scale: 1,
+ targetScale: 1,
+ scaleVelocity: 0,
+ shiftY: 0,
+ targetShiftY: 0,
+ shiftVelocity: 0,
+ };
+ toolbarDockState.set(btn, state);
+ }
+ return state;
+};
+
+const stepToolbarSpring = (value, velocity, target, dt) => {
+ let nextValue = value;
+ let nextVelocity = velocity;
+ const steps = Math.max(1, Math.ceil(dt / TOOLBAR_DOCK_MAX_STEP));
+ const step = dt / steps;
+ for (let i = 0; i < steps; i += 1) {
+ const displacement = nextValue - target;
+ const springForce = -TOOLBAR_DOCK_SPRING.stiffness * displacement;
+ const dampingForce = -TOOLBAR_DOCK_SPRING.damping * nextVelocity;
+ const acceleration = (springForce + dampingForce) / TOOLBAR_DOCK_SPRING.mass;
+ nextVelocity += acceleration * step;
+ nextValue += nextVelocity * step;
+ }
+ return [nextValue, nextVelocity];
+};
+
+const renderToolbarDockFrame = (timestamp = performance.now()) => {
+ const buttons = getVisibleToolbarButtons();
+ if (!buttons.length) {
+ toolbarDockAnimationFrame = null;
+ return;
+ }
+ const dt = toolbarDockLastTs ? Math.min(0.032, Math.max(0.001, (timestamp - toolbarDockLastTs) / 1000)) : 0.016;
+ toolbarDockLastTs = timestamp;
+ let active = false;
+ buttons.forEach((btn) => {
+ const state = getToolbarDockState(btn);
+ [state.scale, state.scaleVelocity] = stepToolbarSpring(state.scale, state.scaleVelocity, state.targetScale, dt);
+ [state.shiftY, state.shiftVelocity] = stepToolbarSpring(state.shiftY, state.shiftVelocity, state.targetShiftY, dt);
+ if (
+ Math.abs(state.scale - state.targetScale) < 0.001 &&
+ Math.abs(state.scaleVelocity) < 0.001 &&
+ Math.abs(state.shiftY - state.targetShiftY) < 0.05 &&
+ Math.abs(state.shiftVelocity) < 0.05
+ ) {
+ state.scale = state.targetScale;
+ state.scaleVelocity = 0;
+ state.shiftY = state.targetShiftY;
+ state.shiftVelocity = 0;
+ } else {
+ active = true;
+ }
+ btn.style.setProperty("--tb-scale", state.scale.toFixed(4));
+ btn.style.setProperty("--tb-shift-y", `${state.shiftY.toFixed(2)}px`);
+ });
+ if (active) {
+ toolbarDockAnimationFrame = window.requestAnimationFrame(renderToolbarDockFrame);
+ } else {
+ toolbarDockAnimationFrame = null;
+ toolbarDockLastTs = 0;
+ }
+};
+
+const startToolbarDockAnimation = () => {
+ if (toolbarDockAnimationFrame) return;
+ toolbarDockLastTs = 0;
+ toolbarDockAnimationFrame = window.requestAnimationFrame(renderToolbarDockFrame);
+};
+
+const setToolbarButtonTarget = (btn, size, shiftY, settings) => {
+ const state = getToolbarDockState(btn);
+ state.targetScale = settings.minSize > 0 ? size / settings.minSize : 1;
+ state.targetShiftY = shiftY;
+ startToolbarDockAnimation();
+};
+
+const resetToolbarDockMagnification = () => {
+ const settings = readToolbarDockSettings();
+ bottomToolbar?.classList.remove("toolbar-dock-active");
+ getVisibleToolbarButtons().forEach((btn) => setToolbarButtonTarget(btn, settings.minSize, 0, settings));
+ if (sectionModeActive) {
+ positionSectionToolbar();
+ }
+};
+
+const updateToolbarDockMagnification = (clientY) => {
+ const buttons = getVisibleToolbarButtons();
+ if (!buttons.length) return;
+ const settings = readToolbarDockSettings();
+ const toolbarRect = bottomToolbar.getBoundingClientRect();
+ const entries = buttons.map((btn) => {
+ return {
+ btn,
+ centerY: toolbarRect.top + btn.offsetTop + btn.offsetHeight / 2,
+ };
+ }).sort((a, b) => a.centerY - b.centerY);
+ const gap = parseFloat(cssValue("--tb-gap", "10")) || 10;
+ const step = settings.minSize + gap;
+ const lensRadius = Math.max(
+ settings.minSize,
+ Math.floor((settings.lensCount - 1) / 2) * step + gap / 2
+ );
+ const maxScale = settings.minSize > 0 ? settings.maxSize / settings.minSize : 1;
+ const scaledNudge = TOOLBAR_DOCK_BASE_NUDGE *
+ (lensRadius / TOOLBAR_DOCK_BASE_DISTANCE) *
+ Math.max(0, maxScale - 1) / (TOOLBAR_DOCK_BASE_SCALE - 1) *
+ TOOLBAR_DOCK_NUDGE_TIGHTNESS;
+ entries.forEach((entry) => {
+ const distance = clientY - entry.centerY;
+ const absDistance = Math.abs(distance);
+ const scale = absDistance >= lensRadius
+ ? 1
+ : 1 + (maxScale - 1) * (1 - absDistance / lensRadius);
+ const size = settings.minSize * scale;
+ const shiftY = absDistance > lensRadius
+ ? Math.sign(distance || 1) * -1 * scaledNudge
+ : (-distance / lensRadius) * scaledNudge * scale;
+ setToolbarButtonTarget(entry.btn, size, shiftY, settings);
+ });
+ if (sectionModeActive) {
+ positionSectionToolbar();
+ }
+};
+
+const requestToolbarDockMagnification = (clientY) => {
+ updateToolbarDockMagnification(clientY);
+};
+
+const showToolbarDock = () => {
+ if (!bottomToolbar) return;
+ window.clearTimeout(toolbarDockHideTimer);
+ toolbarDockHideTimer = null;
+ bottomToolbar.classList.add("is-visible");
+};
+
+const scheduleToolbarDockHide = () => {
+ if (!bottomToolbar) return;
+ const settings = readToolbarDockSettings();
+ if (!settings.autoHide) return;
+ if (toolbarDockHideTimer) return;
+ toolbarDockHideTimer = window.setTimeout(() => {
+ toolbarDockHideTimer = null;
+ bottomToolbar.classList.remove("is-visible");
+ resetToolbarDockMagnification();
+ }, 5000);
+};
+
+const updateToolbarDockMode = () => {
+ if (!bottomToolbar) return;
+ const settings = readToolbarDockSettings();
+ bottomToolbar.classList.toggle("toolbar-autohide", settings.autoHide);
+ bottomToolbar.classList.toggle("is-visible", !settings.autoHide);
+ if (!settings.autoHide) {
+ window.clearTimeout(toolbarDockHideTimer);
+ toolbarDockHideTimer = null;
+ }
+ resetToolbarDockMagnification();
+};
+
+const setupToolbarDock = () => {
+ if (!bottomToolbar) return;
+ bottomToolbar.addEventListener("pointermove", (event) => {
+ showToolbarDock();
+ bottomToolbar.classList.add("toolbar-dock-active");
+ requestToolbarDockMagnification(event.clientY);
+ });
+ bottomToolbar.addEventListener("pointerenter", () => {
+ showToolbarDock();
+ bottomToolbar.classList.add("toolbar-dock-active");
+ });
+ bottomToolbar.addEventListener("pointerleave", () => {
+ resetToolbarDockMagnification();
+ scheduleToolbarDockHide();
+ });
+ window.addEventListener("pointermove", (event) => {
+ const settings = readToolbarDockSettings();
+ if (!settings.autoHide || isGuestMode()) return;
+ if (event.clientX <= Math.max(32, settings.maxSize * 0.75)) {
+ showToolbarDock();
+ } else if (!bottomToolbar.matches(":hover")) {
+ scheduleToolbarDockHide();
+ }
+ }, { passive: true });
+ window.addEventListener("resize", () => {
+ resetToolbarDockMagnification();
+ updateToolbarDockMode();
+ });
+ updateToolbarDockMode();
+};
+
const hexToRgb01 = (hex) => {
if (!hex) return [1, 1, 1];
const clean = hex.trim();
@@ -4097,7 +4329,8 @@ const buildProjectSnapshot = async (name) => {
const applyDesignState = (design) => {
if (!design || !settingsMenu?.applyState) return;
settingsMenu.applyState(design);
- applyToolbarVars();
+ applyToolbarVars(settingsMenu?.getState?.());
+ updateToolbarDockMode();
applyHighlightTheme();
applyMeasureTheme();
if (sdkRef) {
@@ -4481,7 +4714,8 @@ const initViewer = async () => {
rebuildNavCube(sdk);
resetTreeView(sdk);
- applyToolbarVars();
+ applyToolbarVars(settingsMenu?.getState?.());
+ updateToolbarDockMode();
applyHighlightTheme();
updatePanelsVisibility();
ensureCameraControlActive();
@@ -5330,6 +5564,10 @@ const loadProjectSnapshot = async (project) => {
if (key && key.startsWith("commentTarget")) {
renderCommentTargets();
}
+ if (key && key.startsWith("toolbar")) {
+ applyToolbarVars(settingsMenu?.getState?.());
+ updateToolbarDockMode();
+ }
focusCanvasAndControls();
},
onShow: () => {
@@ -5343,6 +5581,8 @@ const loadProjectSnapshot = async (project) => {
}
});
// ensure persisted settings are applied to cube/highlight on first load
+ applyToolbarVars(settingsMenu?.getState?.());
+ updateToolbarDockMode();
rebuildNavCube(sdk);
applyHighlightTheme();
applyMeasureTheme();
@@ -5944,6 +6184,7 @@ const loadProjectSnapshot = async (project) => {
// нижняя тулбар
if (bottomToolbar) {
+ setupToolbarDock();
bottomToolbar.addEventListener("click", (e) => {
const btn = e.target.closest(".tb-btn");
if (!btn) return;
diff --git a/frontend/index.html b/frontend/index.html
index 358318d..ddced96 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -9,7 +9,7 @@
-
+
+