Initial commit

This commit is contained in:
Robert Nasarek 2026-06-25 09:11:23 +02:00
commit 05c65aad4d
155 changed files with 93617 additions and 0 deletions

634
viewer/metadata.js Normal file
View file

@ -0,0 +1,634 @@
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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function buildMetadataRow(label, value) {
if (!label || typeof value === "undefined" || value === null || value === "") {
return "";
}
return (
'<div class="metadata-row">' +
'<span class="metadata-label">' + escapeHtml(label) + ':</span>' +
'<span class="metadata-value">' + escapeHtml(value) + '</span>' +
'</div>'
);
}
/**
* 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 =
'<div id="metadata-card">' +
'<button id="metadata-collapse" class="metadata-collapse metadata-collapsed" type="button" aria-expanded="false" aria-controls="metadata-content">' +
'<span class="metadata-toggle-icon" aria-hidden="true"></span>' +
'<span class="metadata-toggle-copy">' +
'<span class="metadata-toggle-eyebrow" data-i18n-key="metadata.modelDetails">' + escapeHtml(t("metadata.modelDetails", "Model details")) + '</span>' +
'<span class="metadata-toggle-title" data-i18n-key="metadata.metadata">' + escapeHtml(t("metadata.metadata", "Metadata")) + '</span>' +
'</span>' +
'<span class="metadata-toggle-chevron" aria-hidden="true"></span>' +
'</button>' +
'<div id="metadata-content" class="metadata-content">';
metadataContent +=
'<div class="metadata-row">' +
'<span class="metadata-label" data-i18n-key="metadata.visualizedFile">' + escapeHtml(t("metadata.visualizedFile", "Visualized file")) + ':</span>' +
'<span class="metadata-value">' +
escapeHtml(core.fileObject.basename) + '.' + escapeHtml(core.fileObject.extension) +
'</span>' +
'</div>';
metadataContent += '<div class="metadataSeparator"></div>';
metadataContent +=
'<div class="metadata-row">' +
'<span class="metadata-label" data-i18n-key="metadata.vertices">' + escapeHtml(t("metadata.vertices", "Vertices")) + ':</span>' +
'<span class="metadata-value">' + metadata["vertices"] + '</span>' +
'</div>';
metadataContent +=
'<div class="metadata-row">' +
'<span class="metadata-label" data-i18n-key="metadata.faces">' + escapeHtml(t("metadata.faces", "Faces")) + ':</span>' +
'<span class="metadata-value">' + metadata["faces"] + '</span>' +
'</div>';
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 +=
'</div>' + // #metadata-content
'</div>';
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 = `
<span class="title">${escapeHtml(t(`${titleKey}.loader`, `${className} Loader`))}</span>
<div class="tools">
<button type="button" id="manifesto-toggle-collapse" title="${escapeHtml(t(`${titleKey}.collapse`, `Collapse`))}"></button>
</div>
`;
formContainer.appendChild(header);
/* content */
const content = document.createElement("div");
content.className = `form-manifesto-content`;
content.id = `form-manifesto-content`;
content.innerHTML = `
<div class="form-manifesto-group">
<input type="text" id="manifesto-manifest-url" placeholder="${escapeHtml(t(`${titleKey}.manifestUrlPlaceholder`, `https://example.org/manifesto/manifest.json`))}">
<button class="primary" id="load-manifesto-from-url">${escapeHtml(t(`${titleKey}.loadFromUrl`, `Load from URL`))}</button>
</div>
<div class="form-manifesto-group column">
<textarea id="manifesto-manifest-text" rows="8" placeholder="${escapeHtml(t(`${titleKey}.manifestTextPlaceholder`, `Paste ${className} manifest JSON here...`))}"></textarea>
<div class="actions">
<button class="secondary" id="load-manifesto-from-text">${escapeHtml(t(`${titleKey}.loadFromText`, `Load from Text`))}</button>
</div>
</div>
`;
formContainer.appendChild(content);
document.body.appendChild(formContainer);
}