Skip to content

Commit

Permalink
feat(#4): ✨ update annotation persitence to work with the UI
Browse files Browse the repository at this point in the history
  • Loading branch information
gautegf committed Oct 20, 2025
1 parent c98a21a commit b626233
Show file tree
Hide file tree
Showing 2 changed files with 125 additions and 69 deletions.
15 changes: 0 additions & 15 deletions public/annotations/annotations.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,5 @@
{
"version": 1,
"pointcloudKey": "_pointclouds_data_converted_metadata_json",
"folders": {
"General": [
{
"title": "Test1",
"description": "Test1 description",
"position": [
1183815.569987793,
63692.700009765635,
6243873.499987793
],
"cameraPosition": null,
"cameraTarget": null,
"children": []
}
]
}
}
179 changes: 125 additions & 54 deletions src/Annotations/persistence.js → src/AnnotationControl/persistence.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ export function initAnnotationPersistence(viewer, options = {}) {
} catch {}
return '/api/annotations'
})()
const saveUrl = options.saveUrl || defaultSaveUrl
const autosave = Boolean(options.autosave)
const folderResolver = options.getAnnotationFolder || defaultGetFolder
const folderSetter = options.setAnnotationFolder || defaultSetFolder
const key = deriveKey(options.pointcloudUrl || options.pointcloudPath)
const STORAGE_KEY = `molloy_annotations_snapshot_${key}`
const saveUrl = defaultSaveUrl
const autosave = true
const folderResolver = defaultGetFolder
const folderSetter = defaultSetFolder
const STORAGE_KEY = `molloy_annotations_snapshot`
const _watchedAnnotations = new Set()
const _titleDescCache = new WeakMap()

function defaultGetFolder(ann) {
return ann?.userData?.folder || 'General'
Expand All @@ -40,12 +41,8 @@ export function initAnnotationPersistence(viewer, options = {}) {
return {
title: ann.title || '',
description: ann.description || '',
position: posToArray(
ann.position || ann._position || ann?.marker?.position
),
position: posToArray((ann?.marker && ann?.marker?.position) ? ann.marker.position : (ann.position || ann._position)),
cameraPosition: posToArray(ann.cameraPosition),
cameraTarget: posToArray(ann.cameraTarget),
children: (ann.children || []).map(serializeNode)
}
}

Expand All @@ -57,7 +54,7 @@ export function initAnnotationPersistence(viewer, options = {}) {
if (!folders[f]) folders[f] = []
folders[f].push(serializeNode(ann))
}
return { version: 1, pointcloudKey: key, folders }
return { version: 1, folders }
}

async function loadFromJson() {
Expand All @@ -80,6 +77,7 @@ export function initAnnotationPersistence(viewer, options = {}) {
}

function applyJsonToScene(json) {
console.log('Loading annotations from JSON...', json)
if (!json || !json.folders) return
const root = viewer.scene.annotations
for (const ch of [...(root.children || [])]) root.remove(ch)
Expand All @@ -92,34 +90,29 @@ export function initAnnotationPersistence(viewer, options = {}) {
}

function addAnnotationRec(parent, item, folderName) {
const pos = item.position || [0, 0, 0]
const posArr = Array.isArray(item.position) ? item.position : [0, 0, 0]
const ann = new Potree.Annotation({
position: new THREE.Vector3(pos[0], pos[1], pos[2]),
title: item.title || ''
position: new THREE.Vector3(posArr[0], posArr[1], posArr[2]),
title: item.title || '',
})
ann.description = item.description || ''
if (item.cameraPosition)
ann.cameraPosition = new THREE.Vector3(...item.cameraPosition)
if (item.cameraTarget)
ann.cameraTarget = new THREE.Vector3(...item.cameraTarget)
if (Array.isArray(item.cameraPosition)) ann.cameraPosition = new THREE.Vector3(...item.cameraPosition)

if (folderSetter) folderSetter(ann, folderName)
parent.add(ann)
watchAnnotationDeep(ann)
for (const child of item.children || [])
addAnnotationRec(ann, child, folderName)
}

function deriveKey(urlLike) {
try {
const u = String(urlLike || '').trim()
if (!u) return 'default'
return u
.replace(/[^a-z0-9]+/gi, '_')
.toLowerCase()
.slice(-80)
} catch {
return 'default'
}
watchAnnotationDeep(ann)
// If Potree attaches a marker asynchronously, hook into it on the next frame
requestAnimationFrame(() => {
try {
if (ann.marker && ann.marker.position) {
if (typeof ann.marker.position.onChange === 'function') {
ann.marker.position.onChange(() => debouncedSnapshot())
}
}
} catch {}
})
for (const child of item.children || []) addAnnotationRec(ann, child, folderName)
}

function getAnnotationsJSON() {
Expand Down Expand Up @@ -163,6 +156,15 @@ export function initAnnotationPersistence(viewer, options = {}) {
})
}

// Expose minimal handle for the poller to access closure values safely
try {
window.__annPersist = {
watched: _watchedAnnotations,
cache: _titleDescCache,
debouncedSnapshot
}
} catch {}

// Patch Potree.Annotation.prototype to watch all additions/removals in the tree
if (Potree?.Annotation && !Potree.Annotation.prototype._persistPatched) {
Potree.Annotation.prototype._persistPatched = true
Expand All @@ -172,7 +174,8 @@ export function initAnnotationPersistence(viewer, options = {}) {
const child = args[0]
const r = _protoAdd.apply(this, args)
if (child) watchAnnotationDeep(child)
debouncedSnapshot()
// Delay a bit so position can settle after placement
requestAnimationFrame(() => requestAnimationFrame(debouncedSnapshot))
return r
}
Potree.Annotation.prototype.remove = function (...args) {
Expand All @@ -182,39 +185,77 @@ export function initAnnotationPersistence(viewer, options = {}) {
}
}

// Snapshot whenever an annotation changes (e.g., placed/moved/edited)
function onAnnotationChanged() {
debouncedSnapshot()
}
try {
root.addEventListener('annotation_changed', onAnnotationChanged)
root.addEventListener('annotation_added', () => {
requestAnimationFrame(() => requestAnimationFrame(debouncedSnapshot))
})
root.addEventListener('annotation_removed', onAnnotationChanged)
} catch (_) {}

function watchAnnotationDeep(ann) {
if (!ann || ann._persistWatched) return
ann._persistWatched = true
watchAnnotation(ann)
// Watch vector changes to capture post-placement updates reliably
watchVectorChanges(ann.position)
if (ann.marker && ann.marker.position) watchVectorChanges(ann.marker.position)
_watchedAnnotations.add(ann)
// cache current title/desc
try { _titleDescCache.set(ann, { t: ann.title, d: ann.description }) } catch {}
;(ann.children || []).forEach(watchAnnotationDeep)
}

function defineWatchedProp(obj, key) {
try {
let _val = obj[key]
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
return _val
},
set(v) {
_val = v
debouncedSnapshot()
}
})
} catch {}
function patchSetMethod(obj, methodName) {
if (!obj || typeof obj[methodName] !== 'function') return
const original = obj[methodName]
if (original._persistPatched) return
const patched = function (...args) {
const r = original.apply(this, args)
debouncedSnapshot()
return r
}
patched._persistPatched = true
obj[methodName] = patched
}

function watchAnnotation(ann) {
defineWatchedProp(ann, 'title')
defineWatchedProp(ann, 'description')
defineWatchedProp(ann, 'cameraPosition')
defineWatchedProp(ann, 'cameraTarget')
patchSetMethod(ann, 'setTitle')
patchSetMethod(ann, 'setDescription')
if (ann.cameraPosition) watchVectorChanges(ann.cameraPosition)
}

function watchVectorChanges(vec) {
if (!vec) return
try {
if (typeof vec.onChange === 'function' && !vec._persistOnChangeHooked) {
vec._persistOnChangeHooked = true
vec.onChange(() => debouncedSnapshot())
} else if (!vec._persistPollHooked) {
// Fallback: brief polling window to detect early changes when onChange is unavailable
vec._persistPollHooked = true
let frames = 0
let last = `${vec.x},${vec.y},${vec.z}`
const poll = () => {
const cur = `${vec.x},${vec.y},${vec.z}`
if (cur !== last) {
last = cur
debouncedSnapshot()
}
if (frames++ < 90) requestAnimationFrame(poll) // ~1.5s at 60fps
}
requestAnimationFrame(poll)
}
} catch {}
}

loadFromJson()
watchAnnotationDeep(root)
startTitleDescPoller()

return {
getAnnotationsJSON,
Expand All @@ -224,3 +265,33 @@ export function initAnnotationPersistence(viewer, options = {}) {
saveToServer
}
}

function startTitleDescPoller() {
// Use a module-level flag to avoid multiple pollers
if (startTitleDescPoller._started) return
startTitleDescPoller._started = true
try {
const tick = () => {
try {
const h = window.__annPersist
if (h && h.watched && h.cache) {
let changed = false
h.watched.forEach((ann) => {
const prev = h.cache.get(ann) || { t: undefined, d: undefined }
const curT = ann.title
const curD = ann.description
if (prev.t !== curT || prev.d !== curD) {
h.cache.set(ann, { t: curT, d: curD })
changed = true
}
})
if (changed && typeof h.debouncedSnapshot === 'function') {
h.debouncedSnapshot()
}
}
} catch {}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
} catch {}
}

0 comments on commit b626233

Please sign in to comment.