import { core } from "../core.js"; import { toastHelper, showToast } from "../viewer-utils.js"; import { t } from "../i18n-utils.js"; import THREE from "../init.js"; import { EnvironmentNode } from "three/src/nodes/Nodes.js"; export function attachAnnotations(Viewer) { Object.assign(Viewer, { clearAnnotationPOIs() { this.closeAnnotationPOITooltip(); if (!this.annotationPOIGroup) { this.annotationPOIMarkers = []; return; } this.annotationPOIGroup.children.slice().forEach((child) => { this.removeAndDisposeFromScene(child); }); this.annotationPOIGroup.clear(); this.annotationPOIMarkers = []; this.annotationPOIGroup.visible = false; }, ensureAnnotationPOIGroup() { if (this.annotationPOIGroup) return this.annotationPOIGroup; const group = new THREE.Group(); group.name = "annotation-poi-group"; group.visible = false; core.scene?.add?.(group); this.annotationPOIGroup = group; return group; }, createNumberTexture(text) { const size = 128; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.beginPath(); ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#fff"; ctx.font = "bold 64px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(text, size / 2, size / 2); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; return texture; }, createAnnotationPOIMarker(entry, position, index = 1) { const radius = Math.max((this.gridSize || core.gridSize || 1) / 15, 0.005); const texture = Viewer.createNumberTexture(index.toString()); const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, }); const sprite = new THREE.Sprite(spriteMaterial); sprite.scale.set(radius, radius, 1); sprite.position.copy(position); sprite.userData.isAnnotationPOI = true; sprite.userData.annotationId = entry.id; sprite.userData.groupId = entry.groupId || ""; sprite.userData.key = entry.key || ""; sprite.userData.targetId = entry.targetId; sprite.userData.faceIndex = entry.faceIndex; sprite.userData.title = entry.title || ""; return sprite; }, ensureAnnotationPOITooltip() { if (this.annotationPOITooltip) return this.annotationPOITooltip; const tooltip = document.createElement("div"); tooltip.id = "annotationPOITooltip"; tooltip.className = "annotation-poi-tooltip"; tooltip.hidden = true; tooltip.innerHTML = `
`; document.body.appendChild(tooltip); this.annotationPOITooltip = tooltip; this.annotationPOITooltipTitle = tooltip.querySelector("#annotationPOITooltipTitle"); return tooltip; }, openAnnotationPOITooltip(marker) { if (!marker?.userData?.isAnnotationPOI) { this.closeAnnotationPOITooltip(); return false; } const tooltip = this.ensureAnnotationPOITooltip(); if (!tooltip) return false; this.annotationPOITooltipTarget = marker; const titleText = String(marker.userData?.title || "").trim(); if (this.annotationPOITooltipTitle) { this.annotationPOITooltipTitle.textContent = titleText || "Annotation"; } tooltip.hidden = false; tooltip.style.visibility = "visible"; this.updateAnnotationPOITooltipPosition(); return true; }, getAnnotationEntriesForPOIMarker(marker) { if (!marker?.userData?.isAnnotationPOI) return []; const markerId = String(marker.userData?.annotationId || "").trim(); const markerGroupId = String(marker.userData?.groupId || "").trim(); const markerKey = String(marker.userData?.key || "").trim(); let baseEntry = null; if (markerId) { baseEntry = this.annotationEntries.find((entry) => String(entry?.id || "") === markerId) || null; } if (!baseEntry && markerKey) { baseEntry = this.annotationEntries.find((entry) => String(entry?.key || "") === markerKey) || null; } const effectiveGroupId = String(baseEntry?.groupId || markerGroupId || "").trim(); if (effectiveGroupId) { const groupedEntries = this.annotationEntries.filter( (entry) => String(entry?.groupId || "").trim() === effectiveGroupId ); if (groupedEntries.length > 0) return groupedEntries; } if (baseEntry) return [baseEntry]; const fallbackTargetId = String(marker.userData?.targetId || "").trim(); const fallbackFaceIndex = Number(marker.userData?.faceIndex); if (!fallbackTargetId || !Number.isInteger(fallbackFaceIndex) || fallbackFaceIndex < 0) return []; return [{ id: markerId || "", key: this.getFaceSelectionKey(fallbackTargetId, fallbackFaceIndex), targetId: fallbackTargetId, object: fallbackTargetId, faceIndex: fallbackFaceIndex, title: String(marker.userData?.title || "").trim(), description: "", groupId: effectiveGroupId, }]; }, selectAnnotationEntriesFaces(entries) { if (!Array.isArray(entries) || entries.length === 0) { return; } this.clearSelectedFaces(); entries.forEach((entry) => { const object = this.resolveObjectByTargetId(entry.targetId); if (!object) return; const faces = Array.isArray(entry.faceNumbers) ? entry.faceNumbers : [entry.faceIndex]; faces.forEach((faceIndex) => { this.toggleSelectedFace( { object, faceIndex, }, { multiSelect: true, } ); }); }); this.updateSelectedFacesCount(); }, openAnnotationDialogFromPOIMarker(marker) { const entries = this.getAnnotationEntriesForPOIMarker(marker); if (!entries.length) { toastHelper("annotationDataMissing", "warning"); return false; } this.selectAnnotationEntriesFaces(entries); this.buildAnnotationDialog(); if (!this.annotationDialog) return false; const keys = entries .map((entry) => String(entry?.key || "").trim()) .filter(Boolean); this.annotationTargetFaceKeys = Array.from(new Set(keys)); const existingGroupIds = Array.from( new Set(entries.map((entry) => String(entry?.groupId || "").trim()).filter(Boolean)) ); this.annotationBatchGroupId = existingGroupIds.length === 1 ? existingGroupIds[0] : `anno-group-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const uniqueTitles = Array.from( new Set(entries.map((entry) => String(entry?.title || "").trim())) ); const uniqueDescriptions = Array.from( new Set(entries.map((entry) => String(entry?.description || "").trim())) ); this.annotationDialogTitleInput.value = uniqueTitles.length === 1 ? uniqueTitles[0] : ""; this.annotationDialogDescriptionInput.value = uniqueDescriptions.length === 1 ? uniqueDescriptions[0] : ""; this.updateAnnotationDialogBounds(); this.annotationDialog.hidden = false; this.closeAnnotationPOITooltip(); this.closeActionMenu(); this.annotationDialogTitleInput?.focus(); this.annotationDialogTitleInput?.select(); return true; }, closeAnnotationPOITooltip() { this.annotationPOITooltipTarget = null; if (!this.annotationPOITooltip) return; this.annotationPOITooltip.hidden = true; this.annotationPOITooltip.style.visibility = "hidden"; }, updateAnnotationPOITooltipPosition() { const tooltip = this.annotationPOITooltip; const marker = this.annotationPOITooltipTarget; if (!tooltip || tooltip.hidden || !marker || !core.camera) return; const rect = Viewer.mainCanvas?.getBoundingClientRect?.() || core.container?.getBoundingClientRect?.(); if (!rect || rect.width <= 0 || rect.height <= 0) return; const worldPosition = new THREE.Vector3(); marker.getWorldPosition(worldPosition); const projected = worldPosition.clone().project(core.camera); const withinDepth = projected.z >= -1 && projected.z <= 1; const screenX = rect.left + ((projected.x + 1) / 2) * rect.width; const screenY = rect.top + ((-projected.y + 1) / 2) * rect.height; const withinHorizontal = screenX >= rect.left && screenX <= rect.right; const withinVertical = screenY >= rect.top && screenY <= rect.bottom; if (!withinDepth || !withinHorizontal || !withinVertical) { tooltip.style.visibility = "hidden"; return; } tooltip.style.left = `${Math.round(screenX)}px`; tooltip.style.top = `${Math.round(screenY)}px`; tooltip.style.visibility = "visible"; }, refreshAnnotationPOIs() { this.clearAnnotationPOIs(); const entries = this.getAnnotationEntriesForPersistence(); if (!entries.length) return 0; const group = this.ensureAnnotationPOIGroup(); let added = 0; entries.forEach((entry) => { const object = this.resolveObjectByTargetId(entry.targetId); if (!object) return; const center = new THREE.Vector3(); entry.faceNumbers.forEach(faceIndex => { const point = this.getFaceCentroidWorld(object, faceIndex); if (point) center.add(point); }); center.divideScalar(entry.faceNumbers.length); const marker = this.createAnnotationPOIMarker(entry, center, entries.indexOf(entry) + 1); group.add(marker); this.annotationPOIMarkers.push(marker); added += 1; }); group.visible = added > 0; return added; }, findAnnotationByFaceKey(key) { if (!key) return null; return this.annotationEntries.find((annotation) => { const targetId = String( annotation.targetId || annotation.object || annotation.target?.id || "" ).trim(); if (!targetId) return false; const faceNumbers = Array.isArray(annotation.faceNumbers) ? annotation.faceNumbers : [annotation.faceIndex]; return faceNumbers.some((faceIndex) => { const annotationKey = this.getFaceSelectionKey( targetId, Number(faceIndex) ); return annotationKey === key; }); }) || null; }, buildAnnotationDialog() { if (!core.container || this.annotationDialog) return; const dialog = document.createElement("div"); dialog.id = "annotationDialog"; dialog.className = "annotation-dialog"; dialog.hidden = true; dialog.innerHTML = `
`; document.body.appendChild(dialog); this.annotationDialog = dialog; this.annotationDialogHost = document.body; this.annotationDialogTitleInput = dialog.querySelector("#annotationTitleInput"); this.annotationDialogDescriptionInput = dialog.querySelector("#annotationDescriptionInput"); const form = dialog.querySelector("#annotationDialogForm"); this.bindEventListener(dialog, "click", (event) => { const dismissTrigger = event.target?.closest?.("[data-annotation-dismiss='true']"); if (dismissTrigger) { this.closeAnnotationDialog(); } }); this.bindEventListener(document, "keydown", (event) => { if (event.key !== "Escape") return; if (!this.annotationDialog || this.annotationDialog.hidden) return; event.preventDefault(); this.closeAnnotationDialog(); }); this.bindEventListener(form, "submit", (event) => { event.preventDefault(); this.saveAnnotationFromDialog(); }); this.bindEventListener(window, "resize", () => this.updateAnnotationDialogBounds()); this.bindEventListener(window, "scroll", () => this.updateAnnotationDialogBounds(), true); this.bindEventListener(document, "fullscreenchange", () => this.updateAnnotationDialogBounds()); }, updateAnnotationDialogBounds() { if (!this.annotationDialog) return; const targetRect = Viewer.mainCanvas?.getBoundingClientRect?.() || core.container?.getBoundingClientRect?.(); if (!targetRect) return; const left = Math.max(0, Math.round(targetRect.left)); const top = Math.max(0, Math.round(targetRect.top)); const width = Math.max(0, Math.round(targetRect.width)); const height = Math.max(0, Math.round(targetRect.height)); this.annotationDialog.style.left = `${left}px`; this.annotationDialog.style.top = `${top}px`; this.annotationDialog.style.width = `${width}px`; this.annotationDialog.style.height = `${height}px`; }, openAnnotationDialog() { if (!Array.isArray(this.selectedFaces) || this.selectedFaces.length === 0) { toastHelper("selectFaceRequired", "warning"); return; } this.buildAnnotationDialog(); if (!this.annotationDialog) return; const selectedKeys = this.selectedFaces .map((entry) => String(entry?.key || "").trim()) .filter(Boolean); this.annotationTargetFaceKeys = selectedKeys; const existingGroupIds = Array.from( new Set( selectedKeys .map((key) => this.annotationEntries.find((entry) => entry.key === key)?.groupId || "") .map((value) => String(value).trim()) .filter(Boolean) ) ); this.annotationBatchGroupId = existingGroupIds.length === 1 ? existingGroupIds[0] : `anno-group-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const existingEntries = selectedKeys .map((key) => this.annotationEntries.find((entry) => entry.key === key)) .filter(Boolean); const uniqueTitles = Array.from( new Set(existingEntries.map((entry) => String(entry.title || "").trim())) ); const uniqueDescriptions = Array.from( new Set(existingEntries.map((entry) => String(entry.description || "").trim())) ); this.annotationDialogTitleInput.value = uniqueTitles.length === 1 ? uniqueTitles[0] : ""; this.annotationDialogDescriptionInput.value = uniqueDescriptions.length === 1 ? uniqueDescriptions[0] : ""; this.updateAnnotationDialogBounds(); this.annotationDialog.hidden = false; this.closeAnnotationPOITooltip(); this.closeActionMenu(); this.annotationDialogTitleInput?.focus(); this.annotationDialogTitleInput?.select(); }, openAnnotationDialogWithAutoPicking() { if (!this.pickingMode) { this.pickingMode = true; this.RULER_MODE = false; this.updateDistanceMeasurementControllerLabel(); this.updatePickingModeControllerLabel(); this.updatePickingControlsVisibility(); toastHelper("featureToggle", "info", { feature: "Face picking", state: "enabled" }); } if (!Array.isArray(this.selectedFaces) || this.selectedFaces.length === 0) { toastHelper("selectFaceRequiredAgain", "warning"); return; } this.openAnnotationDialog(); }, closeAnnotationDialog() { if (!this.annotationDialog) return; this.annotationDialog.hidden = true; this.annotationTargetFaceKeys = []; this.annotationBatchGroupId = ""; }, saveAnnotationFromDialog() { if (!Array.isArray(this.annotationTargetFaceKeys) || this.annotationTargetFaceKeys.length === 0) { toastHelper("noFacesSelected", "warning"); this.closeAnnotationDialog(); return; } const title = String(this.annotationDialogTitleInput?.value || "").trim(); const description = String(this.annotationDialogDescriptionInput?.value || "").trim(); if (!title) { toastHelper("titleRequired", "warning"); this.annotationDialogTitleInput?.focus(); return; } const selectedFaces = this.annotationTargetFaceKeys .map((key) => { const selected = this.selectedFaces.find((entry) => entry.key === key); if (selected) return selected; const existingEntry = this.findAnnotationByFaceKey(key); if (!existingEntry) return null; return { key: existingEntry.key, targetId: existingEntry.targetId || existingEntry.object || "", object: existingEntry.object || existingEntry.targetId || "", faceIndex: existingEntry.faceIndex, }; }) .filter(Boolean); if (selectedFaces.length === 0) { toastHelper("facesInactive", "warning"); this.closeAnnotationDialog(); return; } const nowIso = new Date().toISOString(); //const groupId = String(this.annotationBatchGroupId || `anno-group-${Date.now()}`); let updatedCount = 0; let addedCount = 0; const targetId = selectedFaces[0].targetId; const faceNumbers = selectedFaces .map(f => Number(f.faceIndex)) .filter(Number.isInteger); const annotationPayload = { id: this.annotationBatchGroupId || `anno-${Date.now()}`, targetId, object: targetId, faceNumbers, faceIndex: faceNumbers[0], target: { id: targetId, faces: faceNumbers }, title, description, updatedAt: nowIso, createdAt: nowIso }; const existingIndex = this.annotationEntries.findIndex( entry => entry.id === annotationPayload.id ); if (existingIndex >= 0) { this.annotationEntries.splice( existingIndex, 1, annotationPayload ); updatedCount++; } else { this.annotationEntries.push(annotationPayload); addedCount++; } const totalChanged = updatedCount + addedCount; if (totalChanged > 0) { toastHelper("annotationsSaved", "success", { count: totalChanged, plural: totalChanged === 1 ? "" : "s" }); } this.refreshAnnotationPOIs(); this.closeAnnotationDialog(); }, getAnnotationEntriesForPersistence() { if (!Array.isArray(this.annotationEntries)) return []; return this.annotationEntries .map((entry, index) => { if (!entry || typeof entry !== "object") return null; const targetId = String(entry.targetId || entry.object || "").trim(); const faceNumbersRaw = Array.isArray(entry.faceNumbers) ? entry.faceNumbers : [entry.faceIndex]; const faceNumbers = faceNumbersRaw .map((value) => Number(value)) .filter((value) => Number.isInteger(value) && value >= 0); const faceIndex = faceNumbers[0] ?? Number(entry.faceIndex); const normalizedFaceIndex = Number.isInteger(faceIndex) && faceIndex >= 0 ? faceIndex : -1; if (!targetId || normalizedFaceIndex < 0) return null; const key = this.getFaceSelectionKey(targetId, normalizedFaceIndex); const fallbackId = `anno-${this.toStableIdToken(targetId)}-f${normalizedFaceIndex}`; return { id: String(entry.id || fallbackId), groupId: entry.groupId ? String(entry.groupId) : "", key, object: targetId, targetId, faceIndex: normalizedFaceIndex, faceNumbers: faceNumbers.length > 0 ? faceNumbers : [normalizedFaceIndex], target: { id: targetId, faces: faceNumbers.length > 0 ? faceNumbers : [normalizedFaceIndex], }, title: String(entry.title || "").trim(), description: String(entry.description || "").trim(), createdAt: entry.createdAt ? String(entry.createdAt) : "", updatedAt: entry.updatedAt ? String(entry.updatedAt) : "", }; }) .filter(Boolean); }, exportAnnotationsToIIIFXml() { const entries = this.getAnnotationEntriesForPersistence(); const groups = new Map(); entries.forEach((entry) => { const key = entry.groupId || entry.id; if (!groups.has(key)) { groups.set(key, { ...entry, faceNumbers: [], }); } groups.get(key).faceNumbers.push(...entry.faceNumbers); }); const doc = document.implementation.createDocument("", "", null); const root = doc.createElement("iiif:annotations"); root.setAttribute("xmlns:iiif", "http://iiif.io/api/presentation/3#"); root.setAttribute("version", "3.0"); root.setAttribute("generatedAt", new Date().toISOString()); doc.appendChild(root); groups.forEach((entry) => { const annotation = doc.createElement("iiif:annotation"); annotation.setAttribute("id", entry.id); annotation.setAttribute("type", "Annotation"); annotation.setAttribute("motivation", "commenting"); if (entry.groupId) { annotation.setAttribute("groupId", String(entry.groupId)); } const body = doc.createElement("iiif:body"); body.setAttribute("type", "TextualBody"); body.setAttribute("format", "text/plain"); const titleNode = doc.createElement("iiif:title"); titleNode.textContent = entry.title || ""; body.appendChild(titleNode); const descriptionNode = doc.createElement("iiif:description"); descriptionNode.textContent = entry.description || ""; body.appendChild(descriptionNode); annotation.appendChild(body); const targetNode = doc.createElement("iiif:target"); targetNode.setAttribute("id", entry.targetId); targetNode.setAttribute("faces", entry.faceNumbers.join(",")); annotation.appendChild(targetNode); root.appendChild(annotation); }); return new XMLSerializer().serializeToString(doc); }, downloadAnnotationsXmlFile() { const xml = this.exportAnnotationsToIIIFXml(); if (!xml) { toastHelper("noAnnotationsToExport", "warning"); return false; } const defaultBaseName = core.fileObject?.basename || "annotations"; const safeBaseName = String(defaultBaseName).replace(/[^a-zA-Z0-9._-]+/g, "_"); const fileName = `${safeBaseName || "annotations"}-iiif-annotations.xml`; const blob = new Blob([xml], { type: "application/xml;charset=utf-8" }); const objectUrl = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = objectUrl; link.download = fileName; link.style.display = "none"; document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(() => URL.revokeObjectURL(objectUrl), 0); toastHelper("annotationsExported", "success"); return true; }, exportIIIFManifest() { // Added a new method to export the IIIF manifest const iiifUrl = core.fileObject?.originalPath || ""; if (!iiifUrl) { toastHelper("iiifUrlMissing", "warning"); return false; } // Generate the IIIF manifest and log it to the console const sceneId = `${iiifUrl}/scene`; const manifest = { "@context": "http://iiif.io/api/presentation/4/context.json", id: `${iiifUrl}/manifest.json`, type: "Manifest", label: { en: [core.fileObject?.basename || "Model"] }, items: [ { id: sceneId, type: "Scene", label: { en: [core.fileObject?.basename || "Model"] }, backgroundColor: core.scene?.background ? `#${core.scene.background.getHexString()}` : "#000000", items: [ { id: `${sceneId}/page/model`, type: "AnnotationPage", items: [ { id: `${sceneId}/annotation/model`, type: "Annotation", motivation: ["painting"], body: { id: core.fileObject.originalPath, type: "Model", format: core.fileObject.mimeType || undefined }, target: { id: sceneId, type: "Scene" } } ] } ], annotations: [ { id: `${sceneId}/page/annotations`, type: "AnnotationPage", items: this.getAnnotationEntriesForPersistence().map((entry) => ({ id: String(entry.id), type: "Annotation", motivation: ["commenting"], label: { en: [String(entry.title || "").trim()] }, created: entry.createdAt || undefined, modified: entry.updatedAt || undefined, target: { source: core.fileObject.originalPath, selector: { type: "JsonSelector", value: { targetId: entry.targetId, faceIndex: entry.faceIndex, faceNumbers: entry.faceNumbers || [], selectorId: entry.selectorId } } }, body: { type: "TextualBody", value: String(entry.description || "").trim() }, AIM3DViewer: { groupId: entry.groupId || "", key: entry.key || "", object: entry.object || "" } })) } ] } ], AIM3DViewer: { version: "1.0", generatedAt: new Date().toISOString(), camera: { position: core.camera.position.toArray(), target: core.controls?.target ? core.controls.target.toArray() : [0, 0, 0], up: core.camera.up.toArray(), fov: core.camera.fov, zoom: typeof core.controls?.zoom === "number" ? core.controls.zoom : undefined, distance: core.camera.position.distanceTo( core.controls.target ), perspectiveMode: core.camera.isPerspectiveCamera ? "orthographic" : "perspective", }, viewer: { container: core.CONFIG?.viewer?.container || "DFG_3DViewer", mailUrl: core.CONFIG.mainUrl || "https://localhost", baseNamespace: "https://localhost", metadataUrl: "https://localhost", backgroundColor: core.scene?.background?.isColor ? `#${core.scene.background.getHexString()}` : undefined, environmentMap: { intensity: core.environmentMapIntensity || 0.5, preset: core.environmentMapPreset || "neutral", enabled: core.environmentMapEnabled || true }, presentationMode: core.PRESENTATION_MODE || false, sandbox: core.SANDBOX_MODE || false, scale: core.CONFIG.viewer.scaleContainer || new Vector2(1,1), performance: core.CONFIG.viewer.performanceMode || "high-performance", units: core.CONFIG?.viewer?.measurement?.modelUnitInMeters, gallery: { build: core.CONFIG.viewer.gallery?.build || false, container: core.CONFIG.viewer.gallery?.container || "AIM3DViewerContainer", imageClass: core.CONFIG.viewer.gallery?.imageClass || "AIM3DViewerGalleryImage", imageId: core.CONFIG.viewer.gallery?.imageId || "AIM3DViewerGalleryImage", buildFake: true, testImages: [undefined], } }, integration: { type: core.CONFIG.entity ? "drupal" : "none", bundle: core.CONFIG.entity?.bundle || "bd3d7baa74856d141bcff7b4193fa128", fieldDf: core.CONFIG.entity?.fieldDf || "field_df", exportViewer: core.CONFIG.entity?.exportViewer || "field_df", idUri: core.CONFIG.entity?.idUri || "/wisski/navigate/(.*)/view", viewEntityPath: core.CONFIG.entity?.viewEntityPath || "/wisski/navigate/", attributeId: core.CONFIG.entity?.attributeId || "wisski_id", metadata: { source: core.CONFIG.entity?.metadata?.source || "", }, fileUpload: core.CONFIG.viewer.fileUpload || "fbf95bddee5160d515b982b3fd2e05f7", fileName: core.CONFIG.viewer.fileName || "faa602a0be629324806aef22892cdbe5", imageGeneration: core.CONFIG.viewer.imageGeneration || "f605dc6b727a1099b9e52b3ccbdf5673", }, lights: (core.scene?.children || []) .filter((child) => [ "DirectionalLight", "SpotLight", "PointLight", "AmbientLight" ].includes(child.type) ) .map((light) => ({ type: light.type, position: light.position?.toArray?.() || [0, 0, 0], target: light.target?.position?.toArray?.() || undefined, color: `#${light.color.getHexString()}`, intensity: light.intensity })), modelTransform: { position: core.mainObject?.position?.toArray?.() || [0, 0, 0], rotation: core.mainObject?.rotation ? { x: core.mainObject.rotation.x, y: core.mainObject.rotation.y, z: core.mainObject.rotation.z, order: core.mainObject.rotation.order } : { x: 0, y: 0, z: 0, order: "XYZ" }, scale: core.mainObject?.scale?.toArray?.() || [1, 1, 1], wireframe: core.wireframeMode || false, } }, modified: new Date().toISOString(), }; manifest.AIM3DViewer.generatedAt = new Date().toISOString(); core.fileObject?.iiifUrl && (manifest.id = `${core.fileObject?.basename}_manifest.json`); const defaultBaseName = core.fileObject?.basename || "manifest"; const safeBaseName = String(defaultBaseName).replace(/[^a-zA-Z0-9._-]+/g, "_"); const fileName = `${safeBaseName || "manifest"}-iiif-manifest.json`; const blob = new Blob([JSON.stringify(manifest, null, 2)], { type: "application/json;charset=utf-8" }); const objectUrl = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = objectUrl; link.download = fileName; link.style.display = "none"; document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(() => URL.revokeObjectURL(objectUrl), 0); toastHelper("iiifManifestGenerated", "success"); return true; }, ensureAnnotationImportInput() { if (this.annotationImportInput) return this.annotationImportInput; const input = document.createElement("input"); input.type = "file"; input.accept = ".xml,text/xml,application/xml"; input.hidden = true; this.bindEventListener(input, "change", async (event) => { const target = event?.target; const file = target?.files?.[0]; if (!file) return; try { const xmlText = await file.text(); const imported = this.importAnnotationsFromIIIFXml(xmlText); if (imported > 0) { toastHelper("annotationsImported", "success", { count: imported, plural: imported === 1 ? "" : "s" }); } else { toastHelper("noValidAnnotations", "warning"); } } catch (error) { console.error(error); toastHelper("annotationsImportError", "error"); } finally { target.value = ""; } }); document.body.appendChild(input); this.annotationImportInput = input; return input; }, triggerAnnotationsXmlImport() { const input = this.ensureAnnotationImportInput(); if (!input) return false; input.click(); return true; }, importAnnotationsFromIIIFXml(xmlText) { const xml = String(xmlText || "").trim(); if (!xml) { this.annotationEntries = []; return 0; } let doc; try { doc = new DOMParser().parseFromString(xml, "application/xml"); } catch (_error) { return 0; } if (!doc || doc.querySelector("parsererror")) { return 0; } const annotations = Array.from( doc.querySelectorAll("annotation, iiif\\:annotation") ); const importedEntries = []; annotations.forEach((node, index) => { const rawId = node.getAttribute("id") || ""; const rawGroupId = node.getAttribute("groupId") || ""; const targetNode = node.querySelector("target, iiif\\:target") || node.getElementsByTagName("target")[0] || node.getElementsByTagName("iiif:target")[0]; const targetId = String(targetNode?.getAttribute?.("id") || "").trim(); const facesAttr = String(targetNode?.getAttribute?.("faces") || "").trim(); const faceNumbers = facesAttr .split(/[,\s;|]+/) .filter(Boolean) .map((value) => Number(value)) .filter((value) => Number.isInteger(value) && value >= 0); const faceIndex = faceNumbers[0]; if (!targetId || !Number.isInteger(faceIndex)) return; const titleNode = node.querySelector("title, iiif\\:title, label, iiif\\:label") || node.getElementsByTagName("title")[0] || node.getElementsByTagName("iiif:title")[0]; const descriptionNode = node.querySelector("description, iiif\\:description, value, iiif\\:value") || node.getElementsByTagName("description")[0] || node.getElementsByTagName("iiif:description")[0]; const key = this.getFaceSelectionKey(targetId, faceIndex); importedEntries.push({ id: rawId || `anno-${this.toStableIdToken(targetId)}-f${faceIndex}-${index}`, groupId: rawGroupId ? String(rawGroupId) : "", key, object: targetId, targetId, faceIndex, faceNumbers: faceNumbers.length > 0 ? faceNumbers : [faceIndex], target: { id: targetId, faces: faceNumbers.length > 0 ? faceNumbers : [faceIndex], }, title: String(titleNode?.textContent || "").trim(), description: String(descriptionNode?.textContent || "").trim(), }); }); this.annotationEntries = importedEntries; this.refreshAnnotationPOIs(); return importedEntries.length; }, hydrateAnnotationsFromMetadataPayload(payload) { if (!payload || typeof payload !== "object") { this.annotationEntries = []; this.refreshAnnotationPOIs(); return 0; } const xmlCandidate = payload.iiifAnnotationsXml || payload.iiif_annotations_xml || payload.annotationsXml || payload.annotations_xml || ""; if (typeof xmlCandidate === "string" && xmlCandidate.trim() !== "") { return this.importAnnotationsFromIIIFXml(xmlCandidate); } if (Array.isArray(payload.annotationEntries)) { this.annotationEntries = payload.annotationEntries .map((entry, index) => { const targetId = String(entry?.targetId || entry?.object || entry?.target?.id || "").trim(); const faceNumbers = Array.isArray(entry?.faceNumbers) ? entry.faceNumbers : Array.isArray(entry?.target?.faces) ? entry.target.faces : [entry?.faceIndex]; const normalizedFaces = faceNumbers .map((value) => Number(value)) .filter((value) => Number.isInteger(value) && value >= 0); const faceIndex = normalizedFaces[0]; if (!targetId || !Number.isInteger(faceIndex)) return null; return { id: String(entry?.id || `anno-${this.toStableIdToken(targetId)}-f${faceIndex}-${index}`), groupId: entry?.groupId ? String(entry.groupId) : "", key: this.getFaceSelectionKey(targetId, faceIndex), object: targetId, targetId, faceIndex, faceNumbers: normalizedFaces.length > 0 ? normalizedFaces : [faceIndex], target: { id: targetId, faces: normalizedFaces.length > 0 ? normalizedFaces : [faceIndex], }, title: String(entry?.title || "").trim(), description: String(entry?.description || "").trim(), createdAt: entry?.createdAt ? String(entry.createdAt) : "", updatedAt: entry?.updatedAt ? String(entry.updatedAt) : "", }; }) .filter(Boolean); this.refreshAnnotationPOIs(); return this.annotationEntries.length; } this.annotationEntries = []; this.refreshAnnotationPOIs(); return 0; }, extractAnnotationsXmlFromExportDocument(doc) { if (!doc) return ""; const node = doc.querySelector("iiif\\:annotations, annotations, iiif_annotations, iiif_annotations_xml") || doc.getElementsByTagName("iiif:annotations")[0] || doc.getElementsByTagName("annotations")[0] || doc.getElementsByTagName("iiif_annotations")[0] || doc.getElementsByTagName("iiif_annotations_xml")[0]; if (!node) return ""; if (node.tagName === "iiif_annotations_xml") { return String(node.textContent || "").trim(); } try { return new XMLSerializer().serializeToString(node); } catch (_error) { return ""; } }, applyPendingAnnotationsIfAny() { const pendingXml = String(this.pendingAnnotationsXml || "").trim(); if (!pendingXml) return 0; const imported = this.importAnnotationsFromIIIFXml(pendingXml); this.pendingAnnotationsXml = ""; return imported; }, }); }