13 KiB
Dual-ID System: UUIDs + Runtime IDs
The Problem
imgui-node-editor uses dynamic integer IDs for fast runtime access:
ed::NodeId→ int (e.g., 1, 2, 3, ...)ed::LinkId→ inted::PinId→ int
These IDs are:
- ✅ Fast: Direct array/map indexing
- ✅ Efficient: No string hashing
- ❌ Dynamic: Change between sessions
- ❌ Unstable: Not suitable for save/load
We need persistent IDs for save/load that remain stable across sessions.
The Solution: Dual-ID System
Each entity (Node/Link/Pin) has TWO IDs:
| Type | Used For | Storage | Stability |
|---|---|---|---|
UUID (ed::Uuid64) |
Save/Load (persistent) | JSON files | ✅ Stable across sessions |
Runtime ID (int/ed::NodeId) |
Runtime lookups (fast) | Memory only | ❌ Changes each session |
struct Node {
ed::NodeId ID; // Runtime ID (dynamic) - for imgui-node-editor
ed::Uuid64 UUID; // Persistent ID (stable) - for save/load
// ... other fields
};
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Application │
│ │
│ ┌────────────────┐ ┌────────────────────┐ │
│ │ UuidIdManager │◄──────────────►│ Node/Link/Pin │ │
│ │ │ │ Structures │ │
│ │ UUID ↔ ID Map │ │ │ │
│ └────────────────┘ │ - UUID (persist) │ │
│ ▲ │ - ID (runtime) │ │
│ │ └────────────────────┘ │
│ │ │ │
│ │ ▼ │
│ ┌──────┴────────────┐ ┌───────────────────────┐ │
│ │ Save/Load │ │ imgui-node-editor │ │
│ │ (uses UUIDs) │ │ (uses Runtime IDs) │ │
│ └───────────────────┘ └───────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Workflow
1. Creating New Nodes (Runtime)
// Generate runtime ID (from container ID generator)
int runtimeId = container->GetNextId();
// Generate persistent UUID
ed::Uuid64 uuid = app->m_UuidIdManager.GenerateUuid();
// Create node with both IDs
Node newNode(runtimeId, "MyNode");
newNode.UUID = uuid;
// Register mapping: UUID ↔ Runtime ID
app->m_UuidIdManager.RegisterNode(uuid, runtimeId);
// imgui-node-editor uses Runtime ID
ed::BeginNode(newNode.ID); // Uses runtimeId
2. Saving Graph (Serialize UUIDs)
void SaveGraph(const std::string& filename)
{
crude_json::value root;
for (auto* node : nodes)
{
crude_json::value nodeData;
// ✅ SAVE UUID (persistent, stable)
nodeData["uuid_high"] = (double)node->UUID.high;
nodeData["uuid_low"] = (double)node->UUID.low;
// ❌ DON'T save runtime ID (changes each session)
// nodeData["id"] = node->ID.Get(); // WRONG!
nodeData["name"] = node->Name;
nodeData["type"] = node->BlockType;
// Save pins (also use UUIDs)
for (auto& pin : node->Inputs)
{
crude_json::value pinData;
pinData["uuid_high"] = (double)pin.UUID.high;
pinData["uuid_low"] = (double)pin.UUID.low;
pinData["name"] = pin.Name;
// ... etc
}
root["nodes"].push_back(nodeData);
}
// Save links with PIN UUIDs (not runtime pin IDs!)
for (auto* link : links)
{
crude_json::value linkData;
linkData["uuid_high"] = (double)link->UUID.high;
linkData["uuid_low"] = (double)link->UUID.low;
// Find start/end pins and save their UUIDs
Pin* startPin = FindPin(link->StartPinID);
Pin* endPin = FindPin(link->EndPinID);
linkData["start_pin_uuid_high"] = (double)startPin->UUID.high;
linkData["start_pin_uuid_low"] = (double)startPin->UUID.low;
linkData["end_pin_uuid_high"] = (double)endPin->UUID.high;
linkData["end_pin_uuid_low"] = (double)endPin->UUID.low;
root["links"].push_back(linkData);
}
}
3. Loading Graph (Resolve UUIDs → Generate New Runtime IDs)
void LoadGraph(const std::string& filename)
{
// Clear old mappings
m_UuidIdManager.Clear();
crude_json::value root = crude_json::value::parse(fileData);
// ========== Load Nodes ==========
for (auto& nodeData : root["nodes"])
{
// ✅ Load UUID (persistent)
ed::Uuid64 uuid(
(uint32_t)nodeData["uuid_high"].get<double>(),
(uint32_t)nodeData["uuid_low"].get<double>()
);
// Generate NEW runtime ID (don't reuse old IDs!)
int runtimeId = container->GetNextId();
// Create node
Node newNode(runtimeId, nodeData["name"].get<std::string>().c_str());
newNode.UUID = uuid;
newNode.BlockType = nodeData["type"].get<std::string>();
// Build node structure (adds pins)
BuildNodeStructure(newNode);
// Load pin UUIDs and register them
for (size_t i = 0; i < newNode.Inputs.size(); ++i)
{
ed::Uuid64 pinUuid(
(uint32_t)nodeData["inputs"][i]["uuid_high"].get<double>(),
(uint32_t)nodeData["inputs"][i]["uuid_low"].get<double>()
);
newNode.Inputs[i].UUID = pinUuid;
// Register pin mapping
m_UuidIdManager.RegisterPin(pinUuid, newNode.Inputs[i].ID.Get());
}
// Add to container
Node* addedNode = container->AddNode(newNode);
// ✅ Register mapping: UUID → Runtime ID
m_UuidIdManager.RegisterNode(uuid, runtimeId);
}
// ========== Load Links ==========
for (auto& linkData : root["links"])
{
// Load link UUID
ed::Uuid64 linkUuid(
(uint32_t)linkData["uuid_high"].get<double>(),
(uint32_t)linkData["uuid_low"].get<double>()
);
// Load start/end pin UUIDs
ed::Uuid64 startPinUuid(
(uint32_t)linkData["start_pin_uuid_high"].get<double>(),
(uint32_t)linkData["start_pin_uuid_low"].get<double>()
);
ed::Uuid64 endPinUuid(
(uint32_t)linkData["end_pin_uuid_high"].get<double>(),
(uint32_t)linkData["end_pin_uuid_low"].get<double>()
);
// ✅ Resolve pin UUIDs → Runtime IDs
int startPinRuntimeId = m_UuidIdManager.GetPinRuntimeId(startPinUuid);
int endPinRuntimeId = m_UuidIdManager.GetPinRuntimeId(endPinUuid);
if (startPinRuntimeId < 0 || endPinRuntimeId < 0)
{
printf("[LoadGraph] WARNING: Link references missing pin\n");
continue; // Skip orphaned link
}
// Generate new runtime ID for link
int linkRuntimeId = container->GetNextId();
// Create link with resolved runtime pin IDs
Link newLink(ed::LinkId(linkRuntimeId),
ed::PinId(startPinRuntimeId),
ed::PinId(endPinRuntimeId));
newLink.UUID = linkUuid;
// Add link
Link* addedLink = container->AddLink(newLink);
// Register link mapping
m_UuidIdManager.RegisterLink(linkUuid, linkRuntimeId);
}
}
Key Principles
✅ DO:
-
Generate UUIDs on creation (not on save!)
Node* SpawnNode() { int runtimeId = GetNextId(); ed::Uuid64 uuid = m_UuidIdManager.GenerateUuid(); // ✅ Assign immediately Node node(runtimeId, "Name"); node.UUID = uuid; m_UuidIdManager.RegisterNode(uuid, runtimeId); return addedNode; } -
Save UUIDs (persistent identifiers)
nodeData["uuid_high"] = node->UUID.high; nodeData["uuid_low"] = node->UUID.low; -
Load UUIDs, generate new runtime IDs
ed::Uuid64 uuid = LoadUuidFromJson(nodeData); int newRuntimeId = container->GetNextId(); // Fresh ID! m_UuidIdManager.RegisterNode(uuid, newRuntimeId); -
Link via UUIDs in save files (not runtime IDs)
linkData["start_pin_uuid"] = startPin->UUID; // ✅
❌ DON'T:
-
Don't save runtime IDs
nodeData["id"] = node->ID.Get(); // ❌ Will change next session! -
Don't reuse runtime IDs from save file
int runtimeId = nodeData["id"].get<int>(); // ❌ Conflicts! -
Don't reference runtime IDs in save files
linkData["start_pin_id"] = link->StartPinID.Get(); // ❌ Unstable! -
Don't forget to register mappings
Node node(runtimeId, "Name"); node.UUID = uuid; // m_UuidIdManager.RegisterNode(uuid, runtimeId); // ❌ FORGOT!
Benefits
| Feature | Without UUIDs | With Dual-ID System |
|---|---|---|
| Runtime Performance | Fast ✅ | Fast ✅ (uses runtime IDs) |
| Save/Load Stability | ❌ Breaks on ID changes | ✅ Stable across sessions |
| Node Reference Integrity | ❌ Can break | ✅ Preserved |
| Merge Graphs | ❌ ID conflicts | ✅ UUIDs unique |
| Debugging | ❌ IDs change | ✅ UUIDs stable |
Migration Strategy
Phase 1: Add UUID fields (✅ DONE)
- Added
UUIDtoNode,Link,Pinstructures - Created
UuidIdManagerclass
Phase 2: Generate UUIDs on creation
- Modify
SpawnBlockNode()to generate UUIDs - Modify
SpawnParameterNode()to generate UUIDs - Register all UUID ↔ Runtime ID mappings
Phase 3: Update Save logic
- Save UUIDs instead of runtime IDs
- Use pin UUIDs for link serialization
Phase 4: Update Load logic
- Load UUIDs from file
- Generate fresh runtime IDs
- Populate UUID ↔ Runtime ID mappings
- Resolve pin UUIDs for links
Phase 5: Backward compatibility (optional)
- Detect old format (has runtime IDs)
- Auto-generate UUIDs for old saves
- Resave in new format
Example: Complete Node Lifecycle
// ========== CREATION ==========
// (Happens in SpawnBlockNode)
int runtimeId = container->GetNextId(); // e.g., 5
ed::Uuid64 uuid = m_UuidIdManager.GenerateUuid(); // e.g., {0x1000000, 0x0}
Node node(runtimeId, "Add");
node.UUID = uuid;
m_UuidIdManager.RegisterNode(uuid, runtimeId);
// ========== RUNTIME ==========
// imgui-node-editor uses runtime ID
ed::BeginNode(node.ID); // Uses 5
ed::EndNode();
// ========== SAVE ==========
nodeData["uuid_high"] = 0x1000000;
nodeData["uuid_low"] = 0x0;
// Runtime ID (5) is NOT saved
// ========== LOAD (Next Session) ==========
ed::Uuid64 loadedUuid(0x1000000, 0x0); // Same as before ✅
int newRuntimeId = container->GetNextId(); // Could be 12 now!
Node newNode(newRuntimeId, "Add");
newNode.UUID = loadedUuid;
m_UuidIdManager.RegisterNode(loadedUuid, newRuntimeId);
// Now:
// - Node has NEW runtime ID (12)
// - Node has SAME UUID ({0x1000000, 0x0})
// - imgui-node-editor uses 12
// - Next save will use UUID again
FAQ
Q: Why not use UUIDs directly as imgui-node-editor IDs?
A: imgui-node-editor expects int IDs for performance. Converting Uuid64 to int would lose information or require hashing (slow).
Q: What happens if I forget to assign a UUID?
A: Node will have UUID{0,0} (invalid). Detect with uuid.IsValid(). Save will fail or generate UUID on-the-fly.
Q: Can I use random UUIDs instead of sequential?
A: Yes! Change GenerateUuid() to use GenerateRandom64(). Sequential is better for debugging.
Q: How do I migrate existing save files?
A: Detect old format (has "id" field), auto-generate UUIDs, register mappings, resave.
Q: Do pins need UUIDs too?
A: Yes! Links reference pins by UUID. Otherwise, link loading will break.
Summary
- Two IDs per entity: UUID (persistent) + Runtime ID (dynamic)
- Save UUIDs to JSON (stable across sessions)
- Generate new runtime IDs on load (avoid conflicts)
- UuidIdManager maintains UUID ↔ Runtime ID mapping
- imgui-node-editor uses runtime IDs (no changes needed)
- Result: Stable save files + fast runtime performance