import THREE from "./init.js";
import { core } from "./core.js";
import { t } from "./i18n-utils.js";
import { toastHelper } from './viewer-utils.js';
export function getEditorToolbarIcon(icon) {
const icons = {
moveToolbar: '',
orbit: '',
move: '',
rotate: '',
scale: '',
lightMove: '',
lightTarget: '',
lights: '',
materials: '',
ambientLight: '',
cameraLight: '',
environmentMap: '',
color: '',
intensity: '',
picking: '',
resetCamera: '',
preview: '',
save: '',
mainMenu: '',
advancedEditor: '',
fullScreen: '',
displayHelperX: '',
displayHelperY: '',
displayHelperZ: '',
visible: '',
clippingPlanes: '',
ruler: '',
annotate: '',
annotateAdd: '',
annotateImport: '',
annotateExport: '',
IIIFexport: '', hierarchy: '',
loadingLogs: '',
performance: '',
statistics: '',
performanceDefault: '',
performanceHigh: '',
performanceLow: '',
expand: '',
collapse: '',
projection: '',
wireframe: '',
screenshot: '',
download: '',
};
return icons[icon] || icons.advancedEditor;
}
export function syncEditorToolbarSecondaryTrayWidth(viewer) {
if (!viewer.editorToolbarSecondaryTray) return;
const width =
Array.from(
viewer.editorToolbarSecondaryTray.children
).reduce(
(sum, el) => sum + el.getBoundingClientRect().width + 10,
0
);
viewer.editorToolbarSecondaryTray.style.setProperty(
"--viewer-toolbar-secondary-width",
`${Math.ceil(width)}px`
);
}
export function getEditorToolbarHost(viewer) {
return core.container || viewer.viewerWrapper || null;
}
function initializeEditorToolbarDrag(handle, viewer, toolbar, host) {
let dragState = null;
// persistent toolbar position
let currentX = 0;
let currentY = 0;
const getScale = () => {
const style = getComputedStyle(toolbar);
const scale = parseFloat(
style.getPropertyValue("--viewer-toolbar-scale")
);
return Number.isFinite(scale) ? scale : 1;
};
const clampPosition = (x, y) => {
const hostRect = host.getBoundingClientRect();
return {
x: Math.min(
Math.max(x, -hostRect.width),
hostRect.width
),
y: Math.min(
Math.max(y, -hostRect.height),
hostRect.height
),
};
};
const applyPosition = () => {
toolbar.style.setProperty("--drag-x", `${currentX}px`);
toolbar.style.setProperty("--drag-y", `${currentY}px`);
};
const updateToolbarPosition = (event) => {
if (!dragState) return;
const scale = getScale();
const dx = (event.clientX - dragState.startX) / scale;
const dy = (event.clientY - dragState.startY) / scale;
const pos = clampPosition(
dragState.originX + dx,
dragState.originY + dy
);
currentX = pos.x;
currentY = pos.y;
applyPosition();
};
const stopToolbarDrag = () => {
if (!dragState) return;
dragState = null;
toolbar.classList.remove("viewer-editor-toolbar_dragging");
document.removeEventListener(
"pointermove",
updateToolbarPosition
);
document.removeEventListener(
"pointerup",
stopToolbarDrag
);
document.removeEventListener(
"pointercancel",
stopToolbarDrag
);
requestAnimationFrame(() => {
toolbar.style.removeProperty("transition");
});
};
const startToolbarDrag = (event) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
dragState = {
startX: event.clientX,
startY: event.clientY,
originX: currentX,
originY: currentY,
};
toolbar.classList.add("viewer-editor-toolbar_dragging");
toolbar.style.transition = "none";
document.addEventListener(
"pointermove",
updateToolbarPosition,
{passive: true}
);
document.addEventListener(
"pointerup",
stopToolbarDrag
);
document.addEventListener(
"pointercancel",
stopToolbarDrag
);
};
viewer.bindEventListener(
handle,
"pointerdown",
startToolbarDrag
);
viewer.bindEventListener(handle, "click", (event) => {
event.stopPropagation();
});
// keep position valid after resize
const resizeObserver = new ResizeObserver(() => {
const pos = clampPosition(currentX, currentY);
currentX = pos.x;
currentY = pos.y;
applyPosition();
});
resizeObserver.observe(host);
applyPosition();
}
export function attachEditorToolbar(viewer) {
if (!core.editorToolbar || !core.container) return;
if (getComputedStyle(core.container).position === 'static') {
core.container.style.position = 'relative';
}
const host = getEditorToolbarHost(viewer);
if (!host || core.editorToolbar.parentElement === host) return;
host.appendChild(core.editorToolbar);
}
export function toggleToolbarExpanded(viewer) {
if (!core.editorToolbar) return;
syncEditorToolbarSecondaryTrayWidth(viewer);
viewer.isToolbarExpanded = !viewer.isToolbarExpanded;
core.editorToolbar.classList.toggle("expanded", viewer.isToolbarExpanded);
viewer.editorToolbarButtons.expand.classList.toggle("expanded-icon", viewer.isToolbarExpanded);
viewer.editorToolbarButtons.expand.setAttribute("aria-expanded", viewer.isToolbarExpanded ? "true" : "false");
const icon = viewer.editorToolbarButtons.expand.querySelector(".viewer-editor-tool_icon");
if (icon) {
icon.innerHTML = getEditorToolbarIcon(viewer.isToolbarExpanded ? "collapse" : "expand");
}
viewer.updateEditorToolbarLabels();
}
async function downloadFile(fileName = "model.glb") {
if (!core.downloadModel) return;
const handle = await window.showSaveFilePicker({
suggestedName: fileName,
});
const writable = await handle.createWritable();
await writable.write(core.downloadModel);
await writable.close();
toastHelper("download", "success");
}
export function createEditorToolbar(viewer) {
if (!core.EDITOR || viewer.urlOptions.hideUi || core.editorToolbar || !core.container) return;
const toolbar = document.createElement("div");
toolbar.id = "viewerEditorToolbar";
toolbar.setAttribute("role", "toolbar");
toolbar.setAttribute("aria-label", t("toolbar.editor", "Editor tools"));
const tools = [
{ key: "moveToolbar", icon: "moveToolbar", onClick: () => {}, pressed:true, primary: true },
{ key: "orbit", icon: "orbit", onClick: () => viewer.setObjectTransformMode(""), primary: true },
{ key: "move", icon: "move", onClick: () => viewer.toggleObjectTransformMode("translate"), pressed: true, primary: true },
{ key: "rotate", icon: "rotate", onClick: () => viewer.toggleObjectTransformMode("rotate"), pressed: true, primary: true },
{ key: "scale", icon: "scale", onClick: () => viewer.toggleObjectTransformMode("scale"), pressed: true, primary: true },
{ key: "lights", icon: "lights", onClick: () => {}, pressed: false, primary: false },
{ key: "materials", icon: "materials", onClick: () => viewer.openMaterialsFolder(), pressed: false, primary: false },
{ key: "picking", icon: "picking", onClick: () => viewer.togglePickingMode(), pressed: true, primary: false },
{ key: "annotate", icon: "annotate", onClick: () => viewer.openAnnotationDialogWithAutoPicking(), primary: false },
{ key: "ruler", icon: "ruler", onClick: () => viewer.toggleDistanceMeasurement(), pressed: true, primary: false },
{ key: "fullScreen", icon: "fullScreen", onClick: () => viewer.toggleFullscreen(), pressed: true, primary: true },
{ key: "clippingPlanes", icon: "clippingPlanes", onClick: () => viewer.toggleClippingPlanesPanel(), pressed: true, primary: false },
{ key: "resetCamera", icon: "resetCamera", onClick: () => viewer.resetCamera(), primary: false },
{ key: "hierarchy", icon: "hierarchy", onClick: () => {}, pressed: true, primary: false },
{ key: "projection", icon: "projection", onClick: () => viewer.toggleCameraProjection(), pressed: true, primary: false },
{ key: "wireframe", icon: "wireframe", onClick: () => viewer.toggleWireframeMode(), pressed: true, primary: false },
{ key: "statistics", icon: "statistics", onClick: () => {}, pressed: false, primary: false },
];
if (!core.isLightweight || core.isLocalPreview) {
tools.splice(tools.length - 1, 0,
{ key: "loadingLogs", icon: "loadingLogs", onClick: () => viewer.toggleLoadingLogs(), pressed: true, primary: false },
{ key: "download", icon: "download", onClick: () => downloadFile(core.fileObject.filename), pressed: true, primary: false },
{ key: "preview", icon: "preview", onClick: () => viewer.takeScreenshot(), primary: false },
{ key: "save", icon: "save", onClick: () => {}, primary: false }
);
}
viewer.editorToolbarButtons = {};
viewer.environmentMapPreset = viewer.environmentMapPreset || "neutral";
const secondaryTray = document.createElement("div");
secondaryTray.className = "viewer-editor-toolbar_secondary-tray";
viewer.editorToolbarSecondaryTray = secondaryTray;
tools.forEach((tool) => {
const button = document.createElement("button");
button.type = "button";
button.className = "viewer-editor-tool";
if (!tool.primary) {
button.classList.add("viewer-editor-tool-not-primary");
}
button.dataset.tool = tool.key;
button.dataset.pressed = tool.pressed ? "true" : "false";
button.dataset.primary = tool.primary ? "true" : "false";
if (tool.key === "materials") {
const label = t("gui.materials", "Materials");
button.setAttribute("title", label);
button.setAttribute("aria-label", label);
}
button.innerHTML = `
${getEditorToolbarIcon(tool.icon)}
`;
if (tool.key === "moveToolbar") {
initializeEditorToolbarDrag(button, viewer, toolbar, getEditorToolbarHost(viewer));
}
else if (tool.key === "clippingPlanes") {
button.classList.add("has-submenu");
const submenu = document.createElement("div");
submenu.className = "viewer-editor-tool_submenu";
const submenuItems = [
{ key: "displayHelperX", icon: "displayHelperX", label: t("gui.displayHelperX", "Show X helper"), onClick: () => viewer.toggleClippingPlaneHelper("x") },
{ key: "displayHelperY", icon: "displayHelperY", label: t("gui.displayHelperY", "Show Y helper"), onClick: () => viewer.toggleClippingPlaneHelper("y") },
{ key: "displayHelperZ", icon: "displayHelperZ", label: t("gui.displayHelperZ", "Show Z helper"), onClick: () => viewer.toggleClippingPlaneHelper("z") },
{ key: "visible", icon: "visible", label: t("gui.visible", "Visible"), onClick: () => viewer.toggleClippingPlaneVisible() },
];
viewer.clippingPlaneSubmenuButtons = {};
submenuItems.forEach((item) => {
const subButton = document.createElement("button");
subButton.type = "button";
subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button";
subButton.dataset.tool = item.key;
subButton.innerHTML = `
${getEditorToolbarIcon(item.icon)}
`;
subButton.setAttribute("title", item.label);
subButton.setAttribute("aria-label", item.label);
viewer.bindEventListener(subButton, "click", (event) => {
event.stopPropagation();
item.onClick();
});
submenu.appendChild(subButton);
viewer.clippingPlaneSubmenuButtons[item.key] = subButton;
});
button.appendChild(submenu);
} else if (tool.key === "annotate") {
button.classList.add("has-submenu");
const submenu = document.createElement("div");
submenu.className = "viewer-editor-tool_submenu";
const submenuItems = [
{ key: "annotateAdd", icon: "annotateAdd", label: t("gui.annotateAdd", "Add Annotation"), onClick: () => viewer.openAnnotationDialogWithAutoPicking() },
{ key: "annotateImport", icon: "annotateImport", label: t("gui.annotateImport", "Import Annotations"), onClick: () => viewer.triggerAnnotationsXmlImport() },
{ key: "annotateExport", icon: "annotateExport", label: t("gui.annotateExport", "Export Annotations"), onClick: () => viewer.downloadAnnotationsXmlFile() },
{ key: "IIIFexport", icon: "IIIFexport", label: t("gui.IIIFexport", "Export to IIIF"), onClick: () => viewer.exportIIIFManifest() },
];
viewer.annotateSubmenuButtons = {};
submenuItems.forEach((item) => {
const subButton = document.createElement("button");
subButton.type = "button";
subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button";
subButton.dataset.tool = item.key;
subButton.innerHTML = `
${getEditorToolbarIcon(item.icon)}
`;
subButton.setAttribute("title", item.label);
subButton.setAttribute("aria-label", item.label);
viewer.bindEventListener(subButton, "click", (event) => {
event.stopPropagation();
item.onClick();
});
submenu.appendChild(subButton);
viewer.annotateSubmenuButtons[item.key] = subButton;
});
button.appendChild(submenu);
} else if (tool.key === "materials") {
button.classList.add("has-submenu");
const submenu = document.createElement("div");
submenu.className = "viewer-editor-tool_submenu viewer-editor-tool_submenu-materials";
viewer.materialsSubmenu = submenu;
viewer.refreshMaterialsToolbarMenu();
button.appendChild(submenu);
} else if (tool.key === "hierarchy") {
button.classList.add("has-submenu");
const submenu = document.createElement("div");
submenu.className = "viewer-editor-tool_submenu viewer-editor-hierarchy-submenu";
viewer.hierarchySubmenu = submenu;
const hierarchyList = document.createElement("div");
hierarchyList.className = "viewer-editor-hierarchy-submenu-list";
viewer.hierarchySubmenuList = hierarchyList;
const clearButton = document.createElement("button");
clearButton.type = "button";
clearButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button viewer-editor-hierarchy-clear";
viewer.bindEventListener(clearButton, "click", (event) => {
event.stopPropagation();
viewer.clearHierarchySelection();
});
viewer.hierarchyClearButton = clearButton;
viewer.hierarchySubmenuButtons = {};
submenu.appendChild(hierarchyList);
submenu.appendChild(clearButton);
button.appendChild(submenu);
} else if (tool.key === "save") {
button.classList.add("has-submenu");
const submenu = document.createElement("div");
submenu.className = "viewer-editor-tool_submenu viewer-editor-save-submenu";
viewer.bindEventListener(submenu, "click", (event) => {
event.stopPropagation();
});
const submenuItems = [
{ key: "Position", label: t("gui.position", "Position") },
{ key: "Rotation", label: t("gui.rotation", "Rotation") },
{ key: "Scale", label: t("gui.scale", "Scale") },
{ key: "Camera", label: t("gui.camera", "Camera") },
{ key: "DirectionalLight", label: t("gui.directionalLight", "Directional Light") },
{ key: "AmbientLight", label: t("gui.ambientLight", "Ambient Light") },
{ key: "CameraLight", label: t("gui.cameraLight", "Camera Light") },
{ key: "BackgroundColor", label: t("gui.backgroundColor", "Background Color") },
];
viewer.saveSubmenuCheckboxes = {};
submenuItems.forEach((item) => {
const row = document.createElement("label");
row.className = "viewer-editor-save-option";
row.setAttribute("title", item.label);
row.setAttribute("aria-label", item.label);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = Boolean(viewer.saveProperties[item.key]);
checkbox.dataset.property = item.key;
viewer.bindEventListener(checkbox, "click", (event) => {
event.stopPropagation();
});
viewer.bindEventListener(checkbox, "change", (event) => {
event.stopPropagation();
viewer.saveProperties[item.key] = event.target.checked;
});
const text = document.createElement("span");
text.className = "viewer-editor-save-option_label";
text.textContent = item.label;
row.appendChild(checkbox);
row.appendChild(text);
submenu.appendChild(row);
viewer.saveSubmenuCheckboxes[item.key] = { row, checkbox, text };
});
const actions = document.createElement("div");
actions.className = "viewer-editor-save-actions";
const saveButton = document.createElement("button");
saveButton.type = "button";
saveButton.className = "viewer-editor-save-apply";
saveButton.textContent = t("gui.saveSettings", "Save settings");
viewer.bindEventListener(saveButton, "click", (event) => {
event.stopPropagation();
viewer.saveEditorMetadata();
});
viewer.saveSubmenuActionButton = saveButton;
actions.appendChild(saveButton);
submenu.appendChild(actions);
button.appendChild(submenu);
} else if (tool.key === "statistics") {
button.classList.add("has-submenu");
const submenu = document.createElement("div");
submenu.className = "viewer-editor-tool_submenu";
viewer.statisticsSubmenuButtons = {};
const appendStatisticsSubmenuItems = (items, container) => {
items.forEach((item) => {
const subButton = document.createElement("button");
subButton.type = "button";
subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button";
subButton.dataset.tool = item.key;
subButton.setAttribute("title", item.label);
subButton.setAttribute("aria-label", item.label);
subButton.setAttribute("aria-pressed", item.pressed);
const iconSpan = document.createElement("span");
iconSpan.className = "viewer-editor-tool_icon";
iconSpan.setAttribute("aria-hidden", "true");
iconSpan.innerHTML = getEditorToolbarIcon(item.icon);
subButton.appendChild(iconSpan);
const srSpan = document.createElement("span");
srSpan.className = "viewer-editor-tool_sr";
srSpan.textContent = item.label;
subButton.appendChild(srSpan);
if (item.onClick) {
viewer.bindEventListener(subButton, "click", (event) => {
event.stopPropagation();
item.onClick();
});
}
if (item.children) {
subButton.classList.add("has-submenu");
const nested = document.createElement("div");
nested.className = "viewer-editor-tool_submenu";
appendStatisticsSubmenuItems(item.children, nested);
subButton.appendChild(nested);
}
viewer.statisticsSubmenuButtons[item.key] = subButton;
container.appendChild(subButton);
});
};
appendStatisticsSubmenuItems([
{
key: "toggleStats",
icon: "statistics",
label: t("gui.statistics", "Statistics"),
pressed: false,
onClick: () => viewer.toggleStatsVisibility(),
},
{
key: "performance",
icon: "performance",
label: t("gui.performance", "Performance"),
children: [
{
key: "performanceDefault",
icon: "statistics",
label: t("gui.default", "Default"),
onClick: () => viewer.setPerformanceMode("default"),
pressed: true,
},
{
key: "performanceHigh",
icon: "performanceHigh",
label: t("gui.highPerformance", "High-performance"),
onClick: () => viewer.setPerformanceMode("high-performance"),
pressed: true,
},
{
key: "performanceLow",
icon: "performanceLow",
label: t("gui.lowPower", "Low-power"),
onClick: () => viewer.setPerformanceMode("low-power"),
pressed: true,
},
],
},
], submenu);
button.appendChild(submenu);
} else if (tool.key === "lights") {
button.classList.add("has-submenu");
const submenu = document.createElement("div");
submenu.className = "viewer-editor-tool_submenu";
viewer.lightsSubmenuButtons = {};
const normalizeColorValue = (value) => {
if (typeof value !== "string") return "#ffffff";
if (value.startsWith("0x")) {
return `#${value.slice(2).padStart(6, "0")}`;
}
return value.startsWith("#") ? value : `#${value}`;
};
const appendSubmenuItems = (items, container) => {
items.forEach((item) => {
const subButton = document.createElement("button");
subButton.type = "button";
subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button ";
subButton.dataset.tool = item.key;
subButton.setAttribute("title", item.label);
subButton.setAttribute("aria-label", item.label);
const iconSpan = document.createElement("span");
iconSpan.className = "viewer-editor-tool_icon";
iconSpan.setAttribute("aria-hidden", "true");
iconSpan.innerHTML = item.iconHtml || getEditorToolbarIcon(item.icon);
subButton.appendChild(iconSpan);
if (item.type === "color") {
subButton.classList.add("viewer-editor-tool_submenu-control");
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.value = normalizeColorValue(item.value());
colorInput.className = "viewer-editor-tool_submenu-input";
colorInput.addEventListener("click", (event) => event.stopPropagation());
colorInput.addEventListener("input", (event) => {
const value = event.target.value;
item.onChange(value);
colorInput.value = normalizeColorValue(value);
});
subButton.appendChild(colorInput);
} else if (item.type === "slider") {
subButton.classList.add("viewer-editor-tool_submenu-control");
const slider = document.createElement("input");
slider.type = "range";
slider.min = item.min ?? 0;
slider.max = item.max ?? 10;
slider.step = item.step ?? 0.01;
slider.value = String(item.value());
slider.className = "viewer-editor-tool_submenu-input";
slider.addEventListener("click", (event) => event.stopPropagation());
slider.addEventListener("input", (event) => {
const value = parseFloat(event.target.value);
item.onChange(value);
valueLabel.textContent = value.toFixed(2);
});
const valueLabel = document.createElement("span");
valueLabel.className = "viewer-editor-tool_submenu-value";
valueLabel.textContent = Number(item.value()).toFixed(2);
valueLabel.setAttribute("aria-hidden", "true");
subButton.appendChild(slider);
subButton.appendChild(valueLabel);
} else if (item.type === "toggle") {
subButton.classList.add("viewer-editor-tool_submenu-control", "viewer-editor-tool_submenu-toggle");
subButton.setAttribute("type", "button");
const toggleState = document.createElement("span");
toggleState.className = "viewer-editor-tool_submenu-toggle-state";
const setToggleState = () => {
const enabled = Boolean(item.value());
toggleState.textContent = enabled ? t("gui.on", "ON") : t("gui.off", "OFF");
subButton.setAttribute("aria-pressed", enabled ? "true" : "false");
subButton.classList.toggle("is-active", enabled);
};
setToggleState();
viewer.bindEventListener(subButton, "click", async (event) => {
event.stopPropagation();
const nextValue = !Boolean(item.value());
if (item.onChange) {
await item.onChange(nextValue);
}
setToggleState();
});
subButton.appendChild(toggleState);
} else if (item.onClick) {
viewer.bindEventListener(subButton, "click", (event) => {
event.stopPropagation();
item.onClick();
});
}
if (item.children) {
subButton.classList.add("has-submenu");
const nested = document.createElement("div");
nested.className = "viewer-editor-tool_submenu";
appendSubmenuItems(item.children, nested);
subButton.appendChild(nested);
}
if (
item.key === "lightTargetTransformMove" ||
item.key === "lightTargetTransformTarget" ||
item.key.startsWith("environmentMap")
) {
viewer.lightsSubmenuButtons[item.key] = subButton;
}
container.appendChild(subButton);
});
};
appendSubmenuItems([
{
key: "environmentMap",
icon: "environmentMap",
label: t("gui.environmentMap", "Environment map"),
children: [
{
key: "environmentMapToggle",
icon: "environmentMap",
label: t("gui.environmentMapToggle", "Environment map"),
type: "toggle",
value: () => (core.scene?.environmentIntensity ?? 0) > 0,
onChange: async (value) => {
if (!core.scene) return;
core.scene.traverse((child) => {
const materials = child?.material
? Array.isArray(child.material)
? child.material
: [child.material]
: [];
materials.forEach((material) => {
if (material?.isMeshStandardMaterial || material?.isMeshPhysicalMaterial) {
material.needsUpdate = true;
}
});
});
viewer.updateEditorToolbarState();
},
},
{
key: "environmentMapIntensity",
icon: "intensity",
label: t("gui.intensity", "Intensity"),
type: "slider",
min: 0,
max: 1,
step: 0.01,
value: () => core.environmentMapIntensity ?? 0.5,
onChange: (value) => {
if (!core.scene) return;
core.scene.environmentIntensity = value;
core.scene.traverse((child) => {
const materials = child?.material
? Array.isArray(child.material)
? child.material
: [child.material]
: [];
materials.forEach((material) => {
if (material?.isMeshStandardMaterial || material?.isMeshPhysicalMaterial) {
material.needsUpdate = true;
}
});
});
},
},
{
key: "environmentMapStyleNeutral",
iconHtml: "🌥",
label: t("gui.environmentMapNeutral", "Neutral"),
onClick: async () => {
await viewer.setEnvironmentMapPreset("neutral");
viewer.updateLightsSubmenuState();
},
},
{
key: "environmentMapStyleSunny",
iconHtml: "☀️",
label: t("gui.environmentMapSunny", "Sunny"),
onClick: async () => {
await viewer.setEnvironmentMapPreset("sunny");
viewer.updateLightsSubmenuState();
},
},
{
key: "environmentMapStyleStudio",
iconHtml: "📸",
label: t("gui.environmentMapStudio", "Studio"),
onClick: async () => {
await viewer.setEnvironmentMapPreset("studio");
viewer.updateLightsSubmenuState();
},
},
{
key: "environmentMapStyleGoldenHour",
iconHtml: "🌅",
label: t("gui.environmentMapGoldenHour", "Golden Hour"),
onClick: async () => {
await viewer.setEnvironmentMapPreset("goldenHour");
viewer.updateLightsSubmenuState();
},
},
],
},
{
key: "lightTarget",
icon: "lightTarget",
label: t("gui.target", "Target"),
children: [
{
key: "lightTargetColor",
icon: "color",
label: t("gui.color", "Color"),
type: "color",
value: () => viewer.colors.DirectionalLight,
onChange: (value) => {
viewer.colors.DirectionalLight = value;
core.lightObjects[0].color = new THREE.Color(value);
},
},
{
key: "lightTargetIntensity",
icon: "intensity",
label: t("gui.intensity", "Intensity"),
type: "slider",
min: 0,
max: 10,
step: 0.01,
value: () => viewer.intensity.startIntensityDir,
onChange: (value) => {
viewer.intensity.startIntensityDir = value;
core.lightObjects[0].intensity = value;
},
},
{
key: "lightTargetTransform",
icon: "move",
label: t("gui.transform", "Transform"),
children: [
{ key: "lightTargetTransformMove", icon: "move", label: t("gui.move", "Move"), onClick: () => viewer.toggleLightTransformMode("translate") },
{ key: "lightTargetTransformTarget", icon: "lightTarget", label: t("gui.target", "Target"), onClick: () => viewer.toggleLightTransformMode("rotate") },
],
},
],
},
{
key: "lightAmbient",
icon: "ambientLight",
label: t("gui.ambient", "Ambient"),
children: [
{
key: "lightAmbientColor",
icon: "color",
label: t("gui.color", "Color"),
type: "color",
value: () => viewer.colors.AmbientLight,
onChange: (value) => {
viewer.colors.AmbientLight = value;
viewer.ambientLight.color = new THREE.Color(value);
},
},
{
key: "lightAmbientIntensity",
icon: "intensity",
label: t("gui.intensity", "Intensity"),
type: "slider",
min: 0,
max: 10,
step: 0.01,
value: () => viewer.intensity.startIntensityAmbient,
onChange: (value) => {
viewer.intensity.startIntensityAmbient = value;
viewer.ambientLight.intensity = value;
},
},
],
},
{
key: "lightCamera",
icon: "cameraLight",
label: t("gui.camera", "Camera"),
children: [
{
key: "lightCameraColor",
icon: "color",
label: t("gui.color", "Color"),
type: "color",
value: () => viewer.colors.CameraLight,
onChange: (value) => {
viewer.colors.CameraLight = value;
viewer.cameraLight.color = new THREE.Color(value);
},
},
{
key: "lightCameraIntensity",
icon: "intensity",
label: t("gui.intensity", "Intensity"),
type: "slider",
min: 0,
max: 10,
step: 0.01,
value: () => viewer.intensity.startIntensityCamera,
onChange: (value) => {
viewer.intensity.startIntensityCamera = value;
viewer.cameraLight.intensity = value;
},
},
],
},
], submenu);
button.appendChild(submenu);
} else if (tool.key === "materials") {
button.classList.add("has-submenu");
const submenu = document.createElement("div");
submenu.className = "viewer-editor-tool_submenu viewer-editor-tool_submenu-materials";
const submenuItems = [
{ key: "materialColor", icon: "color", label: t("gui.color", "Color"), onClick: () => viewer.openMaterialsFolder() },
{ key: "materialIntensity", icon: "intensity", label: t("gui.intensity", "Intensity"), onClick: () => viewer.openMaterialsFolder() },
];
submenuItems.forEach((item) => {
const subButton = document.createElement("button");
subButton.type = "button";
subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button";
subButton.dataset.tool = item.key;
subButton.innerHTML = `
${getEditorToolbarIcon(item.icon)}
`;
subButton.setAttribute("title", item.label);
subButton.setAttribute("aria-label", item.label);
viewer.bindEventListener(subButton, "click", (event) => {
event.stopPropagation();
item.onClick();
});
submenu.appendChild(subButton);
});
button.appendChild(submenu);
} else if (tool.key === "download") {
if (!core.isLightweight || core.isLocalPreview) {
button.href = core.downloadModel;
button.target = "_blank";
button.rel = "noopener noreferrer";
button.download = core.fileObject.filename;
}
}
viewer.bindEventListener(button, "click", () => {
viewer.stopHandMode();
if (tool.onClick) {
tool.onClick();
}
});
if (tool.primary) toolbar.appendChild(button);
else secondaryTray.appendChild(button);
viewer.editorToolbarButtons[tool.key] = button;
if (!tool.primary) viewer.editorSecondaryKeys.push(button);
});
toolbar.appendChild(secondaryTray);
const expandButton = document.createElement("button");
expandButton.type = "button";
expandButton.className = "viewer-editor-tool viewer-editor-expand";
expandButton.innerHTML = `${getEditorToolbarIcon("expand")}`;
expandButton.dataset.primary = "true";
expandButton.setAttribute("aria-expanded", "false");
expandButton.setAttribute("title", t("gui.expand", "Expand toolbar"));
expandButton.setAttribute("aria-label", t("gui.expand", "Expand toolbar"));
viewer.bindEventListener(expandButton, "click", () => toggleToolbarExpanded(viewer));
toolbar.appendChild(expandButton);
viewer.editorToolbarButtons.expand = expandButton;
if (viewer.actionMenu) {
viewer.actionMenu.classList.add("viewer-action-menu_in-toolbar");
toolbar.appendChild(viewer.actionMenu);
}
getEditorToolbarHost(viewer)?.appendChild(toolbar);
core.editorToolbar = toolbar;
core.editorToolbar.classList.add("editorToolbar-hidden");
core.editorToolbar.classList.add("collapsed");
viewer.updateFullscreenButtonIcon();
viewer.updateEditorToolbarLabels();
viewer.updateEditorToolbarState();
syncEditorToolbarSecondaryTrayWidth(viewer);
viewer.bindEventListener(window, "resize", () => syncEditorToolbarSecondaryTrayWidth(viewer));
}
export function updateHierarchySubmenuState(viewer) {
if (!viewer.hierarchySubmenuButtons) return;
const selectedIds = new Set(
(core.selectedObjects || [])
.filter((item) => item?.selected === true)
.map((item) => String(item.id))
);
Object.entries(viewer.hierarchySubmenuButtons).forEach(([key, button]) => {
const isActive = selectedIds.has(String(key));
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-pressed", isActive ? "true" : "false");
});
viewer.hierarchyClearButton?.toggleAttribute("disabled", selectedIds.size === 0);
}
export function updateStatisticsSubmenuState(viewer) {
if (!viewer.statisticsSubmenuButtons) return;
const isVisible = typeof core.stats !== "undefined" && core.stats?.dom?.style?.visibility !== "hidden";
viewer.statisticsSubmenuButtons.toggleStats?.classList.toggle("is-active", isVisible);
viewer.statisticsSubmenuButtons.toggleStats?.setAttribute("aria-pressed", isVisible ? "true" : "false");
const currentMode = core.renderer?.powerPreference || core.CONFIG.viewer?.performanceMode || "default";
const performanceMap = {
performanceHigh: "high-performance",
performanceLow: "low-power",
performanceDefault: "default",
};
Object.entries(performanceMap).forEach(([key, value]) => {
const isActive = currentMode === value;
viewer.statisticsSubmenuButtons[key]?.classList.toggle("is-active", isActive);
viewer.statisticsSubmenuButtons[key]?.setAttribute("aria-pressed", isActive ? "true" : "false");
});
}
export function updateClippingPlanesSubmenuState(viewer) {
if (!viewer.clippingPlaneSubmenuButtons) return;
const clippingMode = core.planeParams?.clippingMode || {};
viewer.clippingPlaneSubmenuButtons.displayHelperX?.classList.toggle(
"is-active",
Boolean(clippingMode.x)
);
viewer.clippingPlaneSubmenuButtons.displayHelperY?.classList.toggle(
"is-active",
Boolean(clippingMode.y)
);
viewer.clippingPlaneSubmenuButtons.displayHelperZ?.classList.toggle(
"is-active",
Boolean(clippingMode.z)
);
viewer.clippingPlaneSubmenuButtons.visible?.classList.toggle(
"is-active",
Boolean(core.planeParams?.outline?.visible)
);
}
export function updateLightsSubmenuState(viewer) {
if (!viewer.lightsSubmenuButtons) return;
const activeMode = viewer.transformText["Transform Light"];
viewer.lightsSubmenuButtons.environmentMap?.classList.toggle(
"is-active",
viewer.environmentMapEnabled !== false
);
viewer.lightsSubmenuButtons.environmentMap?.setAttribute(
"aria-pressed",
viewer.environmentMapEnabled !== false ? "true" : "false"
);
const environmentMapToggle = viewer.lightsSubmenuButtons.environmentMapToggle;
if (environmentMapToggle) {
const toggleLabel = environmentMapToggle.querySelector('.viewer-editor-tool_submenu-toggle-state');
const isEnabled = (core.scene?.environmentIntensity ?? 0) > 0;
if (toggleLabel) toggleLabel.textContent = isEnabled ? t("gui.on", "ON") : t("gui.off", "OFF");
environmentMapToggle.setAttribute("aria-pressed", isEnabled ? "true" : "false");
environmentMapToggle.classList.toggle("is-active", isEnabled);
}
viewer.lightsSubmenuButtons.lightTargetTransformMove?.classList.toggle(
"is-active",
activeMode === "translate"
);
viewer.lightsSubmenuButtons.lightTargetTransformTarget?.classList.toggle(
"is-active",
activeMode === "rotate"
);
const environmentMapPreset = viewer.environmentMapPreset || "neutral";
const environmentMapPresetStates = {
environmentMapStyleNeutral: "neutral",
environmentMapStyleSunny: "sunny",
environmentMapStyleStudio: "studio",
environmentMapStyleGoldenHour: "goldenHour",
};
Object.entries(environmentMapPresetStates).forEach(([key, value]) => {
const isActive = environmentMapPreset === value;
viewer.lightsSubmenuButtons[key]?.classList.toggle("is-active", isActive);
viewer.lightsSubmenuButtons[key]?.setAttribute("aria-pressed", isActive ? "true" : "false");
});
}
export function updateEditorToolbarLabels(viewer) {
if (!viewer.editorToolbarButtons) return;
const labels = {
moveToolbar: t("gui.moveToolbar", "Move toolbar"),
orbit: t("gui.orbit", "Navigation mode"),
move: t("gui.move", "Move"),
rotate: t("gui.rotate", "Rotate"),
scale: t("gui.scale", "Scale"),
lights: t("gui.lights", "Lights"),
picking: viewer.pickingMode
? t("controls.disablePickingMode", "Disable picking mode")
: t("controls.enablePickingMode", "Enable picking mode"),
annotate: t("gui.addAnnotations", "Add annotations"),
ruler: viewer.RULER_MODE
? t("controls.disableDistanceMeasurement", "Disable distance measurement")
: t("controls.enableDistanceMeasurement", "Enable distance measurement"),
resetCamera: t("gui.resetCameraPosition", "Reset camera position"),
preview: t("gui.renderPreview", "Render preview"),
save: t("gui.saveSettings", "Save settings"),
advancedEditor: viewer.isEditorAdvancedPanelVisible()
? t("gui.hideAdvancedEditor", "Hide advanced editor")
: t("gui.showAdvancedEditor", "Show advanced editor"),
fullScreen: viewer.FULLSCREEN
? t("fullscreen.exit", "Exit fullscreen")
: t("fullscreen.enter", "Enter fullscreen"),
clippingPlanes: viewer.clippingMode
? t("gui.disableClippingPlanesMode", "Disable clipping planes mode")
: t("gui.enableClippingPlanesMode", "Enable clipping planes mode"),
projection: core.camera && core.camera.isPerspectiveCamera
? t("gui.orthographicProjection", "Switch to orthographic projection")
: t("gui.perspectiveProjection", "Switch to perspective projection"),
wireframe: viewer.wireframeMode
? t("gui.disableWireframeMode", "Disable wireframe mode")
: t("gui.enableWireframeMode", "Enable wireframe mode"),
loadingLogs: viewer.showLoadingLogs
? t("gui.hideLoadingLogs", "Hide loading logs")
: t("gui.showLoadingLogs", "Show loading logs"),
hierarchy: t("gui.hierarchy", "Hierarchy"),
materials: t("gui.materials", "Materials"),
statistics: t("gui.statistics", "Statistics"),
expand: viewer.isToolbarExpanded
? t("gui.collapse", "Collapse toolbar")
: t("gui.expand", "Expand toolbar"),
download: t("gui.download", "Download model"),
};
Object.entries(viewer.editorToolbarButtons).forEach(([key, button]) => {
const label = labels[key] || key;
button.setAttribute("title", label);
button.setAttribute("aria-label", label);
const sr = button.querySelector(".viewer-editor-tool_sr");
if (sr) sr.textContent = label;
});
if (viewer.clippingPlaneSubmenuButtons) {
const clippingPlaneSubmenuLabels = {
displayHelperX: t("gui.displayHelperX", "Show X helper"),
displayHelperY: t("gui.displayHelperY", "Show Y helper"),
displayHelperZ: t("gui.displayHelperZ", "Show Z helper"),
visible: t("gui.visible", "Visible"),
};
Object.entries(viewer.clippingPlaneSubmenuButtons).forEach(([key, button]) => {
const label = clippingPlaneSubmenuLabels[key] || key;
button.setAttribute("title", label);
button.setAttribute("aria-label", label);
});
}
if (viewer.annotateSubmenuButtons) {
const annotateSubmenuLabels = {
annotateAdd: t("gui.addAnnotations", "Add Annotation"),
annotateImport: t("gui.importAnnotationsXml", "Import Annotations"),
annotateExport: t("gui.exportAnnotationsXml", "Export Annotations"),
IIIFexport: t("gui.IIIFexport", "Export to IIIF"),
};
Object.entries(viewer.annotateSubmenuButtons).forEach(([key, button]) => {
const label = annotateSubmenuLabels[key] || key;
button.setAttribute("title", label);
button.setAttribute("aria-label", label);
});
}
if (viewer.statisticsSubmenuButtons) {
const statisticsSubmenuLabels = {
toggleStats: t("gui.statistics", "Statistics"),
performance: t("gui.performance", "Performance"),
performanceDefault: t("gui.default", "Default"),
performanceHigh: t("gui.highPerformance", "High-performance"),
performanceLow: t("gui.lowPower", "Low-power"),
};
Object.entries(viewer.statisticsSubmenuButtons).forEach(([key, button]) => {
const label = statisticsSubmenuLabels[key] || key;
button.setAttribute("title", label);
button.setAttribute("aria-label", label);
});
}
if (viewer.lightsSubmenuButtons) {
const lightsSubmenuLabels = {
environmentMap: t("gui.environmentMap", "Environment map"),
lightTargetTransformMove: t("gui.move", "Move"),
lightTargetTransformTarget: t("gui.target", "Target"),
environmentMapToggle: t("gui.environmentMapToggle", "Environment map"),
environmentMapIntensity: t("gui.intensity", "Intensity"),
environmentMapStyleNeutral: t("gui.environmentMapNeutral", "Neutral"),
environmentMapStyleSunny: t("gui.environmentMapSunny", "Sunny"),
environmentMapStyleStudio: t("gui.environmentMapStudio", "Studio"),
environmentMapStyleGoldenHour: t("gui.environmentMapGoldenHour", "Golden Hour"),
};
Object.entries(viewer.lightsSubmenuButtons).forEach(([key, button]) => {
const label = lightsSubmenuLabels[key] || key;
button.setAttribute("title", label);
button.setAttribute("aria-label", label);
});
}
if (viewer.hierarchyClearButton) {
const label = t("gui.clearSelectedHierarchy", "Clear selected objects");
viewer.hierarchyClearButton.setAttribute("title", label);
viewer.hierarchyClearButton.setAttribute("aria-label", label);
viewer.hierarchyClearButton.textContent = label;
}
if (viewer.saveSubmenuCheckboxes) {
const saveSubmenuLabels = {
Position: t("gui.position", "Position"),
Rotation: t("gui.rotation", "Rotation"),
Scale: t("gui.scale", "Scale"),
Camera: t("gui.camera", "Camera"),
DirectionalLight: t("gui.directionalLight", "Directional Light"),
AmbientLight: t("gui.ambientLight", "Ambient Light"),
CameraLight: t("gui.cameraLight", "Camera Light"),
BackgroundColor: t("gui.backgroundColor", "Background Color"),
};
Object.entries(viewer.saveSubmenuCheckboxes).forEach(([key, elements]) => {
const label = saveSubmenuLabels[key] || key;
elements.row.setAttribute("title", label);
elements.row.setAttribute("aria-label", label);
elements.text.textContent = label;
elements.checkbox.checked = Boolean(viewer.saveProperties[key]);
});
}
if (viewer.saveSubmenuActionButton) {
viewer.saveSubmenuActionButton.textContent = t("gui.saveSettings", "Save settings");
}
core.editorToolbar?.setAttribute("aria-label", t("toolbar.editor", "Editor tools"));
viewer.editorToolbarButtons.expand?.setAttribute("aria-expanded", viewer.isToolbarExpanded ? "true" : "false");
}
export function updateEditorToolbarState(viewer) {
if (!viewer.editorToolbarButtons) return;
const activeMap = {
moveToolbar: viewer.transformText["Transform 3D Object"] === "translate" || viewer.transformText["Transform 3D Object"] === "rotate" || viewer.transformText["Transform 3D Object"] === "scale",
orbit: viewer.transformText["Transform 3D Object"] === "",
move: viewer.transformText["Transform 3D Object"] === "translate",
rotate: viewer.transformText["Transform 3D Object"] === "rotate",
scale: viewer.transformText["Transform 3D Object"] === "scale",
picking: viewer.pickingMode === true,
ruler: viewer.RULER_MODE === true,
clippingPlanes: viewer.clippingMode === true,
advancedEditor: viewer.isEditorAdvancedPanelVisible(),
fullScreen: viewer.FULLSCREEN === true,
loadingLogs: viewer.showLoadingLogs === true,
wireframe: viewer.wireframeMode === true,
download: false,
};
Object.entries(viewer.editorToolbarButtons).forEach(([key, button]) => {
const isActive = activeMap[key] === true;
button.classList.toggle("is-active", isActive);
if (button.dataset.pressed === "true") {
button.setAttribute("aria-pressed", isActive ? "true" : "false");
} else {
button.removeAttribute("aria-pressed");
}
});
updateHierarchySubmenuState(viewer);
updateClippingPlanesSubmenuState(viewer);
updateLightsSubmenuState(viewer);
updateStatisticsSubmenuState(viewer);
}