import { truncateString } from "./utils.js"; import { setupObject, setupCamera, toastHelper } from './viewer-utils.js'; import { core } from './core.js'; import { t } from "./i18n-utils.js"; function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function buildMetadataRow(label, value) { if (!label || typeof value === "undefined" || value === null || value === "") { return ""; } return ( '
' + '' + escapeHtml(label) + ':' + '' + escapeHtml(value) + '' + '
' ); } /** * Formats WissKI metadata labels and values for display. */ export function addWissKIMetadata(label, value) { if (typeof label !== "undefined" && typeof value !== "undefined") { var _str = ""; label = label.replace("wisski_path_3d_model__", ""); switch (label) { case "title": _str = t("metadata.title", "Title"); break; case "author_name": _str = t("metadata.author", "Author"); break; case "author_affiliation": _str = t("metadata.authorAffiliation", "Author affiliation"); break; case "license": _str = t("metadata.license", "License"); break; case "description": _str = t("metadata.description", "Description"); break; case "object_type": _str = t("metadata.objectType", "Object type"); break; case "reconstruction_authors": _str = t("metadata.reconstructionAuthors", "Reconstruction authors"); break; case "reconstruction_period": _str = t("metadata.reconstructionPeriod", "Reconstruction period"); break; default: _str = ""; break; } if (_str !== "") { return buildMetadataRow(_str, value); } } } export function lilGUIhasFolder(folder, name) { return folder.folders.some(f => f._title === name); } export function lilGUIgetFolder(gui, name) { return gui?.folders?.find(f => f._title === name) || null; } /** * Expands/collapses the metadata panel. */ export function expandMetadata() { const content = document.getElementById("metadata-content"); const toggle = document.getElementById("metadata-collapse"); const card = document.getElementById("metadata-card"); if (!content || !toggle) return; const expanded = content.classList.toggle("expanded"); toggle.classList.toggle("metadata-collapsed", !expanded); card?.classList.toggle("metadata-open", expanded); // accessibility toggle.setAttribute("aria-expanded", expanded); if (!expanded) { card?.classList.remove("metadata-card-overflowing"); content.querySelectorAll(".metadata-row-pinned").forEach((row) => { row.classList.remove("metadata-row-pinned"); }); return; } updateMetadataOverflow(); } function updateMetadataOverflow() { const content = document.getElementById("metadata-content"); const card = document.getElementById("metadata-card"); if (!content || !card || !content.classList.contains("expanded")) return; const hasOverflow = content.scrollHeight - content.clientHeight > 8; card.classList.toggle("metadata-card-overflowing", hasOverflow); content.querySelectorAll(".metadata-row").forEach((row) => { const value = row.querySelector(".metadata-value"); if (!value) return; const wasPinned = row.classList.contains("metadata-row-pinned"); row.classList.remove("metadata-row-pinned", "metadata-row-expandable"); const isExpandable = value.scrollHeight - value.clientHeight > 4; row.classList.toggle("metadata-row-expandable", isExpandable); if (wasPinned && isExpandable) { row.classList.add("metadata-row-pinned"); } }); } function bindMetadataInteractions() { if (core.metadataContainer.dataset.boundCollapse === "true") return; core.metadataContainer.addEventListener("click", (e) => { const toggle = e.target.closest("#metadata-collapse"); if (toggle) { expandMetadata(e); return; } const card = document.getElementById("metadata-card"); const content = document.getElementById("metadata-content"); if (!card || !content || !content.classList.contains("expanded")) return; const row = e.target.closest(".metadata-row"); if (!row) return; const willPin = !row.classList.contains("metadata-row-pinned"); content.querySelectorAll(".metadata-row-pinned").forEach((pinnedRow) => { pinnedRow.classList.remove("metadata-row-pinned"); }); if (willPin) row.classList.add("metadata-row-pinned"); }); window.addEventListener("resize", updateMetadataOverflow); core.metadataContainer.dataset.boundCollapse = "true"; } /** * Appends metadata HTML to the DOM. */ export function appendMetadata( metadataContent ) { core.metadataContainer.innerHTML = metadataContent; if (!core.container.contains(core.metadataContainer)) { core.container.appendChild(core.metadataContainer); } } async function fetchEntityMetadata() { if (!core.CONFIG.entity.metadata.sourceType || core.CONFIG.entity.metadata.url === "") { return ""; } const entityComponent = encodeURIComponent(core.CONFIG.entity.id) ?? core.CONFIG.entity.id ?? typeof core.CONFIG.entity.id === "undefined" ? "" : ""; if (!entityComponent) { console.warn("Entity ID is missing or invalid. Skipping metadata fetch."); return ""; } const metadataUrl = core.CONFIG.entity.metadata.url + entityComponent; try { const response = await fetch(metadataUrl, { cache: "no-cache" }); if (!response.ok) { console.warn("Metadata request failed with status:", response.status); return ""; } const responseText = await response.text(); try { const jsonData = JSON.parse(responseText); const record = Array.isArray(jsonData) ? jsonData[0] : jsonData; if (!record || typeof record !== "object") { return ""; } console.log("Processing JSON metadata:", record); const jsonFieldMap = { title: "title", reconstruction_authors: "author_name", reconstruction_authors_affiliation: "author_affiliation", reconstruction_license: "license", reconstruction_time_frame: "reconstruction_period", object_description: "description", object_type: "object_type", }; let entityMetadataContent = ""; for (const [jsonField, metadataLabel] of Object.entries(jsonFieldMap)) { if (record[jsonField]) { const fetchedValue = addWissKIMetadata(metadataLabel, record[jsonField]); if (typeof fetchedValue !== "undefined") { entityMetadataContent += fetchedValue; } } } return entityMetadataContent; } catch (_jsonError) { const parser = new DOMParser(); const doc = parser.parseFromString(responseText, "application/xml"); if (doc.documentElement.tagName === "parsererror") { console.error("XML parsing error:", doc.documentElement.textContent); return ""; } let entityMetadataContent = ""; if (doc.documentElement.childNodes.length > 0) { var data = doc.documentElement.childNodes[0].childNodes; if (data !== undefined) { for (var i = 0; i < data.length; i++) { var fetchedValue = addWissKIMetadata(data[i].tagName, data[i].textContent); if (typeof fetchedValue !== "undefined") { entityMetadataContent += fetchedValue; } } } } return entityMetadataContent; } } catch (error) { console.error("Error processing metadata:", error); return ""; } } export function fetchMetadata(_object, _type) { if (!_object?.geometry) return 0; const indexedCount = _object.geometry.index?.count; const positionCount = _object.geometry.attributes?.position?.count ?? 0; switch (_type) { case "vertices": return positionCount; case "faces": return (indexedCount ?? positionCount) / 3; default: return 0; } } /** * Handles metadata response and builds the metadata UI. */ export async function handleMetadataResponse( data, metadata, object, ) { Viewer.clearHierarchySubmenu(); var tempArray = []; if (Array.isArray(object)) { setupObject(object[0], data); await setupCamera(object[0], data); } else if (object.name === "Scene" || object.children.length > 0 || object.type == "Mesh") { setupObject(object, data); object.traverse(function (child) { if (child.isMesh) { metadata["vertices"] += fetchMetadata(child, "vertices"); metadata["faces"] += fetchMetadata(child, "faces"); if (child.name === "") child.name = "Mesh"; var shortChildName = truncateString(child.name, 35); Viewer.addHierarchySubmenuItem(shortChildName, child.id); child.traverse(function (children) { if (children.isMesh && children.name !== child.name) { if (children.name === "") children.name = "ChildrenMesh"; var shortChildrenName = truncateString(children.name, 35); Viewer.addHierarchySubmenuItem(shortChildrenName, children.id); } }); } }); await setupCamera(object, data); } else { setupObject(object, data); await setupCamera(object, data); metadata["vertices"] += fetchMetadata(object, "vertices"); metadata["faces"] += fetchMetadata(object, "faces"); if (object.name === "") { Viewer.addHierarchySubmenuItem("Mesh", object.id); object.name = object.id; } else { Viewer.addHierarchySubmenuItem(object.name, object.id); } } if (!core.metadataContainer) { core.metadataContainer = document.createElement("div"); core.metadataContainer.id = "metadata-container"; } core.metadataContainer.setAttribute("data-viewer-theme", core.container?.closest(".viewer-wrapper")?.getAttribute("data-viewer-theme") || "dark"); var metadataContent = '
' + '' + '
'; metadataContent += ''; metadataContent += '
'; metadataContent += ''; metadataContent += ''; metadataContent += await fetchEntityMetadata(); if (!core.downloadModel) { core.downloadModel.hidden = true; core.downloadModel.removeAttribute("href"); } if (core.viewEntity) { core.viewEntity.hidden = true; core.viewEntity.removeAttribute("data-embed-url"); } if (!core.isLightweight && core.downloadModel) { const c_path = core.fileObject.path; if (core.loadedFile !== "") { core.fileObject.filename = core.fileObject.filename.replace(core.fileObject.orgExtension, core.fileObject.extension); } core.downloadModel.href = `blob:${encodeURI(c_path + core.fileObject.filename)}`; core.downloadModel.setAttribute("download", core.fileObject.filename); core.downloadModel.hidden = false; window.Viewer?.updateDownloadMenuEntryLabel?.(); } if (core.viewEntity && (core.CONFIG?.entity?.id || core.fileObject?.originalPath)) { const sharePayload = window.Viewer?.getSharePayload?.(); if (sharePayload?.url) { core.viewEntity.setAttribute("data-embed-url", sharePayload.url); } window.Viewer?.updateEmbedMenuEntryState?.(); core.viewEntity.hidden = false; } metadataContent += '
' + // #metadata-content '
'; appendMetadata(metadataContent); bindMetadataInteractions(); requestAnimationFrame(updateMetadataOverflow); } /** * Handles settings for the loaded object and camera. */ export async function settingsHandler(object, hierarchyMain, data) { if (Array.isArray(object)) { setupObject(object[0], data); await setupCamera(object[0], data); } else if (object.name === "Scene" || object.children.length > 0) { setupObject(object, data); await setupCamera(object, data); } else { setupObject(object, data); await setupCamera(object, data); // Hierarchy is now managed by the editor toolbar submenu } } async function loadMetadataData(metadataUrl) { if (metadataUrl === null || metadataUrl === '') { console.log("No metadata found due to null or empty metadata URL", metadataUrl); return null; } try { if (core.isLocalPreview) { return null; } const response = await fetch(metadataUrl, { cache: "no-cache" }); if (response.status === 404) { toastHelper("settingsNotFound", "info", { filename: core.fileObject.filename }); return null; } toastHelper("settingsFound", "success", { filename: core.fileObject.filename }); return response.json(); } catch (error) { toastHelper("metadataFetchError", "error", { error: error.message }); return null; } } export async function traverseObject (object) { if (Array.isArray(object)) { // Keep relative transforms between parts; centering each element separately // collapses multi-part models into overlapping geometry. object.forEach((obj) => { obj.updateMatrixWorld(true); }); await setupCamera(object, null); } else if (object.name === "Scene" || object.children.length > 0 || object.type == "Mesh") { setupObject(object, null); await setupCamera(object, null); } else { setupObject(object, null); await setupCamera(object, null); } } export async function presentationMode (object) { if (core.PRESENTATION_MODE) { traverseObject(object); } else { return; } } /** * Fetches settings and metadata for the loaded model. */ export async function fetchSettings(object) { var metadata = { vertices: 0, faces: 0 }; let metadataUrl = ''; // Skip metadata fetch for blob URLs (drag & drop files) if (core.fileObject.filename.startsWith('blob:')) { console.log("Skipping metadata fetch for local file"); } else if (core.CONFIG.metadataUrl && core.fileObject.uri && core.fileObject.filename) { const metadataPrefix = new URL(core.CONFIG.metadataUrl).href.replace(/\/+$/, ''); let normalizedUri = new URL(core.fileObject.uri).href.replace(/\/+$/, ''); if (normalizedUri.startsWith(metadataPrefix)) { normalizedUri = normalizedUri.slice(metadataPrefix.length); } normalizedUri = normalizedUri.replace(/^\/+/, ''); metadataUrl = new URL( `${metadataPrefix}/${normalizedUri}/metadata/${core.fileObject.filename}_viewer.json` ).href; console.log("Fetched metadata from:", metadataUrl); } else { console.warn("Metadata URL or file information is missing. Skipping metadata fetch."); } let hierarchyMain; // Hierarchy is now managed by the editor toolbar submenu, not lilGUI if (core.CONFIG.entity.metadata.sourceType === "IIIF") { console.log("Fetching IIIF metadata from ", core.objectsConfig); await handleMetadataResponse( core.CONFIG.model, metadata, object); } else if (metadataUrl) { console.log("Loading metadata from URL:", metadataUrl); if (core.CONFIG.entity.proxyPath !== undefined || core.isLightweight) { metadataUrl = core.getProxyPath(metadataUrl, core.CONFIG); const data = await loadMetadataData(metadataUrl); window.Viewer?.hydrateAnnotationsFromMetadataPayload?.(data); await handleMetadataResponse(data, metadata, object); settingsHandler(object, hierarchyMain, data); } else { const data = await loadMetadataData(metadataUrl); window.Viewer?.hydrateAnnotationsFromMetadataPayload?.(data); await handleMetadataResponse(data, metadata, object); settingsHandler(object, hierarchyMain, data); } } else { window.Viewer?.hydrateAnnotationsFromMetadataPayload?.(null); await handleMetadataResponse("", metadata, object); } } export function createIIIFDropdown(iiifConfigURL) { // list of candidate IIIF config URLs (add more as needed) const iiifList = [ { url: iiifConfigURL.url, name: iiifConfigURL.name }, { url: "https://raw.githubusercontent.com/IIIF/3d/main/manifests/4_transform_and_position/model_transform_scale_position.json", name: t("iiif.optionModelPositionScale", "Model Position and Scale") }, { url: "https://raw.githubusercontent.com/IIIF/3d/main/manifests/1_basic_model_in_scene/model_origin.json", name: t("iiif.optionModelOrigin", "Model Origin") }, { url: "https://raw.githubusercontent.com/IIIF/3d/main/manifests/1_basic_model_in_scene/model_origin_bgcolor.json", name: t("iiif.optionModelOriginBg", "Model Origin with background color") }, { url: "https://raw.githubusercontent.com/IIIF/3d/main/manifests/4_transform_and_position/model_position.json", name: t("iiif.optionModelPosition", "Model Position") }, ].filter(Boolean); const group = document.createElement("div"); group.className = "form-manifesto-group"; const label = document.createElement("label"); label.textContent = t("iiif.manifest", "IIIF manifest"); label.className = "form-manifesto-label"; const select = document.createElement("select"); select.id = "manifesto-manifest-select"; select.name = "manifesto-manifest-select"; iiifList.forEach(item => { const opt = document.createElement("option"); opt.value = item.url; opt.textContent = item.name; select.appendChild(opt); }); group.appendChild(label); group.appendChild(select); // add on the top document.querySelector("#form-manifesto-content").prepend(group); } export function createAIM3IFDropdown(url) { const group = document.createElement("div"); group.className = "form-manifesto-group"; const aim3ifList = [ { url: url, name: t("aim3if.optionDefault", "Default configuration") }, { url: "https://viewer.thedworak.com/manifests/box.json", name: t("aim3if.optionBox", "Box configuration") }, // Add more AIM3IF configurations here as needed ].filter(Boolean); const label = document.createElement("label"); label.textContent = t("aim3if.modelConfig", "Model configuration"); label.className = "form-manifesto-label"; const select = document.createElement("select"); select.id = "manifesto-config-select"; select.name = "manifesto-config-select"; aim3ifList.forEach(item => { const opt = document.createElement("option"); opt.value = item.url; opt.textContent = item.name; select.appendChild(opt); }); group.appendChild(label); const optDefault = document.createElement("option"); optDefault.value = url; optDefault.textContent = t("aim3if.optionDefault", "Default configuration"); select.appendChild(optDefault); group.appendChild(label); group.appendChild(select); // add on the top document.querySelector("#form-manifesto-content").prepend(group); } export function createManifestUI(type = "iiif") { const formContainer = document.createElement("div"); const className = type === "iiif" ? "IIIF" : "AIM3IF"; const titleKey = type === "iiif" ? "iiif" : "aim3if"; formContainer.id = `form-manifesto`; /* header */ const header = document.createElement("div"); header.className = `form-manifesto-header`; header.innerHTML = ` ${escapeHtml(t(`${titleKey}.loader`, `${className} Loader`))}
`; formContainer.appendChild(header); /* content */ const content = document.createElement("div"); content.className = `form-manifesto-content`; content.id = `form-manifesto-content`; content.innerHTML = `
`; formContainer.appendChild(content); document.body.appendChild(formContainer); }