Skip to content

Commit

Permalink
feat(#4): ✨ Made a function for storing annotations
Browse files Browse the repository at this point in the history
The data is sent by API to the small express server and stored in a json. the objects in the json will be fetched when opening the app and displayed
  • Loading branch information
gautegf committed Oct 20, 2025
1 parent 89d65f4 commit 77b328c
Show file tree
Hide file tree
Showing 3 changed files with 252 additions and 0 deletions.
20 changes: 20 additions & 0 deletions public/annotations/annotations.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"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": []
}
]
}
}
226 changes: 226 additions & 0 deletions src/Annotations/persistence.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
export function initAnnotationPersistence(viewer, options = {}) {
const jsonUrl = options.jsonUrl || '/annotations/annotations.json'
const defaultSaveUrl = (() => {
try {
if (
typeof window !== 'undefined' &&
window.location &&
window.location.port === '5173'
) {
// Dev fallback: post directly to API server to avoid proxy issues
return 'http://localhost:5174/api/annotations'
}
} 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}`

function defaultGetFolder(ann) {
return ann?.userData?.folder || 'General'
}
function defaultSetFolder(ann, folder) {
if (!ann.userData) ann.userData = {}
ann.userData.folder = folder || 'General'
}

function posToArray(p) {
if (!p) return null
if (Array.isArray(p)) return p
if (typeof p.x === 'number') return [p.x, p.y, p.z]
if (typeof p.toArray === 'function') return p.toArray()
return null
}

function serializeNode(ann) {
return {
title: ann.title || '',
description: ann.description || '',
position: posToArray(
ann.position || ann._position || ann?.marker?.position
),
cameraPosition: posToArray(ann.cameraPosition),
cameraTarget: posToArray(ann.cameraTarget),
children: (ann.children || []).map(serializeNode)
}
}

function serializeGrouped() {
const root = viewer.scene.annotations
const folders = {}
for (const ann of root.children || []) {
const f = folderResolver(ann) || 'General'
if (!folders[f]) folders[f] = []
folders[f].push(serializeNode(ann))
}
return { version: 1, pointcloudKey: key, folders }
}

async function loadFromJson() {
try {
const res = await fetch(jsonUrl, { cache: 'no-store' })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const data = await res.json()
applyJsonToScene(data)
// snapshot to localStorage for quick restore
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
} catch {}
} catch (err) {
// fallback to local snapshot
try {
const snap = localStorage.getItem(STORAGE_KEY)
if (snap) applyJsonToScene(JSON.parse(snap))
} catch {}
}
}

function applyJsonToScene(json) {
if (!json || !json.folders) return
const root = viewer.scene.annotations
for (const ch of [...(root.children || [])]) root.remove(ch)

const folders = json.folders || {}
for (const folderName of Object.keys(folders)) {
const list = folders[folderName] || []
for (const item of list) addAnnotationRec(root, item, folderName)
}
}

function addAnnotationRec(parent, item, folderName) {
const pos = item.position || [0, 0, 0]
const ann = new Potree.Annotation({
position: new THREE.Vector3(pos[0], pos[1], pos[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 (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'
}
}

function getAnnotationsJSON() {
return serializeGrouped()
}

function snapshotToLocalStorage() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(serializeGrouped()))
} catch {}
}

async function saveToServer(payload) {
const data = payload || serializeGrouped()
try {
const headers = { 'Content-Type': 'application/json' }
const res = await fetch(saveUrl, {
method: 'POST',
headers,
body: JSON.stringify(data)
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return await res.json()
} catch (err) {
throw err
}
}

// Autosnapshot after add/remove
const root = viewer.scene.annotations
let snapshotTimer = null
const debouncedSnapshot = () => {
if (snapshotTimer) cancelAnimationFrame(snapshotTimer)
snapshotTimer = requestAnimationFrame(async () => {
snapshotToLocalStorage()
if (autosave) {
try {
await saveToServer()
} 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
const _protoAdd = Potree.Annotation.prototype.add
const _protoRemove = Potree.Annotation.prototype.remove
Potree.Annotation.prototype.add = function (...args) {
const child = args[0]
const r = _protoAdd.apply(this, args)
if (child) watchAnnotationDeep(child)
debouncedSnapshot()
return r
}
Potree.Annotation.prototype.remove = function (...args) {
const r = _protoRemove.apply(this, args)
debouncedSnapshot()
return r
}
}

function watchAnnotationDeep(ann) {
if (!ann || ann._persistWatched) return
ann._persistWatched = true
watchAnnotation(ann)
;(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 watchAnnotation(ann) {
defineWatchedProp(ann, 'title')
defineWatchedProp(ann, 'description')
defineWatchedProp(ann, 'cameraPosition')
defineWatchedProp(ann, 'cameraTarget')
}

loadFromJson()
watchAnnotationDeep(root)

return {
getAnnotationsJSON,
snapshotToLocalStorage,
loadFromJson,
serializeGrouped,
saveToServer
}
}
6 changes: 6 additions & 0 deletions src/potreeViewer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { initElevationControls } from './ElevationControl/elevationControl.js'
import { initMeasurementsPanel } from './MeasurementControl/measurementsPanel.js'
import { initAnnotationPersistence } from './Annotations/persistence.js'
import { ecef } from './config.js'

/**
Expand Down Expand Up @@ -37,6 +38,11 @@ export async function createPotreeViewer(containerId, pointcloudUrl, settings) {

initElevationControls(viewer)
initMeasurementsPanel(viewer)
initAnnotationPersistence(viewer, {
jsonUrl: '/annotations/annotations.json',
autosave: true,
pointcloudUrl
})
})

const e = await Potree.loadPointCloud(pointcloudUrl)
Expand Down

0 comments on commit 77b328c

Please sign in to comment.