diff --git a/index.html b/index.html index b9c3d02..ea2aeb8 100644 --- a/index.html +++ b/index.html @@ -38,6 +38,10 @@ rel="stylesheet" href="/src/MeasurementControl/measurementsPanel.css" /> + diff --git a/src/AnnotationControl/annotationPanel.css b/src/AnnotationControl/annotationPanel.css new file mode 100644 index 0000000..f8bebc8 --- /dev/null +++ b/src/AnnotationControl/annotationPanel.css @@ -0,0 +1,279 @@ +/* Hide Potree annotation icon specifically */ +/* This targets the that references annotation.svg. */ +img.button-icon[src$="/annotation.svg"]{ + display: none !important; + visibility: hidden !important; + width: 0 !important; + height: 0 !important; + margin: 0 !important; + padding: 0 !important; +} + +.annotation-desc { + font-size: 0.85em; + margin-left: 8px; +} + +.annotation-info { + font-size: 0.8em; + margin-left: 8px; +} + +.annotation-edit-textarea { + width: 100%; +} + +.annotation-add-button { + margin: 10px 0; +} + +.annotation-empty { + opacity: 0.6; + padding: 10px; + text-align: center; +} + +.annotation-row { + display: flex; + flex-direction: column; /* stack header above body */ + gap: 6px; + padding: 6px 8px; + margin: 10px 2px; + border-radius: 6px; + background: #2c3539; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s; + color: #d9e2e6; +} + +.annotation-row .annotation-label { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.annotation-header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} +.annotation-body { + width: 100%; + margin-top: 6px; +} + +/* By default hide the body (details); show when row has open */ +.annotation-body { + display: none; +} +.annotation-row.open .annotation-body { + display: block; +} + +.annotation-row .toggle-triangle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + font-size: 12px; + color: #8fb9c9; + vertical-align: middle; + cursor: pointer; +} +.annotation-row .toggle-triangle::after { + content: '▸'; +} +.annotation-row.open .toggle-triangle::after { + content: '▾'; +} + +/* Jump button */ +.annotation-row .jump-btn { + width: 28px; + height: 28px; + border-radius: 50%; + background: transparent; + color: #7fbcd3; + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 8px; + border: 1px solid rgba(127,188,211,0.28); + cursor: pointer; + transition: transform 0.12s ease, background 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease, color 0.12s ease; + box-shadow: none; +} +.annotation-row .jump-btn::after { + content: '→'; + font-weight: 700; + font-size: 13px; + line-height: 1; +} +.annotation-row .jump-btn:hover { + color: #7fbcd3; + border-color: rgba(14,166,217,0.44); + box-shadow: 0 8px 18px rgba(14,166,217,0.14), 0 0 0 3px rgba(14,166,217,0.06); + background: transparent; +} +.annotation-row .jump-btn:focus { + outline: none; + border-color: rgba(14,166,217,0.44); + box-shadow: 0 8px 22px rgba(14,166,217,0.18), 0 0 0 4px rgba(14,166,217,0.06); +} +.annotation-row .jump-btn:active, +.annotation-row .jump-btn[aria-pressed="true"] { + transform: translateY(1px); + background: linear-gradient(180deg, #28c1ff 0%, #0ea6d9 100%); + color: #fff; + border-color: transparent; + box-shadow: 0 6px 20px rgba(14,166,217,0.26), inset 0 1px 0 rgba(255,255,255,0.14); +} + +.annotation-row .jump-btn.recently-pressed { + transform: translateY(1px); + background: linear-gradient(180deg, #28c1ff 0%, #0ea6d9 100%); + color: #fff; + border-color: transparent; + box-shadow: 0 6px 20px rgba(14,166,217,0.26), inset 0 1px 0 rgba(255,255,255,0.14); + transition: background 1.4s cubic-bezier(.2,.9,.2,1), box-shadow 1.4s cubic-bezier(.2,.9,.2,1), transform 0.12s ease, color 1.0s ease, opacity 1.4s ease; +} + +.annotation-row .jump-btn.jump-disabled, +.annotation-row .jump-btn:disabled { + opacity: 0.44; + color: #9fbfcf; + border-color: rgba(127,188,211,0.12); + box-shadow: none; + cursor: default; + pointer-events: none; +} + +.annotation-row .jump-btn.jump-disabled:hover, +.annotation-row .jump-btn.jump-disabled:focus, +.annotation-row .jump-btn:disabled:hover, +.annotation-row .jump-btn:disabled:focus { + transform: none; + box-shadow: none; + background: transparent; +} + + +.annotation-row .del-btn { + background: #3b2626; + border: 1px solid #5a3a3a; + color: #ff9a9a; + font-weight: 600; + font-size: 11px; + line-height: 1; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; + margin-left: 8px; +} +.annotation-row .del-btn:hover { + background: #5a2d2d; + color: #fff; +} + +.annotation-row:hover { + background: #354045; + border-color: #425056; +} +.annotation-row.active { + background: #1f4b63; + border-color: #2f6b8c; + box-shadow: 0 0 0 1px #2f6b8c66; +} + +.annotation-row .annotation-desc, +.annotation-row .annotation-info { + background: #2f383d; + padding: 8px; + border: 1px solid #404a50; + border-radius: 4px; + color: #cfd5d8; + font-family: inherit; + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.annotation-row .annotation-desc, +.annotation-row .annotation-info { + display: none; +} +.annotation-row.open .annotation-desc, +.annotation-row.open .annotation-info { + display: block; + margin-top: 6px; +} + +.annotation-header .annotation-label { + margin-right: 8px; +} +.annotation-header .controls { + margin-left: auto; + display: flex; + gap: 8px; + align-items: center; + flex: 0 0 auto; +} + +.annotation-header input[type="text"], +.annotation-header .annotation-label input[type="text"] { + width: 100%; + max-width: 100%; + box-sizing: border-box; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Make the caret/triangle take up fixed space so it doesn't shift */ +.annotation-row .toggle-triangle { + flex: 0 0 18px; +} + +/* Add button */ +.annotation-add-button { + background: linear-gradient(180deg,#f6f6f6 0%, #e9e9e9 100%); + color: #222; + padding: 8px 16px; + min-width: 140px; + height: 38px; + display: block; + margin: 12px auto; + border-radius: 6px; + font-size: 13px; + font-weight: 700; + box-shadow: 0 1px 0 rgba(255,255,255,0.6) inset; + border: 1px solid #cfcfcf; + cursor: pointer; + text-align: center; +} +.annotation-add-button .add-label { + color: #222; + font-weight: 700; +} +.annotation-add-button:hover { + background: linear-gradient(180deg,#f3f3f3 0%, #e2e2e2 100%); + border-color: #bfbfbf; +} +.annotation-add-button:active { + transform: translateY(1px); + background: linear-gradient(180deg,#e9e9e9 0%, #dbdbdb 100%); + box-shadow: inset 0 2px 6px rgba(0,0,0,0.06); +} +.annotation-add-button:focus { + outline: 2px solid rgba(100,100,100,0.12); + outline-offset: 2px; +} + diff --git a/src/AnnotationControl/annotationPanel.js b/src/AnnotationControl/annotationPanel.js new file mode 100644 index 0000000..5c4a9f9 --- /dev/null +++ b/src/AnnotationControl/annotationPanel.js @@ -0,0 +1,741 @@ +import {initAnnotationPersistence} from './persistence' + +/** + * Annotations Panel + * Injects a custom Annotations section for storing camera positions, + * that will (later) be used for letting users rapidly jump to saved views. + */ +export function initAnnotationsPanel(viewer) { + // Container management + const existingListContainer = document.getElementById('annotations_list') + let targetContainer = existingListContainer + if (!targetContainer) { + const menu = document.getElementById('potree_menu') + if (menu) { + const header = document.createElement('h3') + header.id = 'menu_camera_annotations' + const headerSpan = document.createElement('span') + headerSpan.textContent = 'Saved Locations' + header.appendChild(headerSpan) + + const panel = document.createElement('div') + panel.className = 'pv-menu-list annotations-panel' + + const listContainerDiv = document.createElement('div') + listContainerDiv.id = 'annotations_list' + listContainerDiv.className = 'auto' + panel.appendChild(listContainerDiv) + + // Insert after measurement panel but before tools, or at end if not found + const measurements = document.querySelector('.measurements-panel') + if (measurements) { + menu.insertBefore(panel, measurements.nextSibling) + menu.insertBefore(header, panel) + } else { + menu.appendChild(header) + menu.appendChild(panel) + } + + if ($(menu).accordion) { + try { + $(menu).accordion('refresh') + } catch (e) {} + } + + // Toggle collapse + header.addEventListener('click', () => { + if ($(menu).accordion && $(menu).data('uiAccordion')) return + if (window.jQuery) { + const $p = window.jQuery(panel) + $p.is(':visible') ? $p.slideUp(350) : $p.slideDown(350) + return + } + }) + targetContainer = panel.querySelector('#annotations_list') + } + } + if (!targetContainer) { + console.warn( + 'Annotations list container not found and dynamic injection failed' + ) + return + } + + // UI update + // Helper: normalize different vector shapes into [x,y,z] (necessary for handling both Three.js Vector3 and serialized data stored in the jsTree) + function vecToArray(v) { + if (!v) return null + if (Array.isArray(v)) return v + if (typeof v.toArray === 'function') return v.toArray() + if (v.x != null && v.y != null && v.z != null) return [v.x, v.y, v.z] + return null + } + // Monotonic numbering: assign an increasing number to each UUID and never reuse numbers. + const _uuidToIndex = new Map() + let _nextIndex = 1 + + function _ensureIndexForUUID(uuid) { + if (!uuid) return null + if (!_uuidToIndex.has(uuid)) { + _uuidToIndex.set(uuid, _nextIndex++) + } + return _uuidToIndex.get(uuid) + } + + // Helper: safe accessors and utilities for jsTree and annotation descriptions + function _getJSTree() { + try { + return $('#jstree_scene').jstree && $('#jstree_scene').jstree() + } catch (e) { + return null + } + } + + function _getJSTreeInstance() { + try { + return ($('#jstree_scene').jstree && $('#jstree_scene').jstree(true)) || + (typeof $.jstree !== 'undefined' && $.jstree.reference && $.jstree.reference('#jstree_scene')) || + null + } catch (e) { + return null + } + } + + function _getAnnotationsRoot() { + const t = _getJSTree() + try { + return t ? t.get_json('annotations') : null + } catch (e) { + return null + } + } + + function _findNodeInAnnotationsRootByUUID(root, uuid) { + if (!root || !root.children || !uuid) return null + return root.children.find((c) => (c.data && c.data.uuid) === uuid) || null + } + + function _getDescriptionForUUID(uuid, nodeData) { + // Prefer live annotation description, then node data (desc/description) + if (!uuid && !nodeData) return '' + try { + const live = _findLiveAnnotationByUUID(uuid) + if (live) { + if (live.data && typeof live.data.description !== 'undefined') return String(live.data.description || '') + if (typeof live.description !== 'undefined') return String(live.description || '') + } + } catch (e) {} + const ann = nodeData || {} + return (ann.description && String(ann.description)) || (ann.desc && String(ann.desc)) || '' + } + + function _renameJSTreeNode(nodeId, text) { + try { + if (window.$ && $.jstree) { + if ($.jstree.reference) { + const ref = $.jstree.reference(nodeId) + if (ref && typeof ref.rename_node === 'function') { + ref.rename_node(nodeId, text) + return + } + } + // fallback to global selector + if ($('#jstree_scene') && $('#jstree_scene').jstree) { + try { + $('#jstree_scene').jstree('rename_node', nodeId, text) + return + } catch (e) {} + } + } + } catch (e) { + // ignore rename failures + } + } + + function _findLiveAnnotationByUUID(uuid) { + if (!uuid || !viewer || !viewer.scene || !viewer.scene.annotations) return null + try { + const coll = + (viewer.scene.annotations.flatten && viewer.scene.annotations.flatten()) || + (viewer.scene.annotations.descendants && viewer.scene.annotations.descendants()) || + viewer.scene.annotations.children || [] + for (const a of coll) { + if (!a) continue + if (a.uuid === uuid || (a.data && a.data.uuid === uuid)) return a + } + } catch (e) { + // fallback + try { + const coll2 = viewer.scene.annotations.children || [] + for (const a of coll2) if (a && (a.uuid === uuid || (a.data && a.data.uuid === uuid))) return a + } catch (e) {} + } + return null + } + function updateAnnotationsList() { + // Implementation for listing annotations + targetContainer.innerHTML = '' + const annotationsTree = _getJSTree() + if (!annotationsTree) return + let annotationsRoot = _getAnnotationsRoot() + if ( + !annotationsRoot || + !annotationsRoot.children || + annotationsRoot.children.length === 0 + ) { + const empty = document.createElement('div') + empty.className = 'annotation-empty' + empty.textContent = 'No saved positions yet' + targetContainer.appendChild(empty) + return + } + // Assign monotonic indices for any node UUIDs encountered and rename unlabeled nodes + try { + const items = (annotationsRoot.children || []).map((n) => ({ data: n.data || {}, node: n })) + for (const it of items) { + const uuid = (it.data && it.data.uuid) || null + if (!uuid) continue + const idx = _ensureIndexForUUID(uuid) + const text = (it.node && it.node.text) || '' + const shouldRename = !text || text.trim() === '' || text === 'Unnamed' || text === 'Annotation Title' + if (shouldRename && idx != null) { + const newName = `Annotation #${idx}` + _renameJSTreeNode(it.node.id, newName) + it.node.text = newName + // update live annotation object's title/name so in-scene label matches + try { + const live = _findLiveAnnotationByUUID(it.data.uuid) + if (live) { + if (typeof live.title !== 'undefined') live.title = newName + if (typeof live.name !== 'undefined') live.name = newName + if (live.data) live.data.title = newName + } + } catch (e) {} + } + } + } catch (e) { + // ignore failures + } + + // Build list entries for each annotation node + annotationsRoot.children.forEach((node) => { + + const row = document.createElement('div') + row.className = 'annotation-row' + + // header and body structure + const header = document.createElement('div') + header.className = 'annotation-header' + const body = document.createElement('div') + body.className = 'annotation-body' + + const label = document.createElement('span') + label.textContent = node.text || 'Unnamed' + label.className = 'annotation-label' + // attach uuid for editing + try { + const uuid = (node.data && node.data.uuid) || null + if (uuid) label.dataset.uuid = uuid + } catch (e) {} + // double-click label to edit + label.addEventListener('dblclick', (ev) => { + ev.stopPropagation() + const uuid = label.dataset.uuid + if (uuid) startInlineEditForUUID(uuid) + }) + + // triangular toggle (collapsed/open) + const toggle = document.createElement('span') + toggle.className = 'toggle-triangle' + toggle.title = 'Toggle details' + + // Jump button + + const jumpBtn = document.createElement('button') + jumpBtn.className = 'jump-btn' + jumpBtn.title = 'Move to this position' + jumpBtn.setAttribute('aria-label', 'Jump to saved view') + // Move the viewer to the saved camera position/pivot for this annotation (if present) + jumpBtn.onclick = () => { + const ann = node.data || {} + const camPos = vecToArray( + ann.cameraPosition || ann.camera_position || ann.cameraPos + ) + const annPos = vecToArray( + ann.position || ann.annotationPosition || ann.pos + ) + + if ( + camPos && + viewer && + viewer.scene && + viewer.scene.view && + typeof viewer.scene.view.setView === 'function' + ) { + const target = annPos || null + viewer.scene.view.setView(camPos, target, 1000) // animation duration in ms + + // Transient visual feedback: mark button as recently pressed so CSS can + // show the filled gradient and glow, then fade it back automatically. + try { + jumpBtn.setAttribute('aria-pressed', 'true') + jumpBtn.classList.add('recently-pressed') + // remove after short delay to allow CSS fade-out + window.setTimeout(() => { + try { + jumpBtn.classList.remove('recently-pressed') + jumpBtn.setAttribute('aria-pressed', 'false') + } catch (e) {} + }, 200) + } catch (e) {} + } + } + + // Delete button + const delBtn = document.createElement('button') + delBtn.className = 'del-btn' + delBtn.textContent = '✖' + delBtn.title = 'Delete saved position' + // Delete an annotation: remove annotation from the renderer scene (live annotation), then remove the tree node + delBtn.onclick = () => { + const annData = node.data || {} + const uuid = annData.uuid + + if (uuid && viewer && viewer.scene && viewer.scene.annotations) { + // Find the live annotation instance by UUID (Universally Unique Identifier) + let candidates = [] + try { + candidates = + (viewer.scene.annotations.flatten && + viewer.scene.annotations.flatten()) || + (viewer.scene.annotations.descendants && + viewer.scene.annotations.descendants()) || + viewer.scene.annotations.children || + [] + } catch (e) { + candidates = viewer.scene.annotations.children || [] + } + + let live = null + if (Array.isArray(candidates)) { + for (let a of candidates) { + if (!a) continue + if (a.uuid === uuid || (a.data && a.data.uuid === uuid)) { + live = a + break + } + } + } + + if (live) { + if (typeof live.removeHandles === 'function') + live.removeHandles(viewer) + if (typeof viewer.scene.removeAnnotation === 'function') { + viewer.scene.removeAnnotation(live) + } else if ( + viewer.scene.annotations && + typeof viewer.scene.annotations.remove === 'function' + ) { + viewer.scene.annotations.remove(live) + } + } + } + + // remove from sidebar/tree + try { + $('#jstree_scene').jstree('delete_node', node.id) + } catch (e) { + try { + $.jstree.reference(node.id).delete_node(node.id) + } catch (_) {} + } + updateAnnotationsList() + } + + // Append elements into header: toggle, label, then controls (jump/delete) + const controls = document.createElement('div') + controls.className = 'controls' + controls.appendChild(jumpBtn) + controls.appendChild(delBtn) + + header.appendChild(toggle) + header.appendChild(label) + header.appendChild(controls) + row.appendChild(header) + + // Wire toggle to show/hide details + try { + toggle.addEventListener('click', (ev) => { + ev.stopPropagation() + row.classList.toggle('open') + }) + } catch (e) {} + // Description view,supports dblclick edit + try { + const ann = node.data || {} + const descText = _getDescriptionForUUID(ann.uuid, ann) + const display = descText.trim() ? descText : 'Annotation Description' + const desc = document.createElement('div') + desc.className = 'annotation-desc' + desc.textContent = display + desc.dataset.uuid = (ann && ann.uuid) || '' + desc.dataset.raw = descText.trim() + desc.addEventListener('dblclick', (ev) => { + ev.stopPropagation() + const u = desc.dataset.uuid + if (u) startInlineDescriptionEditForUUID(u) + }) + body.appendChild(desc) + } catch (e) { + // ignore description rendering errors + } + + // show saved camera and point info + try { + const ann = node.data || {} + + const cam = vecToArray( + ann.cameraPosition || ann.camera_position || ann.cameraPos + ) + + // Start with serialized position, then prefer the live object's current position + let pointPos = vecToArray( + ann.position || ann.annotationPosition || ann.pos + ) + try { + const live = _findLiveAnnotationByUUID(ann.uuid) + if (live) { + const livePos = vecToArray(live.position || (live.data && live.data.position)) + if (livePos) pointPos = livePos + } + } catch (e) {} + + // Hide Potree's default placeholder coordinates until the annotation is actually placed + function approxEqual(a, b, eps = 1e-3) { + if (!a || !b || a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (Math.abs(Number(a[i]) - Number(b[i])) > eps) return false + return true + } + const PLACEHOLDER_POS = [589748.27, 231444.54, 753.675] + if (pointPos && approxEqual(pointPos, PLACEHOLDER_POS)) pointPos = null + + if (cam || pointPos) { + const info = document.createElement('div') + info.className = 'annotation-info' + const fmt = (v) => (v ? v.map((c) => Number(c).toFixed(3)).join(', ') : '—') + info.innerHTML = `Camera coordinates: ${fmt(cam)}

Saved Point coordinates: ${fmt(pointPos)}` + body.appendChild(info) + + // Enable or disable jump button depending on whether we have a saved point position + try { + if (pointPos && Array.isArray(pointPos) && pointPos.length === 3) { + jumpBtn.disabled = false + jumpBtn.classList.remove('jump-disabled') + } else { + jumpBtn.disabled = true + jumpBtn.classList.add('jump-disabled') + } + } catch (e) {} + } + } catch (e) { + // ignore formatting errors + } + // Append body last so it's hidden until opened + row.appendChild(body) + targetContainer.appendChild(row) + }) + } + + // Start inline editing for a given annotation UUID (opens an input in the sidebar) + function startInlineEditForUUID(uuid) { + if (!uuid) return + const labelEl = targetContainer.querySelector(`.annotation-label[data-uuid="${uuid}"]`) + if (!labelEl) return + const oldText = labelEl.textContent || '' + const input = document.createElement('input') + input.type = 'text' + input.value = oldText + input.className = 'annotation-edit-input' + labelEl.replaceWith(input) + input.focus() + input.select() + + function finish(commit) { + const newText = commit ? input.value.trim() || oldText : oldText + // restore label + const newLabel = document.createElement('span') + newLabel.className = 'annotation-label' + newLabel.textContent = newText + newLabel.dataset.uuid = uuid + newLabel.addEventListener('dblclick', (ev) => { + ev.stopPropagation() + startInlineEditForUUID(uuid) + }) + + try { + if (input.isConnected) { + input.replaceWith(newLabel) + } else { + const existing = targetContainer.querySelector(`.annotation-label[data-uuid="${uuid}"]`) + if (existing) existing.textContent = newText + } + } catch (e) { + // ignore DOM replacement errors and try to update label if present + try { + const existing = targetContainer.querySelector(`.annotation-label[data-uuid="${uuid}"]`) + if (existing) existing.textContent = newText + } catch (_) {} + } + // commit to jsTree and live annotation + _commitEditedName(uuid, newText) + } + + input.addEventListener('blur', () => finish(true)) + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + finish(true) + } else if (e.key === 'Escape') { + finish(false) + } + }) + } + + // Start inline editing for an annotation description (multiline) + function startInlineDescriptionEditForUUID(uuid) { + if (!uuid) return + const descEl = targetContainer.querySelector(`.annotation-desc[data-uuid="${uuid}"]`) + if (!descEl) return + let oldText = descEl.dataset.raw || descEl.textContent || '' + try { + const live = _findLiveAnnotationByUUID(uuid) + if (live) { + if (live.data && typeof live.data.description !== 'undefined') { + oldText = String(live.data.description || '') + } else if (typeof live.description !== 'undefined') { + oldText = String(live.description || '') + } + } + } catch (e) {} + + const ta = document.createElement('textarea') + ta.className = 'annotation-edit-textarea' + // Prefill textarea with existing description + ta.value = oldText + ta.rows = 1 + descEl.replaceWith(ta) + ta.focus() + try { + ta.select() + } catch (e) {} + + function finish(commit) { + const newText = commit ? (ta.value.trim() || '') : oldText + const displayText = newText ? newText : 'Annotation Description' + const newDesc = document.createElement('div') + newDesc.className = 'annotation-desc' + newDesc.textContent = displayText + newDesc.dataset.uuid = uuid + newDesc.dataset.raw = newText + newDesc.addEventListener('dblclick', (ev) => { + ev.stopPropagation() + startInlineDescriptionEditForUUID(uuid) + }) + + try { + if (ta.isConnected) { + ta.replaceWith(newDesc) + } else { + const existing = targetContainer.querySelector(`.annotation-desc[data-uuid="${uuid}"]`) + if (existing) existing.textContent = displayText + } + } catch (e) { + try { + const existing = targetContainer.querySelector(`.annotation-desc[data-uuid="${uuid}"]`) + if (existing) existing.textContent = displayText + } catch (_) {} + } + + _commitEditedDescription(uuid, newText) + } + + ta.addEventListener('blur', () => finish(true)) + ta.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + // Shift+Enter: insert newline (allow default). Enter: finish and save. + if (e.shiftKey) { + // allow default -> newline inserted + return + } + e.preventDefault() + finish(true) + } else if (e.key === 'Escape') { + finish(false) + } + }) + } + + function _commitEditedDescription(uuid, description) { + if (!uuid) return + try { + const tree = _getJSTree() + if (tree) { + const annotationsRoot = _getAnnotationsRoot() + const node = _findNodeInAnnotationsRootByUUID(annotationsRoot, uuid) + if (node) { + node.data = node.data || {} + node.data.description = description + + try { + const jsTreeInst = _getJSTreeInstance() + if (jsTreeInst) { + const jsNode = jsTreeInst.get_node(node.id) + if (jsNode) { + jsNode.original = jsNode.original || {} + jsNode.original.data = jsNode.original.data || {} + jsNode.original.data.description = description + } + } + } catch (e) { + // If we couldn't access the internal node, continue anyway + } + } + } + } catch (e) {} + + try { + const live = _findLiveAnnotationByUUID(uuid) + if (live) { + live.data = live.data || {} + live.data.description = description + if (typeof live.description !== 'undefined') live.description = description + if (typeof live.desc !== 'undefined') live.desc = description + } + } catch (e) {} + + setTimeout(updateAnnotationsList, 0) + } + + function _commitEditedName(uuid, name) { + if (!uuid) return + // update jsTree node text + try { + const tree = $('#jstree_scene').jstree && $('#jstree_scene').jstree() + if (tree) { + const annotationsRoot = tree.get_json('annotations') + if (annotationsRoot && annotationsRoot.children) { + const node = annotationsRoot.children.find((c) => (c.data && c.data.uuid) === uuid) + if (node) { + _renameJSTreeNode(node.id, name) + node.text = name + } + } + } + } catch (e) {} + + // update live annotation + try { + const live = _findLiveAnnotationByUUID(uuid) + if (live) { + if (typeof live.title !== 'undefined') live.title = name + if (typeof live.name !== 'undefined') live.name = name + if (live.data) live.data.title = name + } + } catch (e) {} + + // refresh sidebar to reflect changes + setTimeout(updateAnnotationsList, 0) + } + + // Add Annotation UI (mimics Potree toolbar/pinpoint-button logic) + function createAddButton() { + const btn = document.createElement('button') + btn.className = 'annotation-add-button' + btn.setAttribute('aria-label', 'Add a new saved location') + btn.innerHTML = `Add a location` + btn.onclick = () => { + // Show this panel, if collapsed + const menu = document.getElementById('potree_menu') + const annotationHeader = document.getElementById( + 'menu_camera_annotations' + ) + if (annotationHeader && annotationHeader.nextElementSibling) { + $(annotationHeader.nextElementSibling).slideDown() + } + // Capture current camera view (position) at the moment the user clicks Add + let camPos = null + try { + if (viewer && viewer.scene && viewer.scene.view) { + camPos = + viewer.scene.view.position && + typeof viewer.scene.view.position.toArray === 'function' + ? viewer.scene.view.position.toArray() + : viewer.scene.view.position + ? [ + viewer.scene.view.position.x, + viewer.scene.view.position.y, + viewer.scene.view.position.z + ] + : null + } + } catch (e) { + console.warn('Could not read current view for annotation', e) + } + + // Start Potree annotation insertion + let annotation = viewer.annotationTool.startInsertion() + + // Wait for the actual placement (left click) before updating the sidebar. + try { + const dom = viewer && viewer.renderer && viewer.renderer.domElement + if (dom) { + const onMouseUp = (ev) => { + try { + if (ev.button === 0) { + // left click = placement finished + setTimeout(() => updateAnnotationsList(), 50) + dom.removeEventListener('mouseup', onMouseUp, true) + } + } catch (e) {} + } + dom.addEventListener('mouseup', onMouseUp, true) + } + } catch (e) { + // ignore + } + + // Wait for annotation creation, then select in jsTree + setTimeout(() => { + let tree = $('#jstree_scene').jstree && $('#jstree_scene').jstree() + if (!tree || !annotation) return + // Persist captured camera info on the annotation object so it will be available in the jsTree node data + try { + if (camPos) annotation.cameraPosition = camPos + } catch (e) { + console.warn('Could not attach camera info to annotation', e) + } + updateAnnotationsList() + }, 200) // A short delay for Potree/jsTree to update + } + targetContainer.parentElement.insertBefore(btn, targetContainer) + } + + createAddButton() + updateAnnotationsList() + initAnnotationPersistence(viewer) + + // Listen to Potree's annotation events to auto-refresh the list + if (viewer.scene && viewer.scene.annotations) { + viewer.scene.annotations.addEventListener( + 'annotation_added', + updateAnnotationsList + ) + viewer.scene.annotations.addEventListener( + 'annotation_removed', + updateAnnotationsList + ) + viewer.scene.annotations.addEventListener( + 'annotation_changed', + updateAnnotationsList + ) + } +} diff --git a/src/potreeViewer.js b/src/potreeViewer.js index 9ccd25e..24fb4c9 100644 --- a/src/potreeViewer.js +++ b/src/potreeViewer.js @@ -1,6 +1,6 @@ +import { initAnnotationsPanel } from './AnnotationControl/AnnotationPanel.js' import { initElevationControls } from './ElevationControl/elevationControl.js' import { initMeasurementsPanel } from './MeasurementControl/measurementsPanel.js' -import { initAnnotationPersistence } from './Annotations/persistence.js' import { ecef } from './config.js' /** @@ -38,11 +38,7 @@ export async function createPotreeViewer(containerId, pointcloudUrl, settings) { initElevationControls(viewer) initMeasurementsPanel(viewer) - initAnnotationPersistence(viewer, { - jsonUrl: '/annotations/annotations.json', - autosave: true, - pointcloudUrl - }) + initAnnotationsPanel(viewer) }) const e = await Potree.loadPointCloud(pointcloudUrl)