From 77b328c6791907f614327442e8137d78c2a86875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gaute=20Fl=C3=A6gstad?= Date: Mon, 20 Oct 2025 10:54:16 +0200 Subject: [PATCH] feat(#4): :sparkles: Made a function for storing annotations 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 --- public/annotations/annotations.json | 20 +++ src/Annotations/persistence.js | 226 ++++++++++++++++++++++++++++ src/potreeViewer.js | 6 + 3 files changed, 252 insertions(+) create mode 100644 public/annotations/annotations.json create mode 100644 src/Annotations/persistence.js diff --git a/public/annotations/annotations.json b/public/annotations/annotations.json new file mode 100644 index 0000000..62e1690 --- /dev/null +++ b/public/annotations/annotations.json @@ -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": [] + } + ] + } +} \ No newline at end of file diff --git a/src/Annotations/persistence.js b/src/Annotations/persistence.js new file mode 100644 index 0000000..3713ea3 --- /dev/null +++ b/src/Annotations/persistence.js @@ -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 + } +} diff --git a/src/potreeViewer.js b/src/potreeViewer.js index fb709f0..9ccd25e 100644 --- a/src/potreeViewer.js +++ b/src/potreeViewer.js @@ -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' /** @@ -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)