Repository: tqec/tqec

Issue:
SketchUp Imports
## Describe the bug

Not a bug with TQEC, per se, but nonetheless a bug that we need to address on our end due to its importance.

SketchUp's importer (the feature you use in SketchUp to import a .dae with a blockgraph) has something of a bug that leads to the names of blocks/pipes being lost in the process. This breaks the export/import cycle needed to move 3D structures back and forth between SketchUp and TQEC. 

Please note. The issue does not affect SketchUp exports. Those still work just fine. 

## Steps to reproduce the behavior

Export a blockgraph from TQEC as dae and try importing it to SketchUp using the Collada importer. Check the names of pipes. They won't be there. Any subsequent export will not have the attributes needed for TQEC.

## Expected behavior

Imports into SketchUp need to preserve the name of each of the blocks/pipes in the structure, so that they can be exported again in a way that can be used in TQEC. 

## Solution

**Long-term**
We contacted SketchUp and are in contact with their engineers. They seem to agree it is a desired fix and have moved the matter into their backlog. In theory, a full solution will eventually be available. We are also hoping to convince them of making something similar available in free and web versions. 

There is no *better* solution. Nothing beats simply being able to click a button to import a file. 

**Short-term**
We still need alternatives in case the fix takes too long. 

We have tested a way to trigger imports via SketchUp Ruby console. It requires some set up and is not a solution for web versions (no console on web). But, in principle, it enables meaningful import of blockgraphs into SketchUp.

A [branch has been created](https://github.com/tqec/tqec/tree/lean_exports) for the following:
1. Adaptations to our current exports so the user has a choice between exporting a full .dae or an array with spatial information (needed to import via the console). 
2. Fully developing and adding the Ruby script to our assets, so anyone can download and use it. 
3. Tests for all the above.
4. Guidance.

## How to help
Item 1 above is in progress. I am taking care of this together with @inmzhang .

Item 2 will be addressed by @angelaelisa, who is a SketchUp Pro user and therefore knows the tool well, has followed the conversation with SketchUp, and knows Ruby.

We need additional hands for item 3 and 4.
- Item 3 is ideal for with sufficient knowledge of Python testing as to make tests. Since TQEC has a strict (don't ask me, I hate testing) approach to testing, this would also help you familiarise with something very useful across the entire code base.
- Item 4 would be perfect for someone completely new that wants to start experimenting with how to use TQEC.

The issue is critical in that it needs to be solved yes or yes, but not urgent in that the final solution is not needed tomorrow. We are happy to help you extensively if you are new or shy.

Hints from issue comments:
@angelaelisa: @jbolns if no one wants to test, no worries, I love testing :)@inmzhang: Hi J, I've checked `read_write.py` on your branch and it looks correct. Once you submit a PR, I'll do a more in-depth review. Thanks for your efforts!@jbolns: Super! Thanks @inmzhang. 
@angelaelisa , the ball is in your court now. :)@angelaelisa: > Super! Thanks [@inmzhang](https://github.com/inmzhang). [@angelaelisa](https://github.com/angelaelisa) , the ball is in your court now. :)

@jbolns I have an amazing weekend ahead of me hahaha@smburdick: Go ahead and assign me to this as well, I can certainly help with testing.@angelaelisa: @inmzhang @jbolns The problem I'm facing is that `.dae` files don't preserve metadata like object labels, cube kinds, or custom data — which makes them unsuitable for logic-aware reconstruction. So, what do you think of using a combination of `.dae` and `.json` to import? 
- Generate `.dae` for visualization
- Generate `.json` for logic reconstruction

I mean, we could use `.dae` to import the model and Ruby with `.json` to reconstruct the data-aware version (cubes, pipes, labels...). Do you think there is another way to solve it? @jbolns: I like JSON. It's just easy. So, I tend to agree... with some potentially unnecessary caveats for conceptual clarity. 

While I see how this combined usage of .dae and .json files would be the practical outcome until the SketchUp bug is addressed, I think it is best to not conceptually mix TQEC's Collada import/exports with whatever we decide to use to enable imports via the Ruby console. Our .dae imports/exports work fine in and out. Collada is fairly popular in 3D lands. It is desirable to have a functioning Collada pipeline that is a thing of its own. So, for me, the question here is, what format besides Collada can we easily export into that, coincidentally, can be consumed via the Ruby console.

... JSON does seem the best option. 

@angelaelisa @inmzhang 

@inmzhang: It would be great to have a method like `to_json` on block graph to serialize the data for further offline manipulation. Of course, a `from_json` is also needed.@jbolns: @angelaelisa @smburdick , want to give the to/from_json a try?
If you're too busy, that's fine, I can also get them in at some point this coming week@smburdick: There is an online tool to convert COLLADA to json, if that's what you mean: https://tweedegolf.github.io/collada2json_headless/

I'm not sure it matches the schema you would want.@inmzhang: The block graph data structure is quite simple and it would be easy to implement `to_json` for `Cube` and `Pipe`. Converting a Collada model to json will include too many unnecessary information related to 3D.@jbolns: Agree with @inmzhang . We need a more direct blockgraph to/from_json. 

In fact, I think this is my fault for not giving @smburdick full context. We can ignore geometries and other info needed to assemble the final 3D object. The import @angelaelisa is making (and any similar import to a different platform) uses the "kind" to link to an internal library component already defined in the destination platform.

With that said, let's start with the `to_json`, i.e, from blockgraph to json.

The function [core_export](https://github.com/tqec/tqec/blob/c6d9e601dca89f4c6899d25c96912b5d5d1fa94f/src/tqec/interop/collada/read_write.py#L224) gives a list of cubes and pipes from any blockgraph. You can use those to build a JSON that is simply a nested list of nested cubes and pipes.
- Each cube/pipe can have three fields: 3D position, kind, and a "transformation" field/value.
- On the way out, the transformation is simply an identity (3x3) matrix.
- I suppose one could use the (x,y,z) 3D position as ID, as there are no two blocks in the same position. Or just a number. 

Something like... 

```
{
... bla bla bla the whole schema
...
    "properties": {
        "cubes": {
                "1": [ ["pos_x","pos_y","pos_z"], "kind", [[1,0,0],[0,1,0],[0,0,1]] ],
                ...
        }
        "pipes": { ... }
  }
}
```@inmzhang: See #570 for a simple implementation of `to_json`/`from_json` method.@jbolns: Super, mate! Thanks for pushing the train forward. Busy week. I wish I could put more time into quantum whatnot things. Alas, sometimes I get distracted by reality. 

I just updated [lean_exports](https://github.com/tqec/tqec/tree/lean_exports) branch to consider all changes in main.

I also added transformation matrix to `to_json`. We need this in the imports for rotations, and the schema needs to be symmetric. 

I'll take care of `from_json` tomorrow, since it involves rotations stuff. I think I have time (*knocks on wood). 

For `from_json` , @inmzhang, use a slightly modified version of the rotations approach we have in `read_block_graph_from_dae_file` or extract the core rotation logic into something of it's own perhaps in `utils/rotations.py` and then call that from both `from_json` and `from_dae`?@inmzhang: > I'll take care of `from_json` tomorrow, since it involves rotations stuff. I think I have time (*knocks on wood).
> 
> For `from_json` , [@inmzhang](https://github.com/inmzhang), use a slightly modified version of the rotations approach we have in `read_block_graph_from_dae_file` or extract the core rotation logic into something of it's own perhaps in `utils/rotations.py` and then call that from both `from_json` and `from_dae`?

That makes sense to me.@jbolns: Ok, will do.

@angelaelisa , this also means you can use the `to_json` in the [lean_exports](https://github.com/tqec/tqec/tree/lean_exports) branch as source for the ruby script. I can't imagine it changing it much going forwards. 

@smburdick tests for core_exports and revised tests for the export to dae, still all yours.@angelaelisa: > [@angelaelisa](https://github.com/angelaelisa) , this also means you can use the `to_json` in the [lean_exports](https://github.com/tqec/tqec/tree/lean_exports) branch as source for the ruby script. I can't imagine it changing it much going forwards.

Perfect!@jbolns: 8754b73f095697c727a4c619eb3f8930d7eb65d2 extracts core rotation logic into `...utils/rotations.py` so it can be used in the from_json too. I think I managed to avoid collateral effects. The existing test for `read_block_graph_from_dae_file` still work and I tested manually with some rotated dae files and those also work. @jbolns: fbecba53f05ba6c3311702f34c6f4f1b8359065a introduces json importer `read_block_graph_from_json` to `... / read_write.py`:
- Needs stress testing, especially with regards to rotations, but sort of works.
- @inmzhang: Need to decide if we are accepting non-integers in the raw json information. This was originally necessary, I think, because SketchUp .dae exports from legacy and free versions do not round to integers. It may or may not be necessary for a JSON import depending on what requirements we want to impose on any external system that wants to integrate via JSON. I lean for enforcing integers as requirement, but either is fine.

I've added some quick tests at the end of `... / read_write.py`.
- Parts can/need to be converted into actual tests.
- If anyone needs the .json or .dae files to test, can send via email. @inmzhang: >  I lean for enforcing integers as requirement, but either is fine.

+1 for enforcing integers.

When you're free, feel free to open a PR. Anyone interested in implementing the rotation tests can base their work on it. Or if you think the current tests are sufficient, we can proceed to merge it.@jbolns: Super! 
- Two absolutely necessary tests remain to be done. @smburdick , you still interested? Happy to chat about the tests needed. 
- PR after @angelaelisa puts in a preliminary working version of the ruby import, in case she needs some adjustments to any of the functions we've introduced above for the ruby script to work (the ruby script is where we have less flexibility). @jbolns: 2dfd26f33e9f0cd3867bbbbdf6fe4692a76c9209 enforces integers as per above.
I don't want to pressure you @angelaelisa but, at the same time, I kind of want to pressure you. =D@angelaelisa: @jbolns I think the `tqec_blockgraph_loader.rb` is `lean_exports` for a review :)@angelaelisa: @jbolns Maybe it would be nice to add two features to the extension:

1. Add components from a select, so —once you've saved the elements of the template as components— you don't need to use the `template.skp` file, you can just easily add cubes and pipes.
2. Export to a JSON file@jbolns: Forgot to write. As mentioned in the meeting. It looks good for modern versions of SketchUp. I'm working on backwards compatibility with the Desktop version some TQEC users have. It's proving tricky, so can't really say I'm 100% sure I'll manage, but giving it a good go. @jbolns: 42165b0feb6249894a1bdfc1ae92566821e221bf introduces a backup parser module that is called when the ruby gem is not available, i.e., on very old versions of SketchUp. 

It works on my computer. :sweat_smile:

No, but seriously, I used pre-2012 Ruby commands so it should work on other computers as well. 

@angelaelisa , can you double check it still works on newer versions, i.e., Pro?

We can proceed to a draft PR after, to check with @inmzhang which tests are still needed because, in all honesty, I no longer remember. @angelaelisa: > [@angelaelisa](https://github.com/angelaelisa) , can you double check it still works on newer versions, i.e., Pro?

Nice! I'll check :)@angelaelisa: @jbolns It works perfect in Pro version too  🎉🎉🎉

Just one tiny thing :) Maybe we should place these lines at the top, just after instructions to help the user change the pathname if they are using a different SketchUp version, because in my case COMPONENT_FOLDER wasn't placed in line 169 (we both know the structure, but new users could find it difficult to find):

```
#########################
# HOW TO SET THIS FILE UP.
#########################
# To enable this file as a SketchUp plugin/extension:
#   1. Locate your Sketchup component library and write it in the COMPONENT_FOLDER variable.
#      Example location (Windows computer): "C:/Program Files (x86)/Google/Google SketchUp 8/Components/"
#   2. Find files for all primitives on TQEC shared folder https://....//
#      Place the files from (a) on your SketchUp COMPONENT_FOLDER.
#   3. Save this file to your SketchUp plugins library
#      Example location (Windows computer): "C:/Program Files (x86)/Google/Google SketchUp 8/Plugins")


#########################
# HOW TO USE tHIS FILE.
#########################
# In SketchUp:
#    1. Toolbar: Plugins (or extensions) > Load TQEC BlockGraph.
#    2. Select JSON file to import.


# Required modules
require "sketchup.rb"

# Helper variables
COMPONENT_FOLDER = "C:/Program Files (x86)/Google/Google SketchUp 8/Components/"
GRID_SIZE = 3.0  # inches per grid unit
```

What do you think? 🤔@jbolns: Yeah, I suppose that's fine @angelaelisa . Make the change and push it.

We also need to figure out a public accessible folder/place to put the `.skp` files for all primitives. It makes no sense for every user to have to create their own, but it's too many files to upload to the codebase. @nelimee ?@angelaelisa: > Yeah, I suppose that's fine [@angelaelisa](https://github.com/angelaelisa) . Make the change and push it.

Ok! Thanks to you, I'll end up being GitHub friendly 😂😂😂 @nelimee: > the `.skp` files for all primitives.

What is that? A collection of `.skp` files representing what?

I can create a specific repository in the `tqec` organisation if that makes sense. Else, I guess any drive folder might be fine too?@angelaelisa: @nelimee to use the extension/plugin you need to save the components of the template.skp one by one in the SketchUp's library. So then, the extension takes that components to draw the 3D structure so you don't lose the info to export to TQEC again. You need to save the 20 items (zxx.skp, zxz.skp, etc.). Maybe it would be easier for the user to drag and drop that files instead of saving one by one... I think drive folder maybe might be fine too. What do you think @jbolns?@jbolns: Drive folder should be fine. Or somewhere in resources folder, if we still have one. @angelaelisa 

@nelimee . Essentially... Currently we have one template.skp with n primitives. This importer uses n .skp files with one primitive each.@nelimee: As the `.skp` files are fixed and not expected to be updated, I guess versioning them on GitHub is fine. I'll create a repository for that, just give me a good name to use to name it :)

Also, it might make more sense to have a specific repository for both the Ruby code and the `.skp` files. What's your opinion on that?@jbolns: That is a really good question/suggestion @nelimee .
I think I agree we need an "integrations" repository, although I'm inclined to think the whole of "interop" should be sent there, to keep TQEC core as core as possible. But that's a bit bigger than just this issue, so perhaps best to create a separate issue once this one's in. 
@inmzhang ?@jbolns: Actually, I changed my mind. How about this?

tqec > tqec ... > interops: input/output to specific *file* formats (.dae, .json, ...)
tqec > integrations: new repo with lean/short resources to plug into other platforms (sketchup, pyzx, ...). 

With an underlying point being, makes no sense to have a full repo for just one ruby script and a bunch of files. 

@nelimee @inmzhang @inmzhang: I don't really like the idea that scatter the interops into their own repositories because it's not a heavy part in `tqec` and it increases maintaining/installation complexity if there are more repositories. And I vote for creating a repository for `tqec-sketchup-extension`  that only includes the ruby extension script with the `.skp` components.@jbolns: Well, not my preference, but if @nelimee agrees with your vote, we can do that. Let's just review the entire thing now as part of the draft PR and then at the end we just take the .rb script out knowing it works.@nelimee: I would vote for @inmzhang point too. Not all users will use SketchUp, so let's make that a separate repository. I will create a repository with a placeholder name and give you admin rights on it, you should be able to change its name to whatever you want :)@nelimee: https://github.com/tqec/tqec-sketchup-extension

@jbolns you should have admin rights.@angelaelisa: @jbolns once you'll save all the skp files, I'll check that they also work in Pro Version (time ago, remember you told me that my skp files should be saved in older versions to work for you, so let's see the reverse works in my library :)@jbolns: Ok, changed name to [tqec-integrations](https://github.com/tqec/tqec-integrations), just to pretend I didn't entirely lose this point. :grin:

And also because I think this shouldn't be the only such thing. Its the way to get people using TQEC, to make it easier for them to integrate it into their own system. Else, this is all just an expensive hobby. Sorry not sorry. 

Ps1. Will populate after Monday after we've checked all works together.
Ps2. In my understanding, an "integration" is a feature that enables interoperability across two systems, whereas an "extension" is a feature that expands the inherent qualities of a system. This is an integration, albeit not one with live data-streaming... Yet. 

Solution patch:
diff --git a/src/tqec/circuit/measurement.py b/src/tqec/circuit/measurement.py
index 21e393ec..8969aba4 100644
--- a/src/tqec/circuit/measurement.py
+++ b/src/tqec/circuit/measurement.py
@@ -9,7 +9,7 @@ from __future__ import annotations
 
 from abc import ABC, abstractmethod
 from dataclasses import dataclass
-from typing import Mapping, cast
+from typing import Any, Mapping, cast
 
 import stim
 from typing_extensions import override
@@ -117,6 +117,30 @@ class Measurement(AbstractMeasurement):
     def map_qubit(self, qubit_map: Mapping[GridQubit, GridQubit]) -> Measurement:
         return Measurement(qubit_map[self.qubit], self.offset)
 
+    def to_dict(self) -> dict[str, Any]:
+        """Return a dictionary representation of the measurement.
+
+        Returns:
+            a dictionary with the keys ``qubit`` and ``offset`` and their
+            corresponding values.
+        """
+        return {"qubit": [self.qubit.x, self.qubit.y], "offset": self.offset}
+
+    @staticmethod
+    def from_dict(data: dict[str, Any]) -> Measurement:
+        """Return a measurement from its dictionary representation.
+
+        Args:
+            data: dictionary with the keys ``qubit`` and ``offset``.
+
+        Returns:
+            a new instance of :class:`Measurement` with the provided
+            ``qubit`` and ``offset``.
+        """
+        qubit = GridQubit(data["qubit"][0], data["qubit"][1])
+        offset = data["offset"]
+        return Measurement(qubit, offset)
+
 
 def get_measurements_from_circuit(circuit: stim.Circuit) -> list[Measurement]:
     """Get all the measurements found in the provided circuit.
diff --git a/src/tqec/circuit/moment.py b/src/tqec/circuit/moment.py
index 89c90901..bd2a5191 100644
--- a/src/tqec/circuit/moment.py
+++ b/src/tqec/circuit/moment.py
@@ -383,6 +383,31 @@ class Moment:
     def __eq__(self, other: object) -> bool:
         return isinstance(other, Moment) and self._circuit == other._circuit
 
+    def to_dict(self) -> dict[str, Any]:
+        """Return a dictionary representation of the :class:`Moment` instance.
+
+        The dictionary is intended to be used as a JSON object.
+        """
+        return {
+            "circuit": str(self._circuit),
+            "used_qubits": list(self._used_qubits),
+        }
+
+    @staticmethod
+    def from_dict(data: dict[str, Any]) -> Moment:
+        """Return a :class:`Moment` instance from its dictionary representation.
+
+        Args:
+            data: dictionary with the keys ``circuit`` and ``used_qubits``.
+
+        Returns:
+            a new instance of :class:`Moment` with the provided ``circuit`` and
+            ``used_qubits``.
+        """
+        circuit = stim.Circuit(data["circuit"])
+        used_qubits = set(data["used_qubits"])
+        return Moment(circuit, used_qubits=used_qubits, _avoid_checks=True)
+
 
 def iter_stim_circuit_without_repeat_by_moments(
     circuit: stim.Circuit, collected_before_use: bool = True
diff --git a/src/tqec/circuit/qubit.py b/src/tqec/circuit/qubit.py
index 6204a846..06eae049 100644
--- a/src/tqec/circuit/qubit.py
+++ b/src/tqec/circuit/qubit.py
@@ -76,6 +76,28 @@ class GridQubit:
     def __str__(self) -> str:
         return f"Q[{self.x}, {self.y}]"
 
+    def to_dict(self) -> dict[str, int]:
+        """Return a dictionary representation of the qubit.
+
+        Returns:
+            a dictionary with the keys ``x`` and ``y`` and their
+            corresponding values.
+        """
+        return {"x": self.x, "y": self.y}
+
+    @staticmethod
+    def from_dict(data: dict[str, int]) -> GridQubit:
+        """Return a qubit from its dictionary representation.
+
+        Args:
+            data: dictionary with the keys ``x`` and ``y``.
+
+        Returns:
+            a new instance of :class:`GridQubit` with the provided
+            ``x`` and ``y``.
+        """
+        return GridQubit(data["x"], data["y"])
+
 
 """Names of the `stim` instructions that are considered as annotations."""
 ANNOTATION_INSTRUCTIONS: frozenset[str] = frozenset(
diff --git a/src/tqec/circuit/qubit_map.py b/src/tqec/circuit/qubit_map.py
index 94abe789..e4aa0b47 100644
--- a/src/tqec/circuit/qubit_map.py
+++ b/src/tqec/circuit/qubit_map.py
@@ -13,7 +13,7 @@ from __future__ import annotations
 import functools
 from collections import Counter
 from dataclasses import dataclass, field
-from typing import Callable, Iterable
+from typing import Any, Callable, Iterable
 
 import stim
 
@@ -116,6 +116,27 @@ class QubitMap:
     def __getitem__(self, index: GridQubit) -> int:
         return self.q2i[index]
 
+    def to_dict(self) -> dict[str, Any]:
+        """Return a dictionary representation of the qubit map.
+
+        The dictionary is intended to be used as a JSON object.
+        """
+        return {"i2q": [[qi, q.to_dict()] for qi, q in self.i2q.items()]}
+
+    @staticmethod
+    def from_dict(data: dict[str, Any]) -> QubitMap:
+        """Return a qubit map from its dictionary representation.
+
+        Args:
+            data: dictionary with the keys ``i2q`` and ``q2i``.
+
+        Returns:
+            a new instance of :class:`QubitMap` with the provided
+            ``i2q`` and ``q2i``.
+        """
+        i2q = {int(qi): GridQubit.from_dict(q) for qi, q in data["i2q"]}
+        return QubitMap(i2q)
+
 
 def get_qubit_map(circuit: stim.Circuit) -> QubitMap:
     """Returns the existing qubits and their coordinates at the end of the
diff --git a/src/tqec/circuit/schedule/circuit.py b/src/tqec/circuit/schedule/circuit.py
index 468dad9c..6f45bc5f 100644
--- a/src/tqec/circuit/schedule/circuit.py
+++ b/src/tqec/circuit/schedule/circuit.py
@@ -532,3 +532,29 @@ class ScheduledCircuit:
             and self._qubit_map == other._qubit_map
             and self._moments == other._moments
         )
+
+    def to_dict(self) -> dict[str, Any]:
+        """Return a dictionary representation of the circuit.
+
+        Returns:
+            a dictionary with the keys ``moments``, ``schedule`` and
+            ``qubit_map`` and their corresponding values.
+        """
+        return {
+            "moments": [m.to_dict() for m in self._moments],
+            "schedule": self._schedule.schedule,
+            "qubit_map": self._qubit_map.to_dict(),
+        }
+
+    @staticmethod
+    def from_dict(data: dict[str, Any]) -> ScheduledCircuit:
+        """Return a circuit from its dictionary representation.
+
+        Args:
+            data: dictionary with the keys ``moments``, ``schedule`` and
+                ``qubit_map``.
+        """
+        moments = [Moment.from_dict(m) for m in data["moments"]]
+        schedule = Schedule(data["schedule"])
+        qubit_map = QubitMap.from_dict(data["qubit_map"])
+        return ScheduledCircuit(moments, schedule, qubit_map, _avoid_checks=True)
diff --git a/src/tqec/compile/detectors/database.py b/src/tqec/compile/detectors/database.py
index 8fbe31bb..b7953914 100644
--- a/src/tqec/compile/detectors/database.py
+++ b/src/tqec/compile/detectors/database.py
@@ -1,11 +1,14 @@
 from __future__ import annotations
 
 import hashlib
+import json
 import pickle
 from dataclasses import dataclass, field
 from functools import cached_property
 from pathlib import Path
-from typing import Sequence
+from typing import Any, Literal, Sequence
+
+import numpy
 
 from tqec.circuit.measurement_map import MeasurementRecordsMap
 from tqec.circuit.moment import Moment
@@ -16,7 +19,7 @@ from tqec.circuit.schedule import (
 )
 from tqec.compile.detectors.detector import Detector
 from tqec.compile.generation import generate_circuit_from_instantiation
-from tqec.plaquette.plaquette import Plaquettes
+from tqec.plaquette.plaquette import Plaquette, Plaquettes
 from tqec.templates.subtemplates import SubTemplateType
 from tqec.utils.exceptions import TQECException
 from tqec.utils.position import Shift2D
@@ -139,6 +142,50 @@ class _DetectorDatabaseKey:
             schedule.append_schedule(circuit.schedule)
         return ScheduledCircuit(moments, schedule, qubit_map)
 
+    def to_dict(self, plaquettes_to_indices: dict[Plaquette, int]) -> dict[str, Any]:
+        """Return a dictionary representation of the key.
+
+        Args:
+            plaquettes_to_indices: mapping from each :class:`Plaquette` to its
+                index in the list of unique plaquettes. Each plaquette is
+                represented by its index in the list of unique
+                plaquettes to save space.
+
+        Returns:
+            a dictionary with the keys ``subtemplates`` and
+            ``plaquettes_by_timestep`` and their corresponding values.
+        """
+        return {
+            "subtemplates": [st.tolist() for st in self.subtemplates],
+            "plaquettes_by_timestep": [
+                p.to_dict(plaquettes_to_indices) for p in self.plaquettes_by_timestep
+            ],
+        }
+
+    @staticmethod
+    def from_dict(
+        data: dict[str, Any],
+        plaquettes: Sequence[Plaquette],
+    ) -> _DetectorDatabaseKey:
+        """Return a key from its dictionary representation.
+
+        Args:
+            data: dictionary with the keys ``subtemplates`` and
+                ``plaquettes_by_timestep``.
+            plaquettes: list of :class:`Plaquette` instances to use to build the
+                :class:`Plaquettes` instances. Each plaquette is represented by
+                its index in the list of unique plaquettes to save space.
+
+        Returns:
+            a new instance of :class:`_DetectorDatabaseKey` with the provided
+            ``subtemplates`` and ``plaquettes_by_timestep``.
+        """
+        subtemplates = [numpy.array(st) for st in data["subtemplates"]]
+        plaquettes_by_timestep = [
+            Plaquettes.from_dict(p, plaquettes) for p in data["plaquettes_by_timestep"]
+        ]
+        return _DetectorDatabaseKey(subtemplates, plaquettes_by_timestep)
+
 
 @dataclass
 class DetectorDatabase:
@@ -270,20 +317,90 @@ class DetectorDatabase:
     def __len__(self) -> int:
         return len(self.mapping)
 
-    def to_file(self, filepath: Path) -> None:
+    def to_dict(self) -> dict[str, Any]:
+        """Return a dictionary representation of the database.
+
+        Returns:
+            a dictionary with the keys ``mapping`` and ``frozen`` and their
+            corresponding values.
+        """
+        # First obtain the unique plaquettes
+        plaquettes_set: set[Plaquette] = set()
+        for key in self.mapping:
+            for p in key.plaquettes_by_timestep:
+                if p.collection.default_value is not None:
+                    plaquettes_set.add(p.collection.default_value)
+                plaquettes_set.update(p.collection.values())
+        uniq_plaquettes: Sequence[Plaquette] = list(plaquettes_set)
+        # Then create a mapping from each plaquette to its index
+        plaquettes_to_indices = {p: i for i, p in enumerate(uniq_plaquettes)}
+        return {
+            "mapping": [
+                [
+                    key.to_dict(plaquettes_to_indices=plaquettes_to_indices),
+                    [d.to_dict() for d in detectors],
+                ]
+                for key, detectors in self.mapping.items()
+            ],
+            "frozen": self.frozen,
+            "uniq_plaquettes": [p.to_dict() for p in uniq_plaquettes],
+        }
+
+    @staticmethod
+    def from_dict(data: dict[str, Any]) -> DetectorDatabase:
+        """Return a database from its dictionary representation.
+
+        Args:
+            data: dictionary with the keys ``mapping`` and ``frozen``.
+
+        Returns:
+            a new instance of :class:`DetectorDatabase` with the provided
+            ``mapping`` and ``frozen``.
+        """
+        uniq_plaquettes = [Plaquette.from_dict(p) for p in data["uniq_plaquettes"]]
+        mapping = {
+            _DetectorDatabaseKey.from_dict(key, plaquettes=uniq_plaquettes): frozenset(
+                Detector.from_dict(d) for d in detectors
+            )
+            for key, detectors in data["mapping"]
+        }
+        return DetectorDatabase(mapping, data["frozen"])
+
+    def to_file(
+        self, filepath: Path, format: Literal["pickle", "json"] = "pickle"
+    ) -> None:
+        """Save the database to a file.
+
+        Args:
+            filepath: path to the file where the database should be saved.
+            format: format to use to save the database. Currently only
+                "pickle" and "json" are supported.
+        """
         if not filepath.parent.exists():
             filepath.parent.mkdir()
-        with open(filepath, "wb") as f:
-            pickle.dump(self, f)
+        if format == "pickle":
+            with open(filepath, "wb") as f:
+                pickle.dump(self, f)
+        else:
+            filepath = filepath.with_suffix(".json")
+            with open(filepath, "w") as f:
+                json.dump(self.to_dict(), f)
 
     @staticmethod
-    def from_file(filepath: Path) -> DetectorDatabase:
-        with open(filepath, "rb") as f:
-            database = pickle.load(f)
-            if not isinstance(database, DetectorDatabase):
-                raise TQECException(
-                    f"Found the Python type {type(database).__name__} in the "
-                    f"provided file but {type(DetectorDatabase).__name__} was "
-                    "expected."
-                )
+    def from_file(
+        filepath: Path, format: Literal["pickle", "json"] = "pickle"
+    ) -> DetectorDatabase:
+        if format == "pickle":
+            with open(filepath, "rb") as f:
+                database = pickle.load(f)
+        else:
+            with open(filepath) as f:
+                data = json.load(f)
+                database = DetectorDatabase.from_dict(data)
+        if not isinstance(database, DetectorDatabase):
+            raise TQECException(
+                f"Found the Python type {type(database).__name__} in the "
+                f"provided file but {type(DetectorDatabase).__name__} was "
+                "expected."
+            )
         return database
diff --git a/src/tqec/compile/detectors/detector.py b/src/tqec/compile/detectors/detector.py
index 8104012b..8b1cdcc8 100644
--- a/src/tqec/compile/detectors/detector.py
+++ b/src/tqec/compile/detectors/detector.py
@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
+from typing import Any
 
 import stim
 
@@ -93,3 +94,30 @@ class Detector:
             frozenset(m.offset_spatially_by(x, y) for m in self.measurements),
             self.coordinates.offset_spatially_by(x, y),
         )
+
+    def to_dict(self) -> dict[str, Any]:
+        """Return a dictionary representation of the detector.
+
+        Returns:
+            a dictionary with the keys ``measurements`` and ``coordinates`` and
+            their corresponding values.
+        """
+        return {
+            "measurements": [m.to_dict() for m in self.measurements],
+            "coordinates": self.coordinates.to_dict(),
+        }
+
+    @staticmethod
+    def from_dict(data: dict[str, Any]) -> Detector:
+        """Return a detector from its dictionary representation.
+
+        Args:
+            data: dictionary with the keys ``measurements`` and ``coordinates``.
+
+        Returns:
+            a new instance of :class:`Detector` with the provided
+            ``measurements`` and ``coordinates``.
+        """
+        measurements = frozenset(Measurement.from_dict(m) for m in data["measurements"])
+        coordinates = StimCoordinates.from_dict(data["coordinates"])
+        return Detector(measurements, coordinates)
diff --git a/src/tqec/computation/block_graph.py b/src/tqec/computation/block_graph.py
index c280b124..71d2e57e 100644
--- a/src/tqec/computation/block_graph.py
+++ b/src/tqec/computation/block_graph.py
@@ -5,6 +5,8 @@ from __future__ import annotations
 import json
 import math
 import pathlib
+import numpy as np
+
 from collections.abc import Mapping
 from copy import deepcopy
 from io import BytesIO
@@ -857,7 +859,17 @@ class BlockGraph:
             The JSON string representation of the block graph if `file_path` is None,
             otherwise None.
         """
+        # Get dictionary from blockgraph
         obj_dict = self.to_dict()
+
+        # Add transform matrix to keep schema symmetric
+        for cube in obj_dict["cubes"]:
+            cube["transform"] = np.eye(3, dtype=int).tolist()
+
+        for pipe in obj_dict["pipes"]:
+            pipe["transform"] = np.eye(3, dtype=int).tolist()
+
+        # Return or write
         if file_path is None:
             return json.dumps(obj_dict, indent=indent)
         with open(file_path, "w") as fp:
@@ -865,35 +877,19 @@ class BlockGraph:
             return None
 
     @staticmethod
-    def from_json(
-        file_path: str | pathlib.Path | None = None,
-        json_text: str | None = None,
-    ) -> BlockGraph:
-        """Deserialize a block graph from a JSON string or read it from a file.
+    def from_json(filename: str | pathlib.Path, graph_name: str = "") -> BlockGraph:
+        """Construct a block graph from a JSON file.
 
         Args:
-            file_path: The input json file path to read from, or ``None`` to indicate
-                that the JSON string will be provided in `json_text`. Default is None.
-            json_text: The JSON string representation of the block graph, or ``None``
-                to indicate that the JSON file will be read from `file_path`.
-                Default is None.
+            filename: The input ``.json`` file path.
+            graph_name: The name of the block graph. Default is an empty string.
 
         Returns:
-            The :py:class:`~tqec.computation.block_graph.BlockGraph` object
-            constructed from the JSON string or file.
+            The :py:class:`~tqec.computation.block_graph.BlockGraph` object constructed from the DAE file.
         """
-        if (file_path is None) == (json_text is None):
-            raise TQECException(
-                "Either file_path or json_text should be provided, but not both."
-            )
-        obj_dict: dict[str, Any]
-        if json_text is None:
-            assert file_path is not None
-            with open(file_path) as fp:
-                obj_dict = json.load(fp)
-        else:
-            obj_dict = json.loads(json_text)
-        return BlockGraph.from_dict(obj_dict)
+        from tqec.interop.collada.read_write import read_block_graph_from_json
+
+        return read_block_graph_from_json(filename, graph_name)
 
     def relabel_cubes(self, label_mapping: Mapping[Position3D | str, str]) -> None:
         """Relabel cubes in the block graph.
diff --git a/src/tqec/interop/collada/read_write.py b/src/tqec/interop/collada/read_write.py
index b8de98ae..13dfcec8 100644
--- a/src/tqec/interop/collada/read_write.py
+++ b/src/tqec/interop/collada/read_write.py
@@ -3,9 +3,10 @@
 from __future__ import annotations
 
 import pathlib
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from typing import BinaryIO, Iterable, cast
 
+import json
 import collada
 import collada.source
 import numpy as np
@@ -25,9 +26,9 @@ from tqec.interop.color import TQECColor
 from tqec.computation.correlation import CorrelationSurface
 from tqec.utils.position import FloatPosition3D, Position3D, SignedDirection3D
 from tqec.utils.rotations import (
-    calc_rotation_angles,
     get_axes_directions,
-    rotate_block_kind_by_matrix,
+    rotate_on_import,
+    adjust_hadamards_direction,
 )
 from tqec.utils.scale import round_or_fail
 
@@ -40,6 +41,22 @@ _MATERIAL_SYMBOL = "MaterialSymbol"
 _CORRELATION_SUFFIX = "_CORRELATION"
 
 
+# SHARED FUNCTIONS
+def int_position_before_scale(pos: FloatPosition3D, pipe_length: float) -> Position3D:
+    return Position3D(
+        x=round_or_fail(pos.x / (1 + pipe_length), atol=0.35),
+        y=round_or_fail(pos.y / (1 + pipe_length), atol=0.35),
+        z=round_or_fail(pos.z / (1 + pipe_length), atol=0.35),
+    )
+
+
+def offset_y_cube_position(pos: FloatPosition3D, pipe_length: float) -> FloatPosition3D:
+    if np.isclose(pos.z - 0.5, np.floor(pos.z), atol=1e-9):
+        pos = pos.shift_by(dz=-0.5)
+    return FloatPosition3D(pos.x, pos.y, pos.z / (1 + pipe_length))
+
+
+# DAE EXPORTER/IMPORTER
 def read_block_graph_from_dae_file(
     filepath: str | pathlib.Path,
     graph_name: str = "",
@@ -104,46 +121,20 @@ def read_block_graph_from_dae_file(
             axes_directions = get_axes_directions(transformation.rotation)
             kind = block_kind_from_str(name)
 
-            # Rotation health checks
-            # - If node's matrix NOT rotated: proceed automatically
+            # Rotations step 1. Skip if node's matrix not rotated
             # - If node's matrix YES rotated: check closer & make necessary adjustments
             if not np.allclose(transformation.rotation, np.eye(3), atol=1e-9):
-                # Calculate rotation
-                rotation_angles = calc_rotation_angles(transformation.rotation)
-
-                # Reject invalid rotations for all other cubes/pipes:
-                if (
-                    # Any rotation with angle not an integer multiply of 90 degrees: partially rotated block/pipe
-                    any([int(angle) not in [0, 90, 180] for angle in rotation_angles])
-                    # At least 1 * 180-deg or 2 * 90-deg rotation to avoid dimensional collapse
-                    # (A single 90-deg rotation would put the rotated vector on the plane made by the other two axes)
-                    or sum([angle for angle in rotation_angles]) < 180
-                ):
-                    raise TQECException(
-                        f"There is an invalid rotation for {kind} block at position {translation}."
-                    )
-
-                # Rotate node name
-                # Calculate rotated kind and directions for all axes in case it is needed
-                kind = rotate_block_kind_by_matrix(kind, transformation.rotation)
-
-                # Shift nodes slightly according to rotation
-                translation = FloatPosition3D(
-                    *transformation.translation
-                    + transformation.rotation.dot(transformation.scale)
+                translation, kind = rotate_on_import(
+                    transformation.rotation,
+                    transformation.translation,
+                    transformation.scale,
+                    kind,
                 )
 
-            # Adjust hadamards if pipe direction is negative
+            # Rotations step 2. Skip if hadamard points in positive direction
             if isinstance(kind, PipeKind):
                 if axes_directions[str(kind.direction)] == -1 and "H" in str(kind):
-                    hdm_equivalences = {"ZXOH": "XZOH", "XOZH": "ZOXH", "OXZH": "OZXH"}
-                    if str(kind) in hdm_equivalences.keys():
-                        kind = block_kind_from_str(hdm_equivalences[str(kind)])
-                    else:
-                        inv_equivalences = {
-                            value: key for key, value in hdm_equivalences.items()
-                        }
-                        kind = block_kind_from_str(inv_equivalences[str(kind)])
+                    kind = adjust_hadamards_direction(kind)
 
             # Direction, scaling and checks for pipes
             if isinstance(kind, PipeKind):
@@ -175,18 +166,6 @@ def read_block_graph_from_dae_file(
 
     pipe_length = 2.0 if pipe_length is None else pipe_length
 
-    def int_position_before_scale(pos: FloatPosition3D) -> Position3D:
-        return Position3D(
-            x=round_or_fail(pos.x / (1 + pipe_length), atol=0.35),
-            y=round_or_fail(pos.y / (1 + pipe_length), atol=0.35),
-            z=round_or_fail(pos.z / (1 + pipe_length), atol=0.35),
-        )
-
-    def offset_y_cube_position(pos: FloatPosition3D) -> FloatPosition3D:
-        if np.isclose(pos.z - 0.5, np.floor(pos.z), atol=1e-9):
-            pos = pos.shift_by(dz=-0.5)
-        return FloatPosition3D(pos.x, pos.y, pos.z / (1 + pipe_length))
-
     # Construct graph
     # Create graph
     graph = BlockGraph(graph_name)
@@ -194,8 +173,8 @@ def read_block_graph_from_dae_file(
     # Add cubes
     for pos, cube_kind, axes_directions in parsed_cubes:
         if isinstance(cube_kind, YHalfCube):
-            pos = offset_y_cube_position(pos)
-        graph.add_cube(int_position_before_scale(pos), cube_kind)
+            pos = offset_y_cube_position(pos, pipe_length)
+        graph.add_cube(int_position_before_scale(pos, pipe_length), cube_kind)
     port_index = 0
 
     # Add pipes
@@ -203,7 +182,8 @@ def read_block_graph_from_dae_file(
         # Draw pipes in +1/-1 direction using position, kind of pipe, and directional pointers from previous operations
         directional_multiplier = axes_directions[str(pipe_kind.direction)]
         head_pos = int_position_before_scale(
-            pos.shift_in_direction(pipe_kind.direction, -1 * directional_multiplier)
+            pos.shift_in_direction(pipe_kind.direction, -1 * directional_multiplier),
+            pipe_length,
         )
         tail_pos = head_pos.shift_in_direction(
             pipe_kind.direction, 1 * directional_multiplier
@@ -249,34 +229,192 @@ def write_block_graph_to_dae_file(
     for cube in block_graph.cubes:
         if cube.is_port:
             continue
+
         scaled_position = scale_position(cube.position)
         if cube.is_y_cube and block_graph.has_pipe_between(
             cube.position, cube.position.shift_by(dz=1)
         ):
             scaled_position = scaled_position.shift_by(dz=0.5)
+
         matrix = np.eye(4, dtype=np.float32)
         matrix[:3, 3] = scaled_position.as_array()
         pop_faces_at_directions = []
+
         for pipe in block_graph.pipes_at(cube.position):
             pop_faces_at_directions.append(
                 SignedDirection3D(pipe.direction, cube == pipe.u)
             )
+
         base.add_block_instance(matrix, cube.kind, pop_faces_at_directions)
+
     for pipe in block_graph.pipes:
         head_pos = scale_position(pipe.u.position)
         pipe_pos = head_pos.shift_in_direction(pipe.direction, 1.0)
+
         matrix = np.eye(4, dtype=np.float32)
         matrix[:3, 3] = pipe_pos.as_array()
         scales = [1.0, 1.0, 1.0]
+
         # We divide the scaling by 2.0 because the pipe's default length is 2.0.
         scales[pipe.direction.value] = pipe_length / 2.0
         matrix[:3, :3] = np.diag(scales)
+
         base.add_block_instance(matrix, pipe.kind)
+
     if show_correlation_surface is not None:
         base.add_correlation_surface(block_graph, show_correlation_surface, pipe_length)
+
     base.mesh.write(file_like)
 
 
+# JSON IMPORTER
+# Why no JSON exporter? No need. It can be done with blockgraph.to_dict
+def read_block_graph_from_json(
+    filepath: str | pathlib.Path,
+    graph_name: str = "",
+) -> BlockGraph:
+    """Read a Collada JSON file and construct a
+    :py:class:`~tqec.computation.block_graph.BlockGraph` from it.
+
+    Args:
+        filepath: The input dae file path.
+        graph_name: The name of the block graph. Default is an empty string.
+
+    Returns:
+        The constructed :py:class:`~tqec.computation.block_graph.BlockGraph` object.
+
+    Raises:
+        TQECException: If the JSON file cannot be parsed and converted to a block graph.
+    """
+
+    # Read JSON file
+    try:
+        with open(filepath) as f:
+            data = json.load(f)
+    except Exception:
+        raise TQECException("JSON file not found.")
+
+    # Check JSON file has cubes and pipes
+    try:
+        cubes = data["cubes"]
+        pipes = data["pipes"]
+        if not len(cubes) > 0 or not len(pipes) > 0:
+            raise TQECException("No cubes or pipes found.")
+    except Exception:
+        raise TQECException("JSON file is not appropriately formatted.")
+
+    # Initialise list of cubes and pipes
+    parsed_cubes: list[tuple[FloatPosition3D, CubeKind, dict[str, int]]] = []
+    parsed_pipes: list[
+        tuple[FloatPosition3D, FloatPosition3D, PipeKind, dict[str, int]]
+    ] = []
+
+    # Get cubes data
+    for cube in data["cubes"]:
+        # Skip any "PORT" kind (cannot currently import them: error @ `block_kind_from_str` )
+        if cube["kind"] == "PORT":
+            continue
+
+        # Enforce integers for position and transformation
+        if not all([isinstance(i, int) for i in cube["position"]]):
+            raise TQECException(
+                f"Incorrect positioning for cube at {cube['position']}. All positional information must be composed of integer values."
+            )
+
+        if not all([isinstance(i, int) for row in cube["transform"] for i in row]):
+            raise TQECException(
+                f"Incorrect transformation matrix for cube at {cube['position']}. All elements in transformation matrix need to be integers."
+            )
+
+        # Get key spatial info
+        translation = FloatPosition3D(*cube["position"])
+        axes_directions = get_axes_directions(cube["transform"])
+        kind = block_kind_from_str(cube["kind"])
+
+        # Rotations step 1. Skip if node's matrix not rotated
+        # - If node's matrix YES rotated: check closer & make necessary adjustments
+        if not np.allclose(cube["transform"], np.eye(3), atol=1e-9):
+            translation, kind = rotate_on_import(
+                cube["transform"],
+                cube["position"],
+                np.array([1.0, 1.0, 1.0]),
+                kind,
+            )
+
+        # Append to parsed cubes
+        if isinstance(kind, CubeKind):
+            parsed_cubes.append((translation, kind, axes_directions))
+
+    # Get pipes data
+    for pipe in data["pipes"]:
+        # Enforce integers for position and transformation
+        for pos in [pipe["u"], pipe["v"]]:
+            if not all([isinstance(i, int) for i in pos]):
+                raise TQECException(
+                    f"Incorrect positioning for pipe at ({pipe['u']}). All positional information must be composed of integer values."
+                )
+
+        if not all([isinstance(i, int) for row in pipe["transform"] for i in row]):
+            raise TQECException(
+                f"Incorrect transformation for pipe at ({pipe['u']}). All elements in transformation matrix need to be integers."
+            )
+
+        # Get key spatial info
+        u_pos = FloatPosition3D(*pipe["u"])  # Equivalent to "translation" in cubes
+        v_pos = FloatPosition3D(*pipe["v"])
+        axes_directions = get_axes_directions(pipe["transform"])
+        kind = block_kind_from_str(pipe["kind"])
+
+        # Rotations step 1. Skip if node's matrix not rotated
+        # - If node's matrix YES rotated: check closer & make necessary adjustments
+        if not np.allclose(pipe["transform"], np.eye(3), atol=1e-9):
+            u_pos, kind = rotate_on_import(
+                pipe["transform"],
+                pipe["position"],
+                np.array([1.0, 1.0, 1.0]),
+                kind,
+            )
+
+        # Rotations step 2. Skip if hadamard points in positive direction
+        # Check kind is pipe
+        if isinstance(kind, PipeKind):
+            if axes_directions[str(kind.direction)] == -1 and "H" in str(kind):
+                kind = adjust_hadamards_direction(kind)
+
+        # Recheck kind since it might have been regenerated
+        if isinstance(kind, PipeKind):
+            parsed_pipes.append((u_pos, v_pos, kind, axes_directions))
+
+    # Construct graph
+    # Create graph
+    graph = BlockGraph(graph_name)
+
+    # Add cubes
+    for pos, cube_kind, axes_directions in parsed_cubes:
+        if isinstance(cube_kind, YHalfCube):
+            pos = offset_y_cube_position(pos, 0.0)
+        graph.add_cube(int_position_before_scale(pos, 0.0), cube_kind)
+    port_index = 0
+
+    # Add pipes
+    for u_pos, v_pos, pipe_kind, axes_directions in parsed_pipes:
+        # Write head_pos and tail_pos as Position3D
+        head_pos = int_position_before_scale(u_pos, 0)
+        tail_pos = int_position_before_scale(v_pos, 0)
+
+        # Add pipe
+        if head_pos not in graph:
+            graph.add_cube(head_pos, Port(), label=f"Port{port_index}")
+            port_index += 1
+        if tail_pos not in graph:
+            graph.add_cube(tail_pos, Port(), label=f"Port{port_index}")
+            port_index += 1
+        graph.add_pipe(head_pos, tail_pos, pipe_kind)
+
+    return graph
+
+
+# CLASSES
 @dataclass(frozen=True)
 class _BlockLibraryKey:
     """The key to access the library node in the Collada DAE file."""
@@ -503,3 +641,19 @@ class _Transformation:
         mat[:3, :3] = self.rotation * self.scale[None, :]
         mat[:3, 3] = self.translation
         return mat
+
+
+@dataclass(frozen=True)
+class _GraphItem:
+    """Data class to summary key spatial information for a specific cube or pipe."""
+
+    position_in_blockgraph: Position3D
+    kind: BlockKind
+    transformation_matrix: _Transformation
+
+
+@dataclass
+class _GraphItems:
+    """Data class to join _GraphItem into a single object that can be populated with cubes, pipes, or cubes and pipes."""
+
+    items: list[_GraphItem] = field(default_factory=list)
diff --git a/src/tqec/plaquette/debug.py b/src/tqec/plaquette/debug.py
index 9d601a2d..85f7ccf8 100644
--- a/src/tqec/plaquette/debug.py
+++ b/src/tqec/plaquette/debug.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
+from typing import Any
 
 from tqec.plaquette.enums import PlaquetteOrientation
 from tqec.plaquette.rpng.rpng import RPNG, PauliBasis, RPNGDescription
@@ -42,3 +43,18 @@ class PlaquetteDebugInformation:
             ),
             self.basis,
         )
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "rpng": self.rpng.to_dict() if self.rpng is not None else None,
+            "basis": self.basis.value if self.basis is not None else None,
+        }
+
+    @staticmethod
+    def from_dict(data: dict[str, Any]) -> PlaquetteDebugInformation:
+        return PlaquetteDebugInformation(
+            RPNGDescription.from_dict(data["rpng"])
+            if data["rpng"] is not None
+            else None,
+            PauliBasis(data["basis"]) if data["basis"] is not None else None,
+        )
diff --git a/src/tqec/plaquette/plaquette.py b/src/tqec/plaquette/plaquette.py
index e0867bdc..2b275a03 100644
--- a/src/tqec/plaquette/plaquette.py
+++ b/src/tqec/plaquette/plaquette.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import hashlib
 from dataclasses import dataclass, field
-from typing import Callable, Collection, Iterable, Literal, Mapping
+from typing import Any, Callable, Collection, Iterable, Literal, Mapping, Sequence
 
 import stim
 from typing_extensions import override
@@ -132,6 +132,47 @@ class Plaquette:
             self.circuit.get_circuit(include_qubit_coords=False) == stim.Circuit()
         )
 
+    def to_dict(self) -> dict[str, Any]:
+        """Return a dictionary representation of the plaquette.
+
+        The dictionary is intended to be used as a JSON object.
+        """
+        return {
+            "name": self.name,
+            "qubits": self.qubits.to_dict(),
+            "circuit": self.circuit.to_dict(),
+            "mergeable_instructions": list(self.mergeable_instructions),
+            "debug_information": self.debug_information.to_dict(),
+        }
+
+    @staticmethod
+    def from_dict(data: dict[str, Any]) -> Plaquette:
+        """Return a plaquette from its dictionary representation.
+
+        Args:
+            data: dictionary with the keys ``name``, ``qubits``, ``circuit``,
+                ``mergeable_instructions`` and ``debug_information``.
+
+        Returns:
+            a new instance of :class:`Plaquette` with the provided
+            ``name``, ``qubits``, ``circuit``, ``mergeable_instructions`` and
+            ``debug_information``.
+        """
+        name = data["name"]
+        qubits = PlaquetteQubits.from_dict(data["qubits"])
+        circuit = ScheduledCircuit.from_dict(data["circuit"])
+        mergeable_instructions = frozenset(data["mergeable_instructions"])
+        debug_information = PlaquetteDebugInformation.from_dict(
+            data["debug_information"]
+        )
+        return Plaquette(
+            name,
+            qubits,
+            circuit,
+            mergeable_instructions,
+            debug_information,
+        )
+
 
 @dataclass(frozen=True)
 class Plaquettes:
@@ -208,6 +249,75 @@ class Plaquettes:
     def items(self) -> Iterable[tuple[int, Plaquette]]:
         return self.collection.items()
 
+    def to_dict(
+        self, plaquettes_to_indices: dict[Plaquette, int] | None = None
+    ) -> dict[str, Any]:
+        """Return a dictionary representation of the plaquettes.
+
+        Args:
+            plaquettes_to_indices: a dictionary mapping plaquettes to their
+                indices. If provided, a plaquette will be represented by its index
+        """
+
+        def convert(value: Plaquette) -> Any:
+            return (
+                plaquettes_to_indices[value]
+                if plaquettes_to_indices
+                else value.to_dict()
+            )
+
+        return {
+            "plaquettes": [
+                {"index": index, "plaquette": convert(plaquette)}
+                for index, plaquette in self.collection.items()
+            ],
+            "default": (
+                convert(self.collection.default_value)
+                if self.collection.default_value is not None
+                else None
+            ),
+        }
+
+    @staticmethod
+    def from_dict(
+        data: dict[str, Any], plaquettes: Sequence[Plaquette] | None = None
+    ) -> Plaquettes:
+        """Return a collection of plaquettes from its dictionary representation.
+
+        Args:
+            data: dictionary with the keys ``plaquettes`` and ``default``.
+
+        Returns:
+            a new instance of :class:`Plaquettes` with the provided
+            ``plaquettes`` and ``default``.
+        """
+
+        def convert(item: dict[str, Any]) -> Plaquette:
+            return (
+                Plaquette.from_dict(item["plaquette"])
+                if plaquettes is None
+                else plaquettes[item["plaquette"]]
+            )
+
+        collection = FrozenDefaultDict(
+            {int(item["index"]): convert(item) for item in data["plaquettes"]},
+            default_value=(
+                (Plaquette.from_dict(data["default"]) if data["default"] else None)
+                if plaquettes is None
+                else (
+                    plaquettes[data["default"]] if data["default"] is not None else None
+                )
+            ),
+        )
+        # If the default value is None, print a WARNING
+        # (this should not happen in practice)
+        if collection.default_value is None:
+            print(
+                "WARNING: The default value of the plaquettes collection is None. "
+                "This should not happen in practice."
+            )
+        return Plaquettes(collection)
+
 
 @dataclass(frozen=True)
 class RepeatedPlaquettes(Plaquettes):
diff --git a/src/tqec/plaquette/qubit.py b/src/tqec/plaquette/qubit.py
index 686c63e9..c152d079 100644
--- a/src/tqec/plaquette/qubit.py
+++ b/src/tqec/plaquette/qubit.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
-from typing import Iterator
+from typing import Any, Iterator
 
 from tqec.circuit.qubit import GridQubit
 from tqec.circuit.qubit_map import QubitMap
@@ -108,6 +108,34 @@ class PlaquetteQubits:
             | dict(self.syndrome_qubits_with_indices)
         )
 
+    def to_dict(self) -> dict[str, Any]:
+        """Return a dictionary representation of the plaquette qubits.
+
+        Returns:
+            a dictionary with the keys ``data_qubits`` and ``syndrome_qubits`` and
+            their corresponding values.
+        """
+        return {
+            "data_qubits": [q.to_dict() for q in self.data_qubits],
+            "syndrome_qubits": [q.to_dict() for q in self.syndrome_qubits],
+        }
+
+    @staticmethod
+    def from_dict(data: dict[str, Any]) -> PlaquetteQubits:
+        """Return a plaquette qubits from its dictionary representation.
+
+        Args:
+            data: dictionary with the keys ``data_qubits`` and
+                ``syndrome_qubits``.
+
+        Returns:
+            a new instance of :class:`PlaquetteQubits` with the provided
+            ``data_qubits`` and ``syndrome_qubits``.
+        """
+        data_qubits = [GridQubit.from_dict(q) for q in data["data_qubits"]]
+        syndrome_qubits = [GridQubit.from_dict(q) for q in data["syndrome_qubits"]]
+        return PlaquetteQubits(data_qubits, syndrome_qubits)
+
 
 class SquarePlaquetteQubits(PlaquetteQubits):
     def __init__(self) -> None:
diff --git a/src/tqec/plaquette/rpng/rpng.py b/src/tqec/plaquette/rpng/rpng.py
index 477b8a1b..f3176437 100644
--- a/src/tqec/plaquette/rpng/rpng.py
+++ b/src/tqec/plaquette/rpng/rpng.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 from dataclasses import dataclass
 from enum import Enum
 from pathlib import Path
+from typing import Any
 
 
 class PauliBasis(Enum):
@@ -285,3 +286,37 @@ class RPNGDescription:
             with open(write_to_filepath, "w") as f:
                 f.write(svg_str)
         return svg_str
+
+    def to_dict(self) -> dict[str, Any]:
+        """Return a dictionary representation of the RPNG description.
+
+        The dictionary is intended to be used as a JSON object.
+        """
+        return {
+            "corners": [str(rpng) for rpng in self.corners],
+            "ancilla": str(self.ancilla),
+        }
+
+    @staticmethod
+    def from_dict(data: dict[str, Any]) -> RPNGDescription:
+        """Return a RPNGDescription object from its dictionary representation.
+
+        Args:
+            data: dictionary with the keys ``corners`` and ``ancilla``.
+
+        Returns:
+            a new instance of :class:`RPNGDescription` with the provided
+            ``corners`` and ``ancilla``.
+        """
+        assert (
+            len(data["corners"]) == 4
+        ), "There must be 4 corners in the RPNG description."
+        corners = data["corners"]
+        corners = (
+            RPNG.from_string(corners[0]),
+            RPNG.from_string(corners[1]),
+            RPNG.from_string(corners[2]),
+            RPNG.from_string(corners[3]),
+        )
+        ancilla = RG.from_string(data["ancilla"])
+        return RPNGDescription(corners, ancilla)
diff --git a/src/tqec/utils/coordinates.py b/src/tqec/utils/coordinates.py
index 22a33bec..8152b711 100644
--- a/src/tqec/utils/coordinates.py
+++ b/src/tqec/utils/coordinates.py
@@ -4,6 +4,7 @@ base."""
 from __future__ import annotations
 
 from dataclasses import dataclass
+from typing import Any
 
 
 @dataclass(frozen=True)
@@ -59,3 +60,31 @@ class StimCoordinates:
 
     def __lt__(self, other: StimCoordinates) -> bool:
         return self.to_stim_coordinates() < other.to_stim_coordinates()
+
+    def to_dict(self) -> dict[str, Any]:
+        """Return a dictionary representation of the coordinates.
+
+        The dictionary is intended to be used as a JSON object.
+        """
+        return {
+            "x": self.x,
+            "y": self.y,
+            "t": self.t,
+        }
+
+    @staticmethod
+    def from_dict(data: dict[str, Any]) -> StimCoordinates:
+        """Return a new instance of :class:`StimCoordinates` from its dictionary
+        representation.
+
+        Args:
+            data: dictionary with the keys ``x``, ``y`` and ``t``.
+
+        Returns:
+            a new instance of :class:`StimCoordinates` with the provided
+            ``x``, ``y`` and ``t``.
+        """
+        x = data["x"]
+        y = data["y"]
+        t = data.get("t")
+        return StimCoordinates(x, y, t)
diff --git a/src/tqec/utils/rotations.py b/src/tqec/utils/rotations.py
index 87b19d5d..d04eb74e 100644
--- a/src/tqec/utils/rotations.py
+++ b/src/tqec/utils/rotations.py
@@ -38,7 +38,7 @@ from scipy.spatial.transform import Rotation as R
 
 from tqec.computation.block_graph import BlockKind, block_kind_from_str
 from tqec.utils.exceptions import TQECException
-from tqec.utils.position import Direction3D, Position3D
+from tqec.utils.position import Direction3D, Position3D, FloatPosition3D
 from tqec.utils.scale import round_or_fail
 
 
@@ -196,3 +196,52 @@ def rotate_position_by_matrix(
     rotated_center = rotation.apply(center_pos)
     rotated_corner = [round_or_fail(float(i) - 0.5) for i in rotated_center]
     return Position3D(*rotated_corner)
+
+
+def rotate_on_import(
+    rotation_matrix: npt.NDArray[np.float32],
+    translation_matrix: npt.NDArray[np.float32],
+    scale_matrix: npt.NDArray[np.float32],
+    kind: BlockKind,
+) -> tuple[FloatPosition3D, BlockKind]:
+    # Calculate rotation
+    rotation_angles = calc_rotation_angles(rotation_matrix)
+
+    # Reject invalid rotations for all other cubes/pipes:
+    if (
+        # Any rotation with angle not an integer multiply of 90 degrees: partially rotated block/pipe
+        any([int(angle) not in [0, 90, 180] for angle in rotation_angles])
+        # At least 1 * 180-deg or 2 * 90-deg rotation to avoid dimensional collapse
+        # (A single 90-deg rotation would put the rotated vector on the plane made by the other two axes)
+        or sum([angle for angle in rotation_angles]) < 180
+    ):
+        raise TQECException(
+            f"There is an invalid rotation for {kind} block at position {FloatPosition3D(*translation_matrix)}."
+        )
+
+    # Rotate node name
+    # Calculate rotated kind and directions for all axes in case it is needed
+    kind = rotate_block_kind_by_matrix(kind, rotation_matrix)
+
+    # Shift nodes slightly according to rotation
+    translation = FloatPosition3D(
+        *translation_matrix + rotation_matrix.dot(scale_matrix)
+    )
+
+    # Return revised data
+    return translation, kind
+
+
+def adjust_hadamards_direction(kind: BlockKind) -> BlockKind:
+    # List of hadamard equivalences
+    hdm_equivalences = {"ZXOH": "XZOH", "XOZH": "ZOXH", "OXZH": "OZXH"}
+
+    # Match to equivalent block given direction
+    if str(kind) in hdm_equivalences.keys():
+        kind = block_kind_from_str(hdm_equivalences[str(kind)])
+    else:
+        inv_equivalences = {value: key for key, value in hdm_equivalences.items()}
+        kind = block_kind_from_str(inv_equivalences[str(kind)])
+
+    # Return revised kind
+    return kind


Test patch:
diff --git a/src/tqec/compile/detectors/database_test.py b/src/tqec/compile/detectors/database_test.py
index 9acb2d68..e1af1bf9 100644
--- a/src/tqec/compile/detectors/database_test.py
+++ b/src/tqec/compile/detectors/database_test.py
@@ -204,3 +204,21 @@ def test_detector_database_translation_invariance() -> None:
     detectors = db.get_detectors((translated_subtemplate,), (translated_plaquettes,))
     assert detectors is not None
     assert detectors == DETECTORS[0]
+
+
+def test_detector_database_dict() -> None:
+    db = DetectorDatabase()
+    db.add_situation(SUBTEMPLATES[:1], PLAQUETTE_COLLECTIONS[:1], DETECTORS[0])
+    db.add_situation(SUBTEMPLATES[:2], PLAQUETTE_COLLECTIONS[:2], DETECTORS[1])
+
+    # Check that the database can be converted to a dict and back
+    db_dict = db.to_dict()
+    new_db = DetectorDatabase.from_dict(db_dict)
+
+    # Check that the new database has the same situations as the original
+    detectors0 = new_db.get_detectors(SUBTEMPLATES[:1], PLAQUETTE_COLLECTIONS[:1])
+    assert detectors0 is not None
+    assert detectors0 == DETECTORS[0]
+    detectors1 = new_db.get_detectors(SUBTEMPLATES[:2], PLAQUETTE_COLLECTIONS[:2])
+    assert detectors1 is not None
+    assert detectors1 == DETECTORS[1]
diff --git a/src/tqec/compile/detectors/detector_test.py b/src/tqec/compile/detectors/detector_test.py
index 6d3a0c9e..1d67f358 100644
--- a/src/tqec/compile/detectors/detector_test.py
+++ b/src/tqec/compile/detectors/detector_test.py
@@ -49,3 +49,14 @@ def test_detector_offset_spatially_by(measurement: Measurement) -> None:
         [measurement.offset_spatially_by(45, -2)]
     )
     assert offset_detector.coordinates.to_stim_coordinates() == (46, -1, 0)
+
+
+def test_detector_dict(measurement: Measurement) -> None:
+    detector = Detector(frozenset([measurement]), StimCoordinates(1, 1, 0))
+    detector_dict = detector.to_dict()
+    assert detector_dict["measurements"] == [measurement.to_dict()]
+    assert detector_dict["coordinates"] == StimCoordinates(1, 1, 0).to_dict()
+
+    new_detector = Detector.from_dict(detector_dict)
+    assert new_detector.measurements == frozenset([measurement])
+    assert new_detector.coordinates == StimCoordinates(1, 1, 0)
diff --git a/src/tqec/computation/block_graph_test.py b/src/tqec/computation/block_graph_test.py
index 5f7046b9..a6ee567e 100644
--- a/src/tqec/computation/block_graph_test.py
+++ b/src/tqec/computation/block_graph_test.py
@@ -5,7 +5,7 @@ import pytest
 from tqec.computation.block_graph import BlockGraph
 from tqec.computation.cube import ZXCube
 from tqec.computation.pipe import PipeKind
-from tqec.gallery import cz, memory, cnot
+from tqec.gallery import memory, cnot
 from tqec.utils.enums import Basis
 from tqec.utils.exceptions import TQECException
 from tqec.utils.position import Direction3D, Position3D
@@ -268,7 +268,7 @@ def test_block_graph_to_from_dict() -> None:
     assert g.from_dict(g_dict) == g
 
 
-def test_block_graph_to_from_json() -> None:
+def test_block_graph_to_json() -> None:
     g = BlockGraph("Horizontal Hadamard Line")
     n = g.add_cube(Position3D(0, 0, 0), "ZXZ")
     n2 = g.add_cube(Position3D(1, 0, 0), "P", "In")
@@ -276,15 +276,21 @@ def test_block_graph_to_from_json() -> None:
     json_text = g.to_json(indent=None)
     assert (
         json_text
-        == """{"name": "Horizontal Hadamard Line", "cubes": [{"position": [0, 0, 0], "kind": "ZXZ", "label": ""}, {"position": [1, 0, 0], "kind": "PORT", "label": "In"}], "pipes": [{"u": [0, 0, 0], "v": [1, 0, 0], "kind": "OXZH"}], "ports": {"In": [1, 0, 0]}}"""
+        == """{"name": "Horizontal Hadamard Line", "cubes": [{"position": [0, 0, 0], "kind": "ZXZ", "label": "", "transform": [[1, 0, 0], [0, 1, 0], [0, 0, 1]]}, {"position": [1, 0, 0], "kind": "PORT", "label": "In", "transform": [[1, 0, 0], [0, 1, 0], [0, 0, 1]]}], "pipes": [{"u": [0, 0, 0], "v": [1, 0, 0], "kind": "OXZH", "transform": [[1, 0, 0], [0, 1, 0], [0, 0, 1]]}], "ports": {"In": [1, 0, 0]}}"""
     )
-    assert g.from_json(json_text=json_text) == g
 
-    g = cz()
+
+def test_block_graph_from_json() -> None:
+    g = BlockGraph("Horizontal Hadamard Line")
+    n = g.add_cube(Position3D(0, 0, 0), "ZXZ")
+    n2 = g.add_cube(Position3D(1, 0, 0), "ZXZ")
+    g.add_pipe(n, n2, "OXZH")
+
     with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as temp_file:
         g.to_json(temp_file.name)
-        read_g = BlockGraph.from_json(temp_file.name)
+        read_g = BlockGraph.from_json(temp_file.name, "simple_test")
         assert read_g == g
+
     os.remove(temp_file.name)