aframe icon indicating copy to clipboard operation
aframe copied to clipboard

How to effeciently Store/Load the entire structure of A-Scene into/from the DB?

Open hazho opened this issue 8 months ago • 3 comments

I have tried my best to store and load the same structure that I visually see into the DB, but the component's schema keys and values are not being included, also, even if I store the HTML content into the DB, when loaded, most of the entities' component values lost, the following are simplified examples tried with:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>A-Frame Scene Saver</title>
    <script src="https://aframe.io/releases/1.7.1/aframe.min.js"></script>
</head>
<body>
		<div class="html">
			<h2>Scene Management</h2>
			<button onclick="createScene()">Add a Scene</button>
			<select id="sceneSelect">
					<option value="">Select a saved scene...</option>
			</select>
			<button onclick="loadScene()">Load Scene</button>
			<button onclick="saveScene()">Save Scene</button>
			<input type="text" id="sceneName" placeholder="Enter scene name to save">
		</div>
    <div id="sceneContainer"></div>

    <script>
        document.addEventListener("DOMContentLoaded", populateScenes);

        function createScene() {
            const sceneContainer = document.getElementById("sceneContainer");
            if (!document.querySelector("a-scene")) {
                sceneContainer.innerHTML = `
                    <a-scene>
                        <a-entity camera look-controls position="0 1.6 0"></a-entity>
                    </a-scene>
                `;
            }
        }

        function populateScenes() {
            const sceneSelect = document.getElementById("sceneSelect");
            const scenes = JSON.parse(localStorage.getItem("scenes") || "{}");

            sceneSelect.innerHTML = '<option value="">Select a saved scene...</option>';
            for (let name in scenes) {
                let option = document.createElement("option");
                option.value = name;
                option.textContent = name;
                sceneSelect.appendChild(option);
            }
        }

        function saveScene() {
            const sceneElement = document.querySelector("a-scene");
            const sceneName = document.getElementById("sceneName").value.trim();
            if (!sceneElement || !sceneName) return alert("No scene or name provided!");

            let scenes = JSON.parse(localStorage.getItem("scenes") || "{}");
            scenes[sceneName] = sceneElement.innerHTML;
            localStorage.setItem("scenes", JSON.stringify(scenes));

            populateScenes();
            alert("Scene saved!");
        }

        function loadScene() {
            const sceneName = document.getElementById("sceneSelect").value;
            if (!sceneName) return alert("Select a scene!");

            const scenes = JSON.parse(localStorage.getItem("scenes") || "{}");
            const sceneContent = scenes[sceneName];

            if (sceneContent) {
                const sceneContainer = document.getElementById("sceneContainer");
                sceneContainer.innerHTML = `<a-scene>${sceneContent}</a-scene>`;
            }
        }
    </script>
    <style>.html{z-index:999999999999;position:fixed;left:40%}</style>
</body>
</html>

and

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>A-Frame Scene Saver</title>
  <script src="https://aframe.io/releases/1.7.1/aframe.min.js"></script>
  <style>
    #controls {
      position: fixed;
      top: 10px;
      left: 10px;
      z-index: 1000;
      background: white;
      padding: 10px;
      border-radius: 5px;
    }
  </style>
</head>
<body>

<div id="controls">
  <label for="sceneSelector">Choose Scene:</label>
  <select id="sceneSelector"></select>
  <button onclick="saveCurrentScene()">Save Current Scene</button>
</div>

<a-scene id="mainScene">
  <!-- Default scene -->
  <a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9"></a-box>
  <a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E"></a-sphere>
  <a-cylinder position="1 0.75 -3" radius="0.5" height="1.5" color="#FFC65D"></a-cylinder>
  <a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4" color="#7BC8A4"></a-plane>
  <a-sky color="#ECECEC"></a-sky>
</a-scene>

<script>
  const DB_NAME = "AFRAME_SCENE_DB";
  const STORE_NAME = "scenes";

  let db;

  // Open IndexedDB
  function openDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(DB_NAME, 1);
      request.onupgradeneeded = function (event) {
        db = event.target.result;
        if (!db.objectStoreNames.contains(STORE_NAME)) {
          db.createObjectStore(STORE_NAME, { keyPath: "name" });
        }
      };
      request.onsuccess = function (event) {
        db = event.target.result;
        resolve(db);
      };
      request.onerror = function (event) {
        reject("Error opening IndexedDB");
      };
    });
  }

  // Save scene to IndexedDB
  function saveScene(name, sceneHTML) {
    const transaction = db.transaction([STORE_NAME], "readwrite");
    const store = transaction.objectStore(STORE_NAME);
    store.put({ name, sceneHTML });
  }

  // Load all scenes from IndexedDB
  function loadScenes() {
    const selector = document.getElementById("sceneSelector");
    const transaction = db.transaction([STORE_NAME], "readonly");
    const store = transaction.objectStore(STORE_NAME);
    const getAllRequest = store.getAll();

    getAllRequest.onsuccess = function () {
      const scenes = getAllRequest.result;
      selector.innerHTML = ""; // Clear existing options

      // Add default option
      let defaultOption = document.createElement("option");
      defaultOption.value = "default";
      defaultOption.text = "Default Scene";
      selector.appendChild(defaultOption);

      scenes.forEach((scene) => {
        let option = document.createElement("option");
        option.value = scene.name;
        option.text = scene.name;
        selector.appendChild(option);
      });
    };
  }

  // Load a specific scene by name
  function loadSceneByName(name) {
		const mainScene = document.getElementById("mainScene");

		if (name === "default") {
			const defaultHTML = getDefaultSceneHTML();
			replaceSceneHTML(mainScene, defaultHTML);
			return;
		}

		const transaction = db.transaction([STORE_NAME], "readonly");
		const store = transaction.objectStore(STORE_NAME);
		const getRequest = store.get(name);

		getRequest.onsuccess = function () {
			const result = getRequest.result;
			if (result) {
				replaceSceneHTML(mainScene, result.sceneHTML);
			}
		};
	}
  // Get default scene HTML as string (only children)
  function getDefaultSceneHTML() {
    return `
      <a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9"></a-box>
      <a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E"></a-sphere>
      <a-cylinder position="1 0.75 -3" radius="0.5" height="1.5" color="#FFC65D"></a-cylinder>
      <a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4" color="#7BC8A4"></a-plane>
      <a-sky color="#ECECEC"></a-sky>
    `;
  }

  // Event listener for select
  document.getElementById("sceneSelector").addEventListener("change", function (e) {
    loadSceneByName(e.target.value);
  });

  // Save current scene
  window.saveCurrentScene = function () {
    const sceneName = prompt("Enter scene name:");
    if (!sceneName) return;

    const mainScene = document.getElementById("mainScene");

    // Only save child elements, not the <a-scene> itself
    const sceneHTML = Array.from(mainScene.children)
      .map(el => el.outerHTML)
      .join("");

    saveScene(sceneName, sceneHTML);
    loadScenes(); // Refresh selector
  };

  // Initialize
  openDB().then(() => {
    loadScenes();
  });
	function replaceSceneHTML(sceneEl, htmlString) {
		// Clear existing scene content
		while (sceneEl.firstChild) {
			sceneEl.removeChild(sceneEl.firstChild);
		}

		// Convert HTML string to DOM elements
		const div = document.createElement('div');
		div.innerHTML = htmlString.trim();

		// Append each child to <a-scene>
		Array.from(div.children).forEach(child => {
			sceneEl.appendChild(child);
		});
	}
</script>

</body>
</html>

hazho avatar May 06 '25 13:05 hazho

Coincidentally this topic came up in the WebXR Discord a couple of weeks ago (link). I would like to caution that there is no perfect solution. Components and systems can hold state that isn't reflected in their properties, and some have side-effects when initializing.

That said, you can look at the following:

  • The built-in sceneEl.flushToDOM(true) lets A-Frame write out component properties and values.
  • aframe-editor implements additional logic to handle various edge cases: https://github.com/c-frame/aframe-editor/blob/508dd788adb211c91a78800fec4a5bc528e035de/src/lib/entity.js#L169-L428

mrxz avatar May 06 '25 18:05 mrxz

@mrxz thanks for your answer, right before few hours I tried the first suggestion, but still did not work, but I think I am coming up with a concrete solution, similar to aframe-editor, but a bit minimized, I will keep this open, it may help anyone else until I share my solution or someone else does it.

hazho avatar May 06 '25 18:05 hazho

I would make these general suggestions to you to improve your JS coding:

  1. Never put event listners inside the html markup, always attach them using JS.
  2. Drop using innerHTML it's just BAD practice, and instead use DOM-API to create nodes using JS.
    • "Good" coding and "Lazy" coding do not mix well, always go for the good coding.
  3. If you want to save/restore a DOM tree properly then you need to parse it, and convert all attributes you need to include, to/from your JSON-data. If that JSON-data is too big to store in localStorage you might need to compress it first using gzip etc..

The above will help you to avoid CSP problems in the long run, which you will need to secure your code from unauthorized tampering.

TriMoon avatar May 24 '25 16:05 TriMoon