@vuer-ai/vuer-rtc 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/EditBuffer.d.ts +43 -0
- package/dist/client/EditBuffer.d.ts.map +1 -0
- package/dist/client/EditBuffer.js +96 -0
- package/dist/client/EditBuffer.js.map +1 -0
- package/dist/client/actions.d.ts +66 -0
- package/dist/client/actions.d.ts.map +1 -0
- package/dist/client/actions.js +345 -0
- package/dist/client/actions.js.map +1 -0
- package/dist/client/createGraph.d.ts +30 -0
- package/dist/client/createGraph.d.ts.map +1 -0
- package/dist/client/createGraph.js +91 -0
- package/dist/client/createGraph.js.map +1 -0
- package/dist/client/hooks.d.ts +81 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +161 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/client/index.d.ts +8 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +10 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/types.d.ts +74 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +11 -0
- package/dist/client/types.js.map +1 -0
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +7 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/operations/OperationTypes.d.ts +239 -0
- package/dist/operations/OperationTypes.d.ts.map +1 -0
- package/dist/operations/OperationTypes.js +10 -0
- package/dist/operations/OperationTypes.js.map +1 -0
- package/dist/operations/OperationValidator.d.ts +32 -0
- package/dist/operations/OperationValidator.d.ts.map +1 -0
- package/dist/operations/OperationValidator.js +208 -0
- package/dist/operations/OperationValidator.js.map +1 -0
- package/dist/operations/apply/array.d.ts +22 -0
- package/dist/operations/apply/array.d.ts.map +1 -0
- package/dist/operations/apply/array.js +64 -0
- package/dist/operations/apply/array.js.map +1 -0
- package/dist/operations/apply/boolean.d.ts +18 -0
- package/dist/operations/apply/boolean.d.ts.map +1 -0
- package/dist/operations/apply/boolean.js +34 -0
- package/dist/operations/apply/boolean.js.map +1 -0
- package/dist/operations/apply/color.d.ts +14 -0
- package/dist/operations/apply/color.d.ts.map +1 -0
- package/dist/operations/apply/color.js +46 -0
- package/dist/operations/apply/color.js.map +1 -0
- package/dist/operations/apply/index.d.ts +18 -0
- package/dist/operations/apply/index.d.ts.map +1 -0
- package/dist/operations/apply/index.js +26 -0
- package/dist/operations/apply/index.js.map +1 -0
- package/dist/operations/apply/node.d.ts +24 -0
- package/dist/operations/apply/node.d.ts.map +1 -0
- package/dist/operations/apply/node.js +77 -0
- package/dist/operations/apply/node.js.map +1 -0
- package/dist/operations/apply/number.d.ts +26 -0
- package/dist/operations/apply/number.d.ts.map +1 -0
- package/dist/operations/apply/number.js +54 -0
- package/dist/operations/apply/number.js.map +1 -0
- package/dist/operations/apply/object.d.ts +14 -0
- package/dist/operations/apply/object.d.ts.map +1 -0
- package/dist/operations/apply/object.js +47 -0
- package/dist/operations/apply/object.js.map +1 -0
- package/dist/operations/apply/quaternion.d.ts +15 -0
- package/dist/operations/apply/quaternion.d.ts.map +1 -0
- package/dist/operations/apply/quaternion.js +33 -0
- package/dist/operations/apply/quaternion.js.map +1 -0
- package/dist/operations/apply/string.d.ts +14 -0
- package/dist/operations/apply/string.d.ts.map +1 -0
- package/dist/operations/apply/string.js +26 -0
- package/dist/operations/apply/string.js.map +1 -0
- package/dist/operations/apply/types.d.ts +34 -0
- package/dist/operations/apply/types.d.ts.map +1 -0
- package/dist/operations/apply/types.js +32 -0
- package/dist/operations/apply/types.js.map +1 -0
- package/dist/operations/apply/vector3.d.ts +18 -0
- package/dist/operations/apply/vector3.d.ts.map +1 -0
- package/dist/operations/apply/vector3.js +44 -0
- package/dist/operations/apply/vector3.js.map +1 -0
- package/dist/operations/dispatcher.d.ts +35 -0
- package/dist/operations/dispatcher.d.ts.map +1 -0
- package/dist/operations/dispatcher.js +107 -0
- package/dist/operations/dispatcher.js.map +1 -0
- package/dist/operations/index.d.ts +10 -0
- package/dist/operations/index.d.ts.map +1 -0
- package/dist/operations/index.js +17 -0
- package/dist/operations/index.js.map +1 -0
- package/dist/state/ConflictResolver.d.ts +36 -0
- package/dist/state/ConflictResolver.d.ts.map +1 -0
- package/dist/state/ConflictResolver.js +167 -0
- package/dist/state/ConflictResolver.js.map +1 -0
- package/dist/state/DType.d.ts +160 -0
- package/dist/state/DType.d.ts.map +1 -0
- package/dist/state/DType.js +282 -0
- package/dist/state/DType.js.map +1 -0
- package/dist/state/Schema.d.ts +32 -0
- package/dist/state/Schema.d.ts.map +1 -0
- package/dist/state/Schema.js +175 -0
- package/dist/state/Schema.js.map +1 -0
- package/dist/state/VectorClock.d.ts +42 -0
- package/dist/state/VectorClock.d.ts.map +1 -0
- package/dist/state/VectorClock.js +84 -0
- package/dist/state/VectorClock.js.map +1 -0
- package/dist/state/index.d.ts +11 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +13 -0
- package/dist/state/index.js.map +1 -0
- package/docs/OPERATION_HINTS.md +222 -0
- package/docs/SCENE_GRAPH.md +373 -0
- package/docs/TYPE_BEHAVIORS.md +348 -0
- package/examples/01-basic-usage.ts +139 -0
- package/examples/02-concurrent-edits.ts +208 -0
- package/examples/03-scene-building.ts +258 -0
- package/examples/04-conflict-resolution.ts +339 -0
- package/examples/README.md +86 -0
- package/jest.config.js +19 -0
- package/package.json +57 -0
- package/src/client/EditBuffer.ts +105 -0
- package/src/client/actions.ts +397 -0
- package/src/client/createGraph.ts +132 -0
- package/src/client/hooks.tsx +249 -0
- package/src/client/index.ts +35 -0
- package/src/client/types.ts +94 -0
- package/src/hooks.ts +20 -0
- package/src/index.ts +14 -0
- package/src/operations/OperationTypes.ts +340 -0
- package/src/operations/OperationValidator.ts +260 -0
- package/src/operations/apply/array.ts +84 -0
- package/src/operations/apply/boolean.ts +48 -0
- package/src/operations/apply/color.ts +65 -0
- package/src/operations/apply/index.ts +37 -0
- package/src/operations/apply/node.ts +98 -0
- package/src/operations/apply/number.ts +76 -0
- package/src/operations/apply/object.ts +63 -0
- package/src/operations/apply/quaternion.ts +47 -0
- package/src/operations/apply/string.ts +36 -0
- package/src/operations/apply/types.ts +66 -0
- package/src/operations/apply/vector3.ts +60 -0
- package/src/operations/dispatcher.ts +127 -0
- package/src/operations/index.ts +80 -0
- package/src/state/ConflictResolver.ts +205 -0
- package/src/state/DType.ts +333 -0
- package/src/state/Schema.ts +236 -0
- package/src/state/VectorClock.ts +98 -0
- package/src/state/index.ts +14 -0
- package/tests/client/actions.test.ts +371 -0
- package/tests/client/edit-buffer.test.ts +117 -0
- package/tests/fixtures/array-ops.jsonl +6 -0
- package/tests/fixtures/boolean-ops.jsonl +6 -0
- package/tests/fixtures/color-ops.jsonl +4 -0
- package/tests/fixtures/edit-buffer.jsonl +3 -0
- package/tests/fixtures/node-ops.jsonl +6 -0
- package/tests/fixtures/number-ops.jsonl +7 -0
- package/tests/fixtures/object-ops.jsonl +4 -0
- package/tests/fixtures/operations.jsonl +7 -0
- package/tests/fixtures/string-ops.jsonl +4 -0
- package/tests/fixtures/undo-redo.jsonl +3 -0
- package/tests/fixtures/vector-ops.jsonl +9 -0
- package/tests/operations/collections.test.ts +193 -0
- package/tests/operations/nodes.test.ts +228 -0
- package/tests/operations/primitives.test.ts +222 -0
- package/tests/operations/vectors.test.ts +150 -0
- package/tsconfig.json +21 -0
- package/tsconfig.test.json +9 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/state/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,2CAA2C;AAC3C,OAAO,EAAE,kBAAkB,EAAoB,MAAM,kBAAkB,CAAC;AAExE,4CAA4C;AAC5C,OAAO,EAAE,KAAK,EAAoC,MAAM,YAAY,CAAC;AACrE,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,WAAW,EAAuB,MAAM,aAAa,CAAC;AACnG,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC"}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Explicit Operation Types: Additive vs Absolute Updates
|
|
2
|
+
|
|
3
|
+
## The Problem
|
|
4
|
+
|
|
5
|
+
In a collaborative 3D editor, the frontend knows the user's **intent**:
|
|
6
|
+
- **Additive**: `position.x += 5` (relative movement)
|
|
7
|
+
- **Absolute**: `position.x = 10` (set to specific value)
|
|
8
|
+
|
|
9
|
+
## The Solution: Explicit otype
|
|
10
|
+
|
|
11
|
+
Operations use explicit `otype` that encodes both dtype and operation:
|
|
12
|
+
- `vector3.add` = additive vector operation
|
|
13
|
+
- `vector3.set` = absolute vector operation
|
|
14
|
+
- `number.add` = additive number operation
|
|
15
|
+
- `number.set` = absolute number operation
|
|
16
|
+
|
|
17
|
+
### CRDTMessage Structure
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
interface CRDTMessage {
|
|
21
|
+
// === CRDT Metadata (envelope) ===
|
|
22
|
+
id: string; // Message ID
|
|
23
|
+
sessionId: string; // Session that created this message
|
|
24
|
+
clock: VectorClock; // Vector clock for causal ordering
|
|
25
|
+
lamportTime: number; // Lamport timestamp for total ordering
|
|
26
|
+
timestamp: number; // Wall-clock time
|
|
27
|
+
|
|
28
|
+
// === Operations (batch) ===
|
|
29
|
+
ops: Operation[]; // One or more operations
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Operation Structure
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
interface BaseOp {
|
|
37
|
+
key: string; // Node key (e.g., 'cube-1', 'player')
|
|
38
|
+
otype: string; // Explicit dtype.operation (e.g., 'vector3.add')
|
|
39
|
+
path: string; // Property path (e.g., 'transform.position')
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Frontend Use Cases
|
|
44
|
+
|
|
45
|
+
### Case 1: Additive Transform (Relative Movement)
|
|
46
|
+
|
|
47
|
+
User drags object by [5, 0, 0]:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
const msg: CRDTMessage = {
|
|
51
|
+
id: 'msg-001',
|
|
52
|
+
sessionId: 'session-alice',
|
|
53
|
+
clock: { 'session-alice': 1 },
|
|
54
|
+
lamportTime: 1,
|
|
55
|
+
timestamp: Date.now(),
|
|
56
|
+
ops: [
|
|
57
|
+
{
|
|
58
|
+
key: 'cube-1',
|
|
59
|
+
otype: 'vector3.add', // Explicit additive!
|
|
60
|
+
path: 'transform.position',
|
|
61
|
+
value: [5, 0, 0]
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
};
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Result**: If two users drag simultaneously, both movements apply:
|
|
68
|
+
- Alice: `position += [5, 0, 0]`
|
|
69
|
+
- Bob: `position += [0, 3, 0]`
|
|
70
|
+
- Final: `position += [5, 3, 0]` ✅
|
|
71
|
+
|
|
72
|
+
### Case 2: Absolute Transform (Set Position)
|
|
73
|
+
|
|
74
|
+
User enters exact coordinates in inspector:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
const msg: CRDTMessage = {
|
|
78
|
+
id: 'msg-002',
|
|
79
|
+
sessionId: 'session-alice',
|
|
80
|
+
clock: { 'session-alice': 2 },
|
|
81
|
+
lamportTime: 2,
|
|
82
|
+
timestamp: Date.now(),
|
|
83
|
+
ops: [
|
|
84
|
+
{
|
|
85
|
+
key: 'sphere-1',
|
|
86
|
+
otype: 'vector3.set', // Explicit absolute!
|
|
87
|
+
path: 'transform.position',
|
|
88
|
+
value: [10, 5, 0]
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
};
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Result**: If two users set position simultaneously, last-write-wins:
|
|
95
|
+
- Alice: `position = [10, 5, 0]` (lamport: 100)
|
|
96
|
+
- Bob: `position = [0, 10, 0]` (lamport: 101)
|
|
97
|
+
- Final: `position = [0, 10, 0]` (Bob wins) ✅
|
|
98
|
+
|
|
99
|
+
### Case 3: Score Counter (Always Additive)
|
|
100
|
+
|
|
101
|
+
User collects 10 points:
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
const msg: CRDTMessage = {
|
|
105
|
+
id: 'msg-003',
|
|
106
|
+
sessionId: 'session-alice',
|
|
107
|
+
clock: { 'session-alice': 3 },
|
|
108
|
+
lamportTime: 3,
|
|
109
|
+
timestamp: Date.now(),
|
|
110
|
+
ops: [
|
|
111
|
+
{
|
|
112
|
+
key: 'cube-1',
|
|
113
|
+
otype: 'number.add', // Additive counter
|
|
114
|
+
path: 'score',
|
|
115
|
+
value: 10
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
};
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Result**: Concurrent score updates sum:
|
|
122
|
+
- Alice: `score += 10`
|
|
123
|
+
- Bob: `score += 5`
|
|
124
|
+
- Final: `score = 15` ✅
|
|
125
|
+
|
|
126
|
+
### Case 4: Color (Always LWW)
|
|
127
|
+
|
|
128
|
+
User changes material color:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
const msg: CRDTMessage = {
|
|
132
|
+
id: 'msg-004',
|
|
133
|
+
sessionId: 'session-alice',
|
|
134
|
+
clock: { 'session-alice': 4 },
|
|
135
|
+
lamportTime: 4,
|
|
136
|
+
timestamp: Date.now(),
|
|
137
|
+
ops: [
|
|
138
|
+
{
|
|
139
|
+
key: 'cube-1',
|
|
140
|
+
otype: 'color.set',
|
|
141
|
+
path: 'color',
|
|
142
|
+
value: '#ff0000'
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
};
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Result**: Concurrent color changes resolve to latest:
|
|
149
|
+
- Alice: `color = '#ff0000'` (lamport: 100)
|
|
150
|
+
- Bob: `color = '#00ff00'` (lamport: 101)
|
|
151
|
+
- Final: `color = '#00ff00'` (Bob wins) ✅
|
|
152
|
+
|
|
153
|
+
## Batching: Multiple Operations in One Message
|
|
154
|
+
|
|
155
|
+
### Same Node, Multiple Properties
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
const msg: CRDTMessage = {
|
|
159
|
+
id: 'msg-005',
|
|
160
|
+
sessionId: 'session-alice',
|
|
161
|
+
clock: { 'session-alice': 5 },
|
|
162
|
+
lamportTime: 5,
|
|
163
|
+
timestamp: Date.now(),
|
|
164
|
+
ops: [
|
|
165
|
+
{ key: 'cube-1', otype: 'color.set', path: 'color', value: '#00ff00' },
|
|
166
|
+
{ key: 'cube-1', otype: 'number.set', path: 'opacity', value: 0.5 },
|
|
167
|
+
{ key: 'cube-1', otype: 'vector3.add', path: 'transform.position', value: [0, 2, 0] }
|
|
168
|
+
]
|
|
169
|
+
};
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Multi-Select Drag (Multiple Nodes)
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
const msg: CRDTMessage = {
|
|
176
|
+
id: 'msg-006',
|
|
177
|
+
sessionId: 'session-alice',
|
|
178
|
+
clock: { 'session-alice': 6 },
|
|
179
|
+
lamportTime: 6,
|
|
180
|
+
timestamp: Date.now(),
|
|
181
|
+
ops: [
|
|
182
|
+
{ key: 'cube-1', otype: 'vector3.add', path: 'transform.position', value: [3, 0, 0] },
|
|
183
|
+
{ key: 'sphere-1', otype: 'vector3.add', path: 'transform.position', value: [3, 0, 0] }
|
|
184
|
+
]
|
|
185
|
+
};
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Available Operation Types
|
|
189
|
+
|
|
190
|
+
| otype | Description | Example |
|
|
191
|
+
|-------|-------------|---------|
|
|
192
|
+
| `number.set` | LWW number | `opacity = 0.5` |
|
|
193
|
+
| `number.add` | Additive | `score += 10` |
|
|
194
|
+
| `number.multiply` | Multiplicative | `scale *= 2` |
|
|
195
|
+
| `number.min` | Take minimum | `minScore = min(current, new)` |
|
|
196
|
+
| `number.max` | Take maximum | `maxScore = max(current, new)` |
|
|
197
|
+
| `vector3.set` | LWW vector | `position = [0, 5, 0]` |
|
|
198
|
+
| `vector3.add` | Additive | `position += [5, 0, 0]` |
|
|
199
|
+
| `color.set` | LWW color | `color = '#ff0000'` |
|
|
200
|
+
| `array.set` | Replace array | `children = ['a', 'b']` |
|
|
201
|
+
| `array.push` | Append item | `children.push('c')` |
|
|
202
|
+
| `array.remove` | Remove item | `children.remove('a')` |
|
|
203
|
+
| `node.insert` | Create node | New node in scene |
|
|
204
|
+
| `node.remove` | Delete node | Tombstone |
|
|
205
|
+
|
|
206
|
+
## Example: Transform Gestures
|
|
207
|
+
|
|
208
|
+
| Gesture | otype | path | value |
|
|
209
|
+
|---------|-------|------|-------|
|
|
210
|
+
| Drag (translate) | `vector3.add` | `transform.position` | `[dx, dy, dz]` |
|
|
211
|
+
| Inspector input | `vector3.set` | `transform.position` | `[x, y, z]` |
|
|
212
|
+
| Scale gesture | `vector3.multiply` | `transform.scale` | `[sx, sy, sz]` |
|
|
213
|
+
| Snap to grid | `vector3.set` | `transform.position` | `[gx, gy, gz]` |
|
|
214
|
+
|
|
215
|
+
## Key Design Points
|
|
216
|
+
|
|
217
|
+
1. **Explicit otype**: `vector3.add` vs `vector3.set` - no ambiguity
|
|
218
|
+
2. **Operations use `key`**: Human-friendly node keys, not UUIDs
|
|
219
|
+
3. **True batching**: One message, multiple ops, atomic
|
|
220
|
+
4. **Clean structure**: No nested "properties" or "operations" objects
|
|
221
|
+
|
|
222
|
+
See `docs/TYPE_BEHAVIORS.md` for complete merge semantics.
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# Scene Graph Data Structure
|
|
2
|
+
|
|
3
|
+
Hierarchical tree structure for collaborative 3D editing with **instancing support** via flattened map format.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Scene Graph Schema](#scene-graph-schema)
|
|
8
|
+
- [Scene Node Schema](#scene-node-schema)
|
|
9
|
+
- [CRDTMessage Structure](#crdtmessage-structure)
|
|
10
|
+
- [Operations](#operations)
|
|
11
|
+
- [node.insert](#nodeinsert)
|
|
12
|
+
- [node.remove](#noderemove)
|
|
13
|
+
- [Property Operations](#property-operations)
|
|
14
|
+
- [Array Operations](#array-operations)
|
|
15
|
+
- [Tree Operations](#tree-operations)
|
|
16
|
+
- [Reparent (Compound)](#reparent-compound)
|
|
17
|
+
- [Instancing](#instancing)
|
|
18
|
+
- [Query Patterns](#query-patterns)
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Scene Graph Schema
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
interface SceneGraph {
|
|
26
|
+
nodes: Record<string, SceneNode>; // Flattened map by key
|
|
27
|
+
rootKey: string; // Root node key
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Design:** Flattened map (similar to glTF) for O(1) lookup and stable references.
|
|
32
|
+
|
|
33
|
+
**Example:**
|
|
34
|
+
```typescript
|
|
35
|
+
{
|
|
36
|
+
nodes: {
|
|
37
|
+
'scene': { key: 'scene', tag: 'Scene', children: ['forest', 'player'], ... },
|
|
38
|
+
'forest': { key: 'forest', tag: 'Group', children: ['tree-1'], ... },
|
|
39
|
+
'tree-1': { key: 'tree-1', tag: 'Mesh', children: [], ... },
|
|
40
|
+
'player': { key: 'player', tag: 'Mesh', children: [], ... }
|
|
41
|
+
},
|
|
42
|
+
rootKey: 'scene'
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Scene Node Schema
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
interface SceneNode {
|
|
52
|
+
// Identity
|
|
53
|
+
id: string; // UUID (for CRDT)
|
|
54
|
+
key: string; // Map key (human-friendly, unique in scene)
|
|
55
|
+
tag: string; // "Scene" | "Group" | "Mesh" | "Light" | "Camera"
|
|
56
|
+
name: string; // Display name
|
|
57
|
+
|
|
58
|
+
// Tree structure (children-only for instancing)
|
|
59
|
+
children: string[]; // Child keys (not IDs!)
|
|
60
|
+
|
|
61
|
+
// Properties (flat structure with dot-notation paths)
|
|
62
|
+
// e.g., 'transform.position': [0, 0, 0], 'color': '#ff0000'
|
|
63
|
+
[key: string]: any;
|
|
64
|
+
|
|
65
|
+
// CRDT metadata
|
|
66
|
+
clock: VectorClock;
|
|
67
|
+
lamportTime: number;
|
|
68
|
+
createdAt: number;
|
|
69
|
+
updatedAt: number;
|
|
70
|
+
deletedAt?: number; // Tombstone (soft delete)
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Key Design Decisions:**
|
|
75
|
+
- **Flattened map** by `key` (not nested tree)
|
|
76
|
+
- **No `parentId`** → supports **instancing** (same node can have multiple parents)
|
|
77
|
+
- **Operations use `key`** → human-readable references
|
|
78
|
+
- **Children use keys** (not IDs) → human-readable tree structure
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## CRDTMessage Structure
|
|
83
|
+
|
|
84
|
+
All operations are wrapped in a `CRDTMessage` envelope:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
interface CRDTMessage {
|
|
88
|
+
// === CRDT Metadata (envelope) ===
|
|
89
|
+
id: string; // Message ID
|
|
90
|
+
sessionId: string; // Session that created this message
|
|
91
|
+
clock: VectorClock; // Vector clock for causal ordering
|
|
92
|
+
lamportTime: number; // Lamport timestamp for total ordering
|
|
93
|
+
timestamp: number; // Wall-clock time
|
|
94
|
+
|
|
95
|
+
// === Operations (batch) ===
|
|
96
|
+
ops: Operation[]; // One or more operations
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface BaseOp {
|
|
100
|
+
key: string; // Node key (e.g., 'cube-1', 'scene')
|
|
101
|
+
otype: string; // dtype.operation (e.g., 'vector3.add', 'node.insert')
|
|
102
|
+
path: string; // Property path (e.g., 'transform.position')
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Operations
|
|
109
|
+
|
|
110
|
+
### node.insert
|
|
111
|
+
|
|
112
|
+
Create a new node in the scene graph.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
const msg: CRDTMessage = {
|
|
116
|
+
id: 'msg-001',
|
|
117
|
+
sessionId: 'session-alice',
|
|
118
|
+
clock: { 'session-alice': 1 },
|
|
119
|
+
lamportTime: 1,
|
|
120
|
+
timestamp: Date.now(),
|
|
121
|
+
ops: [
|
|
122
|
+
{
|
|
123
|
+
key: 'cube-1',
|
|
124
|
+
otype: 'node.insert',
|
|
125
|
+
path: 'cube-1',
|
|
126
|
+
value: {
|
|
127
|
+
id: 'uuid-cube-001',
|
|
128
|
+
tag: 'Mesh',
|
|
129
|
+
name: 'Red Cube',
|
|
130
|
+
color: '#ff0000',
|
|
131
|
+
'transform.position': [0, 0, 0],
|
|
132
|
+
'transform.rotation': [0, 0, 0, 1],
|
|
133
|
+
'transform.scale': [1, 1, 1]
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Result: Added to nodes map
|
|
140
|
+
// nodes['cube-1'] = { id: 'uuid-cube-001', key: 'cube-1', tag: 'Mesh', ... }
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Behavior:**
|
|
144
|
+
- Adds node to `nodes` map: `nodes[key] = node`
|
|
145
|
+
- If `key` already exists → idempotent (no-op)
|
|
146
|
+
- Does NOT auto-add to parent (use separate `array.push` on parent's `children`)
|
|
147
|
+
|
|
148
|
+
**Conflicts:**
|
|
149
|
+
- Different keys → both succeed
|
|
150
|
+
- Same key → first wins (idempotent)
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
### node.remove
|
|
155
|
+
|
|
156
|
+
Delete node (tombstone) + remove from all parents' children.
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
const msg: CRDTMessage = {
|
|
160
|
+
id: 'msg-002',
|
|
161
|
+
sessionId: 'session-bob',
|
|
162
|
+
clock: { 'session-bob': 1 },
|
|
163
|
+
lamportTime: 2,
|
|
164
|
+
timestamp: Date.now(),
|
|
165
|
+
ops: [
|
|
166
|
+
{
|
|
167
|
+
key: 'cube-1',
|
|
168
|
+
otype: 'node.remove',
|
|
169
|
+
path: 'cube-1'
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
};
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Behavior:**
|
|
176
|
+
1. Set `node.deletedAt = timestamp` (tombstone)
|
|
177
|
+
2. Remove `cube-1` from all parents' `children` arrays
|
|
178
|
+
|
|
179
|
+
**Conflicts:**
|
|
180
|
+
- remove **wins** over concurrent property update
|
|
181
|
+
- Multiple removes → idempotent
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
### Property Operations
|
|
186
|
+
|
|
187
|
+
Update node properties using explicit otype:
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
const msg: CRDTMessage = {
|
|
191
|
+
id: 'msg-003',
|
|
192
|
+
sessionId: 'session-alice',
|
|
193
|
+
clock: { 'session-alice': 2 },
|
|
194
|
+
lamportTime: 3,
|
|
195
|
+
timestamp: Date.now(),
|
|
196
|
+
ops: [
|
|
197
|
+
// Additive transform (drag)
|
|
198
|
+
{ key: 'cube-1', otype: 'vector3.add', path: 'transform.position', value: [5, 0, 0] },
|
|
199
|
+
|
|
200
|
+
// Absolute transform (inspector input)
|
|
201
|
+
{ key: 'sphere-1', otype: 'vector3.set', path: 'transform.position', value: [0, 5, 0] },
|
|
202
|
+
|
|
203
|
+
// Color change (LWW)
|
|
204
|
+
{ key: 'cube-1', otype: 'color.set', path: 'color', value: '#00ff00' },
|
|
205
|
+
|
|
206
|
+
// Additive counter
|
|
207
|
+
{ key: 'cube-1', otype: 'number.add', path: 'score', value: 10 },
|
|
208
|
+
|
|
209
|
+
// LWW number
|
|
210
|
+
{ key: 'cube-1', otype: 'number.set', path: 'opacity', value: 0.5 }
|
|
211
|
+
]
|
|
212
|
+
};
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Available Property otypes:**
|
|
216
|
+
|
|
217
|
+
| otype | Merge Behavior | Example |
|
|
218
|
+
|-------|---------------|---------|
|
|
219
|
+
| `number.set` | LWW | `opacity = 0.5` |
|
|
220
|
+
| `number.add` | Sum | `score += 10` |
|
|
221
|
+
| `number.multiply` | Product | `scale *= 2` |
|
|
222
|
+
| `number.min` | Minimum | `min(current, new)` |
|
|
223
|
+
| `number.max` | Maximum | `max(current, new)` |
|
|
224
|
+
| `vector3.set` | LWW | `position = [0,5,0]` |
|
|
225
|
+
| `vector3.add` | Component-wise sum | `position += [5,0,0]` |
|
|
226
|
+
| `quaternion.set` | LWW | `rotation = [0,0,0,1]` |
|
|
227
|
+
| `color.set` | LWW | `color = '#ff0000'` |
|
|
228
|
+
| `string.set` | LWW | `name = 'Cube'` |
|
|
229
|
+
| `boolean.set` | LWW | `visible = true` |
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
### Array Operations
|
|
234
|
+
|
|
235
|
+
Manipulate arrays (like `children`):
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
const msg: CRDTMessage = {
|
|
239
|
+
id: 'msg-004',
|
|
240
|
+
sessionId: 'session-alice',
|
|
241
|
+
clock: { 'session-alice': 3 },
|
|
242
|
+
lamportTime: 4,
|
|
243
|
+
timestamp: Date.now(),
|
|
244
|
+
ops: [
|
|
245
|
+
// Replace entire array
|
|
246
|
+
{ key: 'scene', otype: 'array.set', path: 'children', value: ['cube-1', 'sphere-1'] },
|
|
247
|
+
|
|
248
|
+
// Append to array
|
|
249
|
+
{ key: 'scene', otype: 'array.push', path: 'children', value: 'light-1' },
|
|
250
|
+
|
|
251
|
+
// Remove from array
|
|
252
|
+
{ key: 'scene', otype: 'array.remove', path: 'children', value: 'cube-1' }
|
|
253
|
+
]
|
|
254
|
+
};
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Tree Operations
|
|
260
|
+
|
|
261
|
+
### Reparent (Compound)
|
|
262
|
+
|
|
263
|
+
Move node from one parent to another using batched operations:
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
const msg: CRDTMessage = {
|
|
267
|
+
id: 'msg-005',
|
|
268
|
+
sessionId: 'session-alice',
|
|
269
|
+
clock: { 'session-alice': 4 },
|
|
270
|
+
lamportTime: 5,
|
|
271
|
+
timestamp: Date.now(),
|
|
272
|
+
ops: [
|
|
273
|
+
// 1. Create new parent (optional)
|
|
274
|
+
{
|
|
275
|
+
key: 'group-1',
|
|
276
|
+
otype: 'node.insert',
|
|
277
|
+
path: 'group-1',
|
|
278
|
+
value: {
|
|
279
|
+
id: 'uuid-group-001',
|
|
280
|
+
tag: 'Group',
|
|
281
|
+
name: 'My Group',
|
|
282
|
+
'transform.position': [0, 0, 0],
|
|
283
|
+
'transform.rotation': [0, 0, 0, 1],
|
|
284
|
+
'transform.scale': [1, 1, 1]
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
// 2. Remove from old parent
|
|
288
|
+
{ key: 'scene', otype: 'array.remove', path: 'children', value: 'cube-1' },
|
|
289
|
+
// 3. Add to new parent
|
|
290
|
+
{ key: 'group-1', otype: 'array.push', path: 'children', value: 'cube-1' }
|
|
291
|
+
]
|
|
292
|
+
};
|
|
293
|
+
// → cube-1 moved from scene to group-1 (atomic)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
**Why compound operations?**
|
|
297
|
+
- Children-only model (no parentId to update)
|
|
298
|
+
- Supports instancing (node can have multiple parents)
|
|
299
|
+
- Atomic: all operations in same message
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Instancing
|
|
304
|
+
|
|
305
|
+
Same node can appear in multiple places (multiple parents reference it).
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
// Shared tree node
|
|
309
|
+
{ key: 'tree-model', tag: 'Mesh', geometry: 'tree.glb', ... }
|
|
310
|
+
|
|
311
|
+
// Parent A references it
|
|
312
|
+
{ key: 'forest-A', tag: 'Group', children: ['tree-model', ...] }
|
|
313
|
+
|
|
314
|
+
// Parent B also references it
|
|
315
|
+
{ key: 'forest-B', tag: 'Group', children: ['tree-model', ...] }
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
`tree-model` appears in **both** `forest-A` and `forest-B`.
|
|
319
|
+
|
|
320
|
+
**Transform:**
|
|
321
|
+
- Each parent's transform combines with `tree-model`'s local transform
|
|
322
|
+
- World transform = parent.transform × tree-model.transform
|
|
323
|
+
|
|
324
|
+
**Delete:**
|
|
325
|
+
- If `tree-model` is removed via `node.remove`, it's deleted from **all** parents' children arrays
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Query Patterns
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
// Get node by key
|
|
333
|
+
getNode(key: string): SceneNode | undefined
|
|
334
|
+
|
|
335
|
+
// Get active nodes (exclude tombstones)
|
|
336
|
+
getActiveNodes(): SceneNode[]
|
|
337
|
+
|
|
338
|
+
// Get children
|
|
339
|
+
getChildren(key: string): SceneNode[]
|
|
340
|
+
|
|
341
|
+
// Find parents (requires reverse index)
|
|
342
|
+
getParents(key: string): SceneNode[]
|
|
343
|
+
|
|
344
|
+
// Build reverse index
|
|
345
|
+
function buildParentIndex(nodes: Record<string, SceneNode>): Map<string, string[]> {
|
|
346
|
+
const index = new Map();
|
|
347
|
+
for (const node of Object.values(nodes)) {
|
|
348
|
+
for (const childKey of node.children) {
|
|
349
|
+
if (!index.has(childKey)) index.set(childKey, []);
|
|
350
|
+
index.get(childKey).push(node.key);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return index;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Check cycles
|
|
357
|
+
isDescendant(key: string, ancestorKey: string): boolean
|
|
358
|
+
|
|
359
|
+
// Get world transform
|
|
360
|
+
getWorldTransform(key: string, throughParentKey: string): Transform3D
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Design Rationale
|
|
366
|
+
|
|
367
|
+
- ✅ **Instancing support** (multiple parents)
|
|
368
|
+
- ✅ **Simpler model** (one-way references only)
|
|
369
|
+
- ✅ **CRDT-friendly** (no bidirectional constraints)
|
|
370
|
+
- ✅ **True batching** (multiple ops, atomic)
|
|
371
|
+
- ✅ **Explicit otype** (no ambiguity: `vector3.add` vs `vector3.set`)
|
|
372
|
+
- ✅ **Human-friendly keys** (not UUIDs)
|
|
373
|
+
- ❌ **Parent lookup requires reverse index** (not persisted)
|