cyclecad 2.0.0 → 2.1.0
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/IMPLEMENTATION_GUIDE.md +502 -0
- package/INTEGRATION-GUIDE.md +377 -0
- package/MODULES_PHASES_6_7.md +780 -0
- package/app/index.html +106 -2
- package/app/js/brep-kernel.js +1353 -455
- package/app/js/help-module.js +1437 -0
- package/app/js/kernel.js +364 -40
- package/app/js/modules/animation-module.js +967 -0
- package/app/js/modules/assembly-module.js +47 -3
- package/app/js/modules/cam-module.js +1067 -0
- package/app/js/modules/collaboration-module.js +1102 -0
- package/app/js/modules/data-module.js +1656 -0
- package/app/js/modules/drawing-module.js +54 -8
- package/app/js/modules/formats-module.js +1173 -0
- package/app/js/modules/inspection-module.js +937 -0
- package/app/js/modules/mesh-module.js +968 -0
- package/app/js/modules/operations-module.js +40 -7
- package/app/js/modules/plugin-module.js +957 -0
- package/app/js/modules/rendering-module.js +1306 -0
- package/app/js/modules/scripting-module.js +955 -0
- package/app/js/modules/simulation-module.js +60 -3
- package/app/js/modules/sketch-module.js +1032 -90
- package/app/js/modules/step-module.js +47 -6
- package/app/js/modules/surface-module.js +728 -0
- package/app/js/modules/version-module.js +1410 -0
- package/app/js/modules/viewport-module.js +95 -8
- package/app/test-agent-v2.html +881 -1316
- package/docs/ARCHITECTURE.html +838 -1408
- package/docs/DEVELOPER-GUIDE.md +1504 -0
- package/docs/TUTORIAL.md +740 -0
- package/package.json +1 -1
- package/.github/scripts/cad-diff.js +0 -590
- package/.github/workflows/cad-diff.yml +0 -117
|
@@ -0,0 +1,1410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file version-module.js
|
|
3
|
+
* @description Git-style version control for CAD designs.
|
|
4
|
+
* Save snapshots of your entire model (geometry, parameters, constraints, tree),
|
|
5
|
+
* create branches to experiment safely, and merge changes back together.
|
|
6
|
+
* All data is stored locally in IndexedDB (browser) with optional cloud sync.
|
|
7
|
+
* Never lose work — complete history is always available for restore or rollback.
|
|
8
|
+
*
|
|
9
|
+
* @tutorial Saving Your First Version
|
|
10
|
+
* Step 1: Make changes to your model (draw sketch, extrude, add features)
|
|
11
|
+
*
|
|
12
|
+
* Step 2: Save a version with Ctrl+S or click Version → Save
|
|
13
|
+
* const saved = await kernel.exec('version.save', {
|
|
14
|
+
* message: 'Added main cylinder body'
|
|
15
|
+
* });
|
|
16
|
+
* console.log('Version ID:', saved.versionId);
|
|
17
|
+
*
|
|
18
|
+
* Step 3: The version appears in the timeline panel on the left
|
|
19
|
+
* - Shows time, message, and thumbnail of 3D model
|
|
20
|
+
* - You can hover to preview the 3D geometry
|
|
21
|
+
*
|
|
22
|
+
* Step 4: Continue working — save again when done
|
|
23
|
+
* const saved2 = await kernel.exec('version.save', {
|
|
24
|
+
* message: 'Added hole and fillets'
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* @tutorial Branching for Experimentation
|
|
28
|
+
* Use branches to try new ideas without losing your main work.
|
|
29
|
+
*
|
|
30
|
+
* Step 1: Create a branch from current version
|
|
31
|
+
* const branch = await kernel.exec('version.branch', {
|
|
32
|
+
* name: 'try-split-design',
|
|
33
|
+
* fromVersionId: null // null = from current state
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* Step 2: Make experimental changes (you're now on the branch)
|
|
37
|
+
* kernel.exec('shape.cylinder', { radius: 30, height: 100 });
|
|
38
|
+
* kernel.exec('version.save', { message: 'Trying larger cylinder' });
|
|
39
|
+
*
|
|
40
|
+
* Step 3a: Like it? Merge back to main
|
|
41
|
+
* const merged = await kernel.exec('version.merge', {
|
|
42
|
+
* branchName: 'try-split-design',
|
|
43
|
+
* strategy: 'ours' // Keep our (main) changes on conflict
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* Step 3b: Don't like it? Just switch back to main
|
|
47
|
+
* await kernel.exec('version.switchBranch', { name: 'main' });
|
|
48
|
+
* // All experimental changes disappear
|
|
49
|
+
*
|
|
50
|
+
* @tutorial Comparing Two Versions
|
|
51
|
+
* See exactly what changed between two snapshots.
|
|
52
|
+
*
|
|
53
|
+
* Step 1: Open the compare viewer
|
|
54
|
+
* const diff = await kernel.exec('version.compare', {
|
|
55
|
+
* versionId1: 'v123',
|
|
56
|
+
* versionId2: 'v124'
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* Step 2: The viewport splits into two 3D views
|
|
60
|
+
* - Left: version v123 in blue
|
|
61
|
+
* - Right: version v124 in green
|
|
62
|
+
* - Changed parts highlighted in orange
|
|
63
|
+
*
|
|
64
|
+
* @tutorial Auto-Save Configuration
|
|
65
|
+
* Protect against crashes with automatic saves.
|
|
66
|
+
*
|
|
67
|
+
* Option 1: Enable default auto-save (every 5 minutes)
|
|
68
|
+
* await kernel.exec('version.setAutoSave', { enabled: true });
|
|
69
|
+
*
|
|
70
|
+
* Option 2: Custom interval (every 2 minutes)
|
|
71
|
+
* await kernel.exec('version.setAutoSave', {
|
|
72
|
+
* enabled: true,
|
|
73
|
+
* intervalMs: 120000
|
|
74
|
+
* });
|
|
75
|
+
*
|
|
76
|
+
* Option 3: Disable auto-save
|
|
77
|
+
* await kernel.exec('version.setAutoSave', { enabled: false });
|
|
78
|
+
*
|
|
79
|
+
* @version 1.0.0
|
|
80
|
+
* @author Sachin Kumar <vvlars@googlemail.com>
|
|
81
|
+
* @license MIT
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// VERSION CONTROL MODULE — Main Export
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
export default {
|
|
89
|
+
name: 'version',
|
|
90
|
+
version: '1.0.0',
|
|
91
|
+
|
|
92
|
+
// ========================================================================
|
|
93
|
+
// MODULE STATE
|
|
94
|
+
// ========================================================================
|
|
95
|
+
|
|
96
|
+
state: {
|
|
97
|
+
/** @type {string} Current project ID (UUID) */
|
|
98
|
+
projectId: null,
|
|
99
|
+
|
|
100
|
+
/** @type {string} Current branch name (default: 'main') */
|
|
101
|
+
currentBranch: 'main',
|
|
102
|
+
|
|
103
|
+
/** @type {string} Current version ID */
|
|
104
|
+
currentVersionId: null,
|
|
105
|
+
|
|
106
|
+
/** @type {Array<Object>} Versions on current branch */
|
|
107
|
+
versions: [],
|
|
108
|
+
|
|
109
|
+
/** @type {Map<string, Object>} All branches by name */
|
|
110
|
+
branches: new Map([['main', { name: 'main', baseVersionId: null }]]),
|
|
111
|
+
|
|
112
|
+
/** @type {number} Auto-save interval in ms (null = disabled) */
|
|
113
|
+
autoSaveInterval: null,
|
|
114
|
+
|
|
115
|
+
/** @type {IDBDatabase} IndexedDB handle */
|
|
116
|
+
db: null,
|
|
117
|
+
|
|
118
|
+
/** @type {number} Total versions created in this project */
|
|
119
|
+
versionCount: 0,
|
|
120
|
+
|
|
121
|
+
/** @type {Date} Timestamp of last manual save */
|
|
122
|
+
lastSaveTime: null,
|
|
123
|
+
|
|
124
|
+
/** @type {boolean} True if changes exist since last save */
|
|
125
|
+
isDirty: false,
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
// ========================================================================
|
|
129
|
+
// INIT — Setup IndexedDB and restore project
|
|
130
|
+
// ========================================================================
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Initialize version control module.
|
|
134
|
+
* Opens IndexedDB, loads project history, starts auto-save if configured.
|
|
135
|
+
* Called automatically on app startup.
|
|
136
|
+
*
|
|
137
|
+
* @async
|
|
138
|
+
* @returns {Promise<void>}
|
|
139
|
+
*/
|
|
140
|
+
async init() {
|
|
141
|
+
// Open or create IndexedDB
|
|
142
|
+
this.state.db = await this._openDatabase();
|
|
143
|
+
|
|
144
|
+
// Load or create project
|
|
145
|
+
const projectId =
|
|
146
|
+
localStorage.getItem('version_projectId') ||
|
|
147
|
+
this._generateUUID();
|
|
148
|
+
this.state.projectId = projectId;
|
|
149
|
+
localStorage.setItem('version_projectId', projectId);
|
|
150
|
+
|
|
151
|
+
// Load project metadata
|
|
152
|
+
const projectMeta = await this._getProjectMetadata();
|
|
153
|
+
if (projectMeta) {
|
|
154
|
+
this.state.branches = new Map(Object.entries(projectMeta.branches || {}));
|
|
155
|
+
this.state.versionCount = projectMeta.versionCount || 0;
|
|
156
|
+
this.state.currentBranch = projectMeta.currentBranch || 'main';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Load versions on current branch
|
|
160
|
+
await this._loadBranchVersions(this.state.currentBranch);
|
|
161
|
+
|
|
162
|
+
// Restore last version if exists
|
|
163
|
+
if (this.state.versions.length > 0) {
|
|
164
|
+
this.state.currentVersionId = this.state.versions[0].id;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Load auto-save setting
|
|
168
|
+
const autoSaveMs = localStorage.getItem('version_autoSaveMs');
|
|
169
|
+
if (autoSaveMs !== null) {
|
|
170
|
+
this.setAutoSave({
|
|
171
|
+
enabled: true,
|
|
172
|
+
intervalMs: parseInt(autoSaveMs, 10),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Listen for model changes
|
|
177
|
+
window.addEventListener('modelChanged', () => {
|
|
178
|
+
this.state.isDirty = true;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
console.log('[Version Control] Initialized. Project ID:', projectId);
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
// ========================================================================
|
|
185
|
+
// PUBLIC API — Save and Restore
|
|
186
|
+
// ========================================================================
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Save a snapshot of the current model state.
|
|
190
|
+
* Captures geometry, parameters, constraints, feature tree, and assembly.
|
|
191
|
+
* Returns immediately with version ID; compression happens in background.
|
|
192
|
+
*
|
|
193
|
+
* @param {Object} options
|
|
194
|
+
* @param {string} [options.message=''] Commit message describing changes
|
|
195
|
+
* @param {Array<string>} [options.tags] Optional tags (e.g., ['release', 'v1'])
|
|
196
|
+
* @param {boolean} [options.createThumbnail=true] Capture 3D thumbnail
|
|
197
|
+
* @returns {Promise<Object>}
|
|
198
|
+
* { versionId, timestamp, message, branch, number }
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* const saved = await kernel.exec('version.save', {
|
|
202
|
+
* message: 'Added main shaft with keyway'
|
|
203
|
+
* });
|
|
204
|
+
* console.log('Saved version', saved.number, 'of', saved.branch);
|
|
205
|
+
*/
|
|
206
|
+
async save(options = {}) {
|
|
207
|
+
const {
|
|
208
|
+
message = '',
|
|
209
|
+
tags = [],
|
|
210
|
+
createThumbnail = true,
|
|
211
|
+
} = options;
|
|
212
|
+
|
|
213
|
+
if (message.length > 500) {
|
|
214
|
+
throw new Error('Message too long (max 500 chars)');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Capture current model state
|
|
218
|
+
const state = await this._captureModelState();
|
|
219
|
+
|
|
220
|
+
// Create version object
|
|
221
|
+
const version = {
|
|
222
|
+
id: this._generateUUID(),
|
|
223
|
+
timestamp: Date.now(),
|
|
224
|
+
message: message.trim(),
|
|
225
|
+
tags,
|
|
226
|
+
branch: this.state.currentBranch,
|
|
227
|
+
author: localStorage.getItem('version_userName') || 'Unknown',
|
|
228
|
+
number: ++this.state.versionCount,
|
|
229
|
+
parentVersionId: this.state.currentVersionId,
|
|
230
|
+
modelState: state,
|
|
231
|
+
thumbnail: null,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Capture thumbnail (async, doesn't block save)
|
|
235
|
+
if (createThumbnail) {
|
|
236
|
+
this._captureThumb(version).then((thumb) => {
|
|
237
|
+
version.thumbnail = thumb;
|
|
238
|
+
this._saveVersionToDB(version);
|
|
239
|
+
});
|
|
240
|
+
} else {
|
|
241
|
+
await this._saveVersionToDB(version);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Update state
|
|
245
|
+
this.state.versions.unshift(version); // Add to front (newest first)
|
|
246
|
+
this.state.currentVersionId = version.id;
|
|
247
|
+
this.state.isDirty = false;
|
|
248
|
+
this.state.lastSaveTime = new Date();
|
|
249
|
+
|
|
250
|
+
// Save project metadata
|
|
251
|
+
await this._saveProjectMetadata();
|
|
252
|
+
|
|
253
|
+
this._showNotification(`Saved version ${version.number}`, 'success');
|
|
254
|
+
this._broadcastEvent('version:saved', version);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
versionId: version.id,
|
|
258
|
+
timestamp: version.timestamp,
|
|
259
|
+
message: version.message,
|
|
260
|
+
branch: version.branch,
|
|
261
|
+
number: version.number,
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Restore the model to a previous version.
|
|
267
|
+
* Replaces current geometry/tree with saved state. Changes become dirty again.
|
|
268
|
+
*
|
|
269
|
+
* @param {Object} options
|
|
270
|
+
* @param {string} options.versionId Version ID to restore
|
|
271
|
+
* @param {boolean} [options.keepChanges=false] Preserve current changes in undo stack
|
|
272
|
+
* @returns {Promise<Object>} Restored version object
|
|
273
|
+
*
|
|
274
|
+
* @example
|
|
275
|
+
* await kernel.exec('version.restore', {
|
|
276
|
+
* versionId: 'abc123',
|
|
277
|
+
* keepChanges: true // Don't lose current work
|
|
278
|
+
* });
|
|
279
|
+
*/
|
|
280
|
+
async restore(options = {}) {
|
|
281
|
+
const { versionId, keepChanges = false } = options;
|
|
282
|
+
|
|
283
|
+
// Load version from DB
|
|
284
|
+
const version = await this._getVersionFromDB(versionId);
|
|
285
|
+
if (!version) {
|
|
286
|
+
throw new Error(`Version ${versionId} not found`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// If keeping changes, save current state first
|
|
290
|
+
if (keepChanges && this.state.isDirty) {
|
|
291
|
+
await this.save({ message: '[Auto-save before restore]' });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Restore model state
|
|
295
|
+
await this._restoreModelState(version.modelState);
|
|
296
|
+
|
|
297
|
+
// Update state
|
|
298
|
+
this.state.currentVersionId = versionId;
|
|
299
|
+
this.state.isDirty = false;
|
|
300
|
+
|
|
301
|
+
this._showNotification(
|
|
302
|
+
`Restored version ${version.number}: ${version.message}`,
|
|
303
|
+
'success'
|
|
304
|
+
);
|
|
305
|
+
this._broadcastEvent('version:restored', version);
|
|
306
|
+
|
|
307
|
+
return version;
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
// ========================================================================
|
|
311
|
+
// PUBLIC API — Branching
|
|
312
|
+
// ========================================================================
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Create a new branch from current state.
|
|
316
|
+
* Branch is a separate history line — changes don't affect main or other branches.
|
|
317
|
+
* Useful for experiments, client-specific variants, or concurrent work.
|
|
318
|
+
*
|
|
319
|
+
* @param {Object} options
|
|
320
|
+
* @param {string} options.name Branch name (alphanumeric, hyphens, underscores)
|
|
321
|
+
* @param {string} [options.fromVersionId] Base version (null = current state)
|
|
322
|
+
* @param {string} [options.description] Branch description
|
|
323
|
+
* @returns {Promise<Object>} { name, baseVersionId, createdAt }
|
|
324
|
+
*
|
|
325
|
+
* @example
|
|
326
|
+
* const branch = await kernel.exec('version.branch', {
|
|
327
|
+
* name: 'client-special-variant',
|
|
328
|
+
* description: 'Custom dimensions for client XYZ'
|
|
329
|
+
* });
|
|
330
|
+
*/
|
|
331
|
+
async branch(options = {}) {
|
|
332
|
+
const { name, fromVersionId = null, description = '' } = options;
|
|
333
|
+
|
|
334
|
+
if (!/^[a-z0-9_-]+$/i.test(name)) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
'Branch name must contain only alphanumeric, hyphens, underscores'
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (this.state.branches.has(name)) {
|
|
341
|
+
throw new Error(`Branch ${name} already exists`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Create branch record
|
|
345
|
+
const branch = {
|
|
346
|
+
name,
|
|
347
|
+
baseVersionId: fromVersionId || this.state.currentVersionId,
|
|
348
|
+
createdAt: Date.now(),
|
|
349
|
+
description,
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
this.state.branches.set(name, branch);
|
|
353
|
+
await this._saveProjectMetadata();
|
|
354
|
+
|
|
355
|
+
this._showNotification(`Created branch: ${name}`, 'success');
|
|
356
|
+
this._broadcastEvent('version:branchCreated', branch);
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
name: branch.name,
|
|
360
|
+
baseVersionId: branch.baseVersionId,
|
|
361
|
+
createdAt: branch.createdAt,
|
|
362
|
+
};
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Switch to a different branch.
|
|
367
|
+
* Saves current state, then loads the target branch's latest version.
|
|
368
|
+
*
|
|
369
|
+
* @param {Object} options
|
|
370
|
+
* @param {string} options.name Target branch name
|
|
371
|
+
* @param {boolean} [options.save=true] Auto-save current changes first
|
|
372
|
+
* @returns {Promise<Object>} Latest version on target branch
|
|
373
|
+
*
|
|
374
|
+
* @example
|
|
375
|
+
* await kernel.exec('version.switchBranch', {
|
|
376
|
+
* name: 'experimental-design'
|
|
377
|
+
* });
|
|
378
|
+
*/
|
|
379
|
+
async switchBranch(options = {}) {
|
|
380
|
+
const { name, save = true } = options;
|
|
381
|
+
|
|
382
|
+
if (!this.state.branches.has(name)) {
|
|
383
|
+
throw new Error(`Branch ${name} does not exist`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (name === this.state.currentBranch) {
|
|
387
|
+
return { alreadyOnBranch: true };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Save current branch's work
|
|
391
|
+
if (save && this.state.isDirty) {
|
|
392
|
+
await this.save({ message: '[Auto-save before branch switch]' });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Load target branch's versions
|
|
396
|
+
this.state.currentBranch = name;
|
|
397
|
+
await this._loadBranchVersions(name);
|
|
398
|
+
|
|
399
|
+
// Restore latest version on target branch
|
|
400
|
+
if (this.state.versions.length > 0) {
|
|
401
|
+
const latest = this.state.versions[0];
|
|
402
|
+
await this._restoreModelState(latest.modelState);
|
|
403
|
+
this.state.currentVersionId = latest.id;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
await this._saveProjectMetadata();
|
|
407
|
+
|
|
408
|
+
this._showNotification(`Switched to branch: ${name}`, 'success');
|
|
409
|
+
this._broadcastEvent('version:branchSwitched', { branch: name });
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
branch: name,
|
|
413
|
+
versionCount: this.state.versions.length,
|
|
414
|
+
};
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Merge another branch into current branch.
|
|
419
|
+
* Handles conflicts using specified strategy.
|
|
420
|
+
*
|
|
421
|
+
* @param {Object} options
|
|
422
|
+
* @param {string} options.branchName Branch to merge in
|
|
423
|
+
* @param {string} [options.strategy='interactive'] Conflict resolution
|
|
424
|
+
* 'ours' = keep current branch on conflict
|
|
425
|
+
* 'theirs' = take incoming branch on conflict
|
|
426
|
+
* 'interactive' = show diff and ask user
|
|
427
|
+
* @returns {Promise<Object>} Merge result with stats
|
|
428
|
+
*
|
|
429
|
+
* @example
|
|
430
|
+
* const merged = await kernel.exec('version.merge', {
|
|
431
|
+
* branchName: 'experimental-feature',
|
|
432
|
+
* strategy: 'ours'
|
|
433
|
+
* });
|
|
434
|
+
* console.log('Parts added:', merged.partsAdded);
|
|
435
|
+
*/
|
|
436
|
+
async merge(options = {}) {
|
|
437
|
+
const { branchName, strategy = 'interactive' } = options;
|
|
438
|
+
|
|
439
|
+
if (!this.state.branches.has(branchName)) {
|
|
440
|
+
throw new Error(`Branch ${branchName} not found`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (branchName === this.state.currentBranch) {
|
|
444
|
+
throw new Error('Cannot merge branch into itself');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Get latest version from both branches
|
|
448
|
+
const currentLatest = this.state.versions[0];
|
|
449
|
+
const branchVersions = await this._getBranchVersions(branchName);
|
|
450
|
+
const incomingLatest = branchVersions[0];
|
|
451
|
+
|
|
452
|
+
if (!incomingLatest) {
|
|
453
|
+
throw new Error(`Branch ${branchName} has no versions`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Compute three-way merge
|
|
457
|
+
const mergeResult = await this._computeThreeWayMerge(
|
|
458
|
+
currentLatest,
|
|
459
|
+
incomingLatest,
|
|
460
|
+
strategy
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
if (mergeResult.conflicts.length > 0 && strategy === 'interactive') {
|
|
464
|
+
// Would show UI for conflict resolution
|
|
465
|
+
throw new Error(
|
|
466
|
+
`Merge has ${mergeResult.conflicts.length} conflicts. Resolve manually.`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Apply merge result
|
|
471
|
+
await this._restoreModelState(mergeResult.mergedState);
|
|
472
|
+
|
|
473
|
+
// Create merge commit
|
|
474
|
+
const mergeVersion = await this.save({
|
|
475
|
+
message: `Merge branch '${branchName}' into '${this.state.currentBranch}'`,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
this._showNotification(`Merged branch: ${branchName}`, 'success');
|
|
479
|
+
this._broadcastEvent('version:merged', mergeResult);
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
success: true,
|
|
483
|
+
partsAdded: mergeResult.partsAdded || 0,
|
|
484
|
+
partsRemoved: mergeResult.partsRemoved || 0,
|
|
485
|
+
partsModified: mergeResult.partsModified || 0,
|
|
486
|
+
conflicts: mergeResult.conflicts,
|
|
487
|
+
mergeVersionId: mergeVersion.versionId,
|
|
488
|
+
};
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Delete a branch.
|
|
493
|
+
* Cannot delete current branch. Main branch cannot be deleted.
|
|
494
|
+
*
|
|
495
|
+
* @param {Object} options
|
|
496
|
+
* @param {string} options.name Branch to delete
|
|
497
|
+
* @returns {Promise<void>}
|
|
498
|
+
*/
|
|
499
|
+
async deleteBranch(options = {}) {
|
|
500
|
+
const { name } = options;
|
|
501
|
+
|
|
502
|
+
if (name === 'main') {
|
|
503
|
+
throw new Error('Cannot delete main branch');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (name === this.state.currentBranch) {
|
|
507
|
+
throw new Error('Cannot delete current branch');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
this.state.branches.delete(name);
|
|
511
|
+
await this._saveProjectMetadata();
|
|
512
|
+
|
|
513
|
+
this._showNotification(`Deleted branch: ${name}`, 'success');
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
// ========================================================================
|
|
517
|
+
// PUBLIC API — Comparison and Diff
|
|
518
|
+
// ========================================================================
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Compare two versions and show differences.
|
|
522
|
+
* Opens a split-view with both versions visible, highlighting changes.
|
|
523
|
+
*
|
|
524
|
+
* @param {Object} options
|
|
525
|
+
* @param {string} options.versionId1 First version (left)
|
|
526
|
+
* @param {string} options.versionId2 Second version (right)
|
|
527
|
+
* @returns {Promise<Object>} Diff data { added, removed, modified, unchanged }
|
|
528
|
+
*
|
|
529
|
+
* @example
|
|
530
|
+
* const diff = await kernel.exec('version.compare', {
|
|
531
|
+
* versionId1: 'abc123',
|
|
532
|
+
* versionId2: 'def456'
|
|
533
|
+
* });
|
|
534
|
+
* console.log('Parts added:', diff.added.length);
|
|
535
|
+
*/
|
|
536
|
+
async compare(options = {}) {
|
|
537
|
+
const { versionId1, versionId2 } = options;
|
|
538
|
+
|
|
539
|
+
if (!versionId1 || !versionId2) {
|
|
540
|
+
throw new Error('Two version IDs required');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const v1 = await this._getVersionFromDB(versionId1);
|
|
544
|
+
const v2 = await this._getVersionFromDB(versionId2);
|
|
545
|
+
|
|
546
|
+
if (!v1 || !v2) {
|
|
547
|
+
throw new Error('One or both versions not found');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Compute diff
|
|
551
|
+
const diff = this._computeDiff(v1.modelState, v2.modelState);
|
|
552
|
+
|
|
553
|
+
this._broadcastEvent('version:diffShown', {
|
|
554
|
+
versionId1,
|
|
555
|
+
versionId2,
|
|
556
|
+
diff,
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
added: diff.added,
|
|
561
|
+
removed: diff.removed,
|
|
562
|
+
modified: diff.modified,
|
|
563
|
+
unchanged: diff.unchanged,
|
|
564
|
+
};
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
// ========================================================================
|
|
568
|
+
// PUBLIC API — List and Query
|
|
569
|
+
// ========================================================================
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Get all versions on current branch.
|
|
573
|
+
*
|
|
574
|
+
* @param {Object} [options]
|
|
575
|
+
* @param {number} [options.limit=50] Max versions to return
|
|
576
|
+
* @param {number} [options.offset=0] Pagination offset
|
|
577
|
+
* @returns {Promise<Array<Object>>} Array of version summaries
|
|
578
|
+
*
|
|
579
|
+
* @example
|
|
580
|
+
* const versions = await kernel.exec('version.list', { limit: 20 });
|
|
581
|
+
* versions.forEach(v => console.log(`#${v.number}: ${v.message}`));
|
|
582
|
+
*/
|
|
583
|
+
async list(options = {}) {
|
|
584
|
+
const { limit = 50, offset = 0 } = options;
|
|
585
|
+
|
|
586
|
+
return this.state.versions.slice(offset, offset + limit).map((v) => ({
|
|
587
|
+
id: v.id,
|
|
588
|
+
number: v.number,
|
|
589
|
+
timestamp: v.timestamp,
|
|
590
|
+
message: v.message,
|
|
591
|
+
tags: v.tags,
|
|
592
|
+
branch: v.branch,
|
|
593
|
+
author: v.author,
|
|
594
|
+
}));
|
|
595
|
+
},
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Get all branches.
|
|
599
|
+
*
|
|
600
|
+
* @returns {Promise<Array<Object>>} Array of branch info
|
|
601
|
+
*
|
|
602
|
+
* @example
|
|
603
|
+
* const branches = await kernel.exec('version.getBranches');
|
|
604
|
+
* console.log('Branches:', branches.map(b => b.name));
|
|
605
|
+
*/
|
|
606
|
+
async getBranches() {
|
|
607
|
+
return Array.from(this.state.branches.values()).map((b) => ({
|
|
608
|
+
name: b.name,
|
|
609
|
+
isCurrent: b.name === this.state.currentBranch,
|
|
610
|
+
createdAt: b.createdAt,
|
|
611
|
+
description: b.description,
|
|
612
|
+
}));
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
// ========================================================================
|
|
616
|
+
// PUBLIC API — Auto-Save and Export
|
|
617
|
+
// ========================================================================
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Configure automatic saving.
|
|
621
|
+
* Saves model state on a timer (e.g., every 5 minutes).
|
|
622
|
+
*
|
|
623
|
+
* @param {Object} options
|
|
624
|
+
* @param {boolean} options.enabled Enable or disable auto-save
|
|
625
|
+
* @param {number} [options.intervalMs=300000] Interval (default 5 min)
|
|
626
|
+
* @returns {Promise<void>}
|
|
627
|
+
*
|
|
628
|
+
* @example
|
|
629
|
+
* await kernel.exec('version.setAutoSave', {
|
|
630
|
+
* enabled: true,
|
|
631
|
+
* intervalMs: 120000 // 2 minutes
|
|
632
|
+
* });
|
|
633
|
+
*/
|
|
634
|
+
async setAutoSave(options = {}) {
|
|
635
|
+
const { enabled = true, intervalMs = 300000 } = options;
|
|
636
|
+
|
|
637
|
+
// Clear existing interval
|
|
638
|
+
if (this.state.autoSaveInterval) {
|
|
639
|
+
clearInterval(this.state.autoSaveInterval);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (enabled) {
|
|
643
|
+
// Start new interval
|
|
644
|
+
this.state.autoSaveInterval = setInterval(() => {
|
|
645
|
+
if (this.state.isDirty) {
|
|
646
|
+
this.save({ message: '[Auto-save]' });
|
|
647
|
+
}
|
|
648
|
+
}, intervalMs);
|
|
649
|
+
|
|
650
|
+
localStorage.setItem('version_autoSaveMs', intervalMs.toString());
|
|
651
|
+
} else {
|
|
652
|
+
this.state.autoSaveInterval = null;
|
|
653
|
+
localStorage.removeItem('version_autoSaveMs');
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Export a version as a JSON file for backup or sharing.
|
|
659
|
+
*
|
|
660
|
+
* @param {Object} options
|
|
661
|
+
* @param {string} options.versionId Version to export
|
|
662
|
+
* @returns {Promise<Blob>} JSON blob ready for download
|
|
663
|
+
*
|
|
664
|
+
* @example
|
|
665
|
+
* const blob = await kernel.exec('version.export', {
|
|
666
|
+
* versionId: 'abc123'
|
|
667
|
+
* });
|
|
668
|
+
* // User can download or share the blob
|
|
669
|
+
*/
|
|
670
|
+
async exportVersion(options = {}) {
|
|
671
|
+
const { versionId } = options;
|
|
672
|
+
|
|
673
|
+
const version = await this._getVersionFromDB(versionId);
|
|
674
|
+
if (!version) {
|
|
675
|
+
throw new Error('Version not found');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Compress before export
|
|
679
|
+
const compressed = await this._compressModelState(version.modelState);
|
|
680
|
+
|
|
681
|
+
const exportData = {
|
|
682
|
+
version: 1,
|
|
683
|
+
projectId: this.state.projectId,
|
|
684
|
+
versionId: version.id,
|
|
685
|
+
timestamp: version.timestamp,
|
|
686
|
+
message: version.message,
|
|
687
|
+
branch: version.branch,
|
|
688
|
+
modelState: compressed,
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
const json = JSON.stringify(exportData, null, 2);
|
|
692
|
+
return new Blob([json], { type: 'application/json' });
|
|
693
|
+
},
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Import a previously exported version.
|
|
697
|
+
*
|
|
698
|
+
* @param {Object} options
|
|
699
|
+
* @param {File|Blob} options.file JSON file to import
|
|
700
|
+
* @param {boolean} [options.asBranch=false] Import as new branch
|
|
701
|
+
* @returns {Promise<Object>} Imported version info
|
|
702
|
+
*/
|
|
703
|
+
async importVersion(options = {}) {
|
|
704
|
+
const { file, asBranch = false } = options;
|
|
705
|
+
|
|
706
|
+
const json = JSON.parse(await file.text());
|
|
707
|
+
|
|
708
|
+
// Decompress
|
|
709
|
+
const modelState = await this._decompressModelState(json.modelState);
|
|
710
|
+
|
|
711
|
+
if (asBranch) {
|
|
712
|
+
const branchName = `imported-${Date.now()}`;
|
|
713
|
+
await this.branch({ name: branchName });
|
|
714
|
+
await this.switchBranch({ name: branchName });
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Create version from imported data
|
|
718
|
+
const version = {
|
|
719
|
+
id: this._generateUUID(),
|
|
720
|
+
timestamp: Date.now(),
|
|
721
|
+
message: `[Imported] ${json.message}`,
|
|
722
|
+
tags: ['imported'],
|
|
723
|
+
branch: this.state.currentBranch,
|
|
724
|
+
author: 'Importer',
|
|
725
|
+
number: ++this.state.versionCount,
|
|
726
|
+
modelState,
|
|
727
|
+
thumbnail: null,
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
await this._saveVersionToDB(version);
|
|
731
|
+
this.state.versions.unshift(version);
|
|
732
|
+
this.state.currentVersionId = version.id;
|
|
733
|
+
|
|
734
|
+
return { versionId: version.id, message: version.message };
|
|
735
|
+
},
|
|
736
|
+
|
|
737
|
+
// ========================================================================
|
|
738
|
+
// INTERNAL HELPERS — Database
|
|
739
|
+
// ========================================================================
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Open or create IndexedDB.
|
|
743
|
+
*
|
|
744
|
+
* @private
|
|
745
|
+
* @async
|
|
746
|
+
* @returns {Promise<IDBDatabase>}
|
|
747
|
+
*/
|
|
748
|
+
async _openDatabase() {
|
|
749
|
+
return new Promise((resolve, reject) => {
|
|
750
|
+
const req = indexedDB.open('cyclecad-versions', 1);
|
|
751
|
+
|
|
752
|
+
req.onerror = () => reject(req.error);
|
|
753
|
+
req.onsuccess = () => resolve(req.result);
|
|
754
|
+
|
|
755
|
+
req.onupgradeneeded = (e) => {
|
|
756
|
+
const db = e.target.result;
|
|
757
|
+
|
|
758
|
+
if (!db.objectStoreNames.contains('projects')) {
|
|
759
|
+
db.createObjectStore('projects', { keyPath: 'projectId' });
|
|
760
|
+
}
|
|
761
|
+
if (!db.objectStoreNames.contains('versions')) {
|
|
762
|
+
const versionsStore = db.createObjectStore('versions', {
|
|
763
|
+
keyPath: 'id',
|
|
764
|
+
});
|
|
765
|
+
versionsStore.createIndex('projectId', 'projectId');
|
|
766
|
+
versionsStore.createIndex('branch', 'branch');
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
});
|
|
770
|
+
},
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Get or create project metadata.
|
|
774
|
+
*
|
|
775
|
+
* @private
|
|
776
|
+
* @async
|
|
777
|
+
* @returns {Promise<Object|null>}
|
|
778
|
+
*/
|
|
779
|
+
async _getProjectMetadata() {
|
|
780
|
+
return new Promise((resolve) => {
|
|
781
|
+
const tx = this.state.db.transaction(['projects'], 'readonly');
|
|
782
|
+
const store = tx.objectStore('projects');
|
|
783
|
+
const req = store.get(this.state.projectId);
|
|
784
|
+
|
|
785
|
+
req.onsuccess = () => resolve(req.result || null);
|
|
786
|
+
});
|
|
787
|
+
},
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Save project metadata to DB.
|
|
791
|
+
*
|
|
792
|
+
* @private
|
|
793
|
+
* @async
|
|
794
|
+
* @returns {Promise<void>}
|
|
795
|
+
*/
|
|
796
|
+
async _saveProjectMetadata() {
|
|
797
|
+
return new Promise((resolve) => {
|
|
798
|
+
const meta = {
|
|
799
|
+
projectId: this.state.projectId,
|
|
800
|
+
branches: Object.fromEntries(this.state.branches),
|
|
801
|
+
currentBranch: this.state.currentBranch,
|
|
802
|
+
versionCount: this.state.versionCount,
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const tx = this.state.db.transaction(['projects'], 'readwrite');
|
|
806
|
+
const store = tx.objectStore('projects');
|
|
807
|
+
store.put(meta);
|
|
808
|
+
|
|
809
|
+
tx.oncomplete = () => resolve();
|
|
810
|
+
});
|
|
811
|
+
},
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Save a version to IndexedDB.
|
|
815
|
+
*
|
|
816
|
+
* @private
|
|
817
|
+
* @async
|
|
818
|
+
* @param {Object} version
|
|
819
|
+
* @returns {Promise<void>}
|
|
820
|
+
*/
|
|
821
|
+
async _saveVersionToDB(version) {
|
|
822
|
+
return new Promise((resolve) => {
|
|
823
|
+
const tx = this.state.db.transaction(['versions'], 'readwrite');
|
|
824
|
+
const store = tx.objectStore('versions');
|
|
825
|
+
store.put(version);
|
|
826
|
+
|
|
827
|
+
tx.oncomplete = () => resolve();
|
|
828
|
+
});
|
|
829
|
+
},
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Retrieve a version from IndexedDB.
|
|
833
|
+
*
|
|
834
|
+
* @private
|
|
835
|
+
* @async
|
|
836
|
+
* @param {string} versionId
|
|
837
|
+
* @returns {Promise<Object|null>}
|
|
838
|
+
*/
|
|
839
|
+
async _getVersionFromDB(versionId) {
|
|
840
|
+
return new Promise((resolve) => {
|
|
841
|
+
const tx = this.state.db.transaction(['versions'], 'readonly');
|
|
842
|
+
const store = tx.objectStore('versions');
|
|
843
|
+
const req = store.get(versionId);
|
|
844
|
+
|
|
845
|
+
req.onsuccess = () => resolve(req.result || null);
|
|
846
|
+
});
|
|
847
|
+
},
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Load all versions for a branch.
|
|
851
|
+
*
|
|
852
|
+
* @private
|
|
853
|
+
* @async
|
|
854
|
+
* @param {string} branchName
|
|
855
|
+
* @returns {Promise<void>}
|
|
856
|
+
*/
|
|
857
|
+
async _loadBranchVersions(branchName) {
|
|
858
|
+
return new Promise((resolve) => {
|
|
859
|
+
const tx = this.state.db.transaction(['versions'], 'readonly');
|
|
860
|
+
const store = tx.objectStore('versions');
|
|
861
|
+
const index = store.index('branch');
|
|
862
|
+
const req = index.getAll(branchName);
|
|
863
|
+
|
|
864
|
+
req.onsuccess = () => {
|
|
865
|
+
// Sort by timestamp descending (newest first)
|
|
866
|
+
this.state.versions = req.result.sort(
|
|
867
|
+
(a, b) => b.timestamp - a.timestamp
|
|
868
|
+
);
|
|
869
|
+
resolve();
|
|
870
|
+
};
|
|
871
|
+
});
|
|
872
|
+
},
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Get all versions for a specific branch (private version).
|
|
876
|
+
*
|
|
877
|
+
* @private
|
|
878
|
+
* @async
|
|
879
|
+
* @param {string} branchName
|
|
880
|
+
* @returns {Promise<Array<Object>>}
|
|
881
|
+
*/
|
|
882
|
+
async _getBranchVersions(branchName) {
|
|
883
|
+
return new Promise((resolve) => {
|
|
884
|
+
const tx = this.state.db.transaction(['versions'], 'readonly');
|
|
885
|
+
const store = tx.objectStore('versions');
|
|
886
|
+
const index = store.index('branch');
|
|
887
|
+
const req = index.getAll(branchName);
|
|
888
|
+
|
|
889
|
+
req.onsuccess = () => {
|
|
890
|
+
const sorted = req.result.sort((a, b) => b.timestamp - a.timestamp);
|
|
891
|
+
resolve(sorted);
|
|
892
|
+
};
|
|
893
|
+
});
|
|
894
|
+
},
|
|
895
|
+
|
|
896
|
+
// ========================================================================
|
|
897
|
+
// INTERNAL HELPERS — State Capture and Restore
|
|
898
|
+
// ========================================================================
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Capture entire model state (geometry, parameters, tree, assembly).
|
|
902
|
+
* This is app-specific and would integrate with app.js state.
|
|
903
|
+
*
|
|
904
|
+
* @private
|
|
905
|
+
* @async
|
|
906
|
+
* @returns {Promise<Object>}
|
|
907
|
+
*/
|
|
908
|
+
async _captureModelState() {
|
|
909
|
+
// In production, this would call app.js or viewport.js to get:
|
|
910
|
+
// - All geometries (meshes)
|
|
911
|
+
// - Feature tree
|
|
912
|
+
// - Parameters and constraints
|
|
913
|
+
// - Assembly structure
|
|
914
|
+
// For now, return stub
|
|
915
|
+
|
|
916
|
+
return {
|
|
917
|
+
geometries: window._scene ? window._scene.children.map((obj) => ({
|
|
918
|
+
uuid: obj.uuid,
|
|
919
|
+
name: obj.name,
|
|
920
|
+
type: obj.type,
|
|
921
|
+
position: obj.position.toArray(),
|
|
922
|
+
rotation: obj.rotation.toArray(),
|
|
923
|
+
scale: obj.scale.toArray(),
|
|
924
|
+
})) : [],
|
|
925
|
+
featureTree: window._featureTree || [],
|
|
926
|
+
parameters: window._parameters || {},
|
|
927
|
+
assembly: window._assembly || {},
|
|
928
|
+
timestamp: Date.now(),
|
|
929
|
+
};
|
|
930
|
+
},
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Restore model from saved state.
|
|
934
|
+
*
|
|
935
|
+
* @private
|
|
936
|
+
* @async
|
|
937
|
+
* @param {Object} modelState
|
|
938
|
+
* @returns {Promise<void>}
|
|
939
|
+
*/
|
|
940
|
+
async _restoreModelState(modelState) {
|
|
941
|
+
// In production, this would:
|
|
942
|
+
// 1. Clear current scene
|
|
943
|
+
// 2. Deserialize and rebuild all geometries
|
|
944
|
+
// 3. Restore feature tree
|
|
945
|
+
// 4. Apply parameters and constraints
|
|
946
|
+
// 5. Restore assembly
|
|
947
|
+
|
|
948
|
+
// For now, dispatch event so app can handle it
|
|
949
|
+
this._broadcastEvent('version:restoring', modelState);
|
|
950
|
+
},
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Capture a 3D thumbnail of current viewport.
|
|
954
|
+
*
|
|
955
|
+
* @private
|
|
956
|
+
* @async
|
|
957
|
+
* @param {Object} version
|
|
958
|
+
* @returns {Promise<string>} Data URL of thumbnail image
|
|
959
|
+
*/
|
|
960
|
+
async _captureThumb(version) {
|
|
961
|
+
// In production:
|
|
962
|
+
// 1. Fit entire model to viewport
|
|
963
|
+
// 2. Render to offscreen canvas (256x256)
|
|
964
|
+
// 3. Convert to PNG
|
|
965
|
+
// 4. Return as data URL
|
|
966
|
+
|
|
967
|
+
if (!window._renderer) return null;
|
|
968
|
+
|
|
969
|
+
try {
|
|
970
|
+
const canvas = document.createElement('canvas');
|
|
971
|
+
canvas.width = 256;
|
|
972
|
+
canvas.height = 256;
|
|
973
|
+
|
|
974
|
+
// Would copy renderer output here
|
|
975
|
+
// For now, return null (thumbnail optional)
|
|
976
|
+
return null;
|
|
977
|
+
} catch (err) {
|
|
978
|
+
console.warn('Failed to capture thumbnail:', err);
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
},
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Compress model state (reduce size for storage).
|
|
985
|
+
*
|
|
986
|
+
* @private
|
|
987
|
+
* @async
|
|
988
|
+
* @param {Object} modelState
|
|
989
|
+
* @returns {Promise<string>} Compressed data
|
|
990
|
+
*/
|
|
991
|
+
async _compressModelState(modelState) {
|
|
992
|
+
// In production, use LZ-string or similar compression
|
|
993
|
+
// For now, JSON stringify
|
|
994
|
+
return JSON.stringify(modelState);
|
|
995
|
+
},
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Decompress model state.
|
|
999
|
+
*
|
|
1000
|
+
* @private
|
|
1001
|
+
* @async
|
|
1002
|
+
* @param {string} compressed
|
|
1003
|
+
* @returns {Promise<Object>}
|
|
1004
|
+
*/
|
|
1005
|
+
async _decompressModelState(compressed) {
|
|
1006
|
+
return JSON.parse(compressed);
|
|
1007
|
+
},
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Compute three-way merge (common ancestor + two branches).
|
|
1011
|
+
*
|
|
1012
|
+
* @private
|
|
1013
|
+
* @async
|
|
1014
|
+
* @param {Object} currentVersion
|
|
1015
|
+
* @param {Object} incomingVersion
|
|
1016
|
+
* @param {string} strategy Conflict resolution strategy
|
|
1017
|
+
* @returns {Promise<Object>} Merge result
|
|
1018
|
+
*/
|
|
1019
|
+
async _computeThreeWayMerge(currentVersion, incomingVersion, strategy) {
|
|
1020
|
+
// In production:
|
|
1021
|
+
// 1. Find common ancestor
|
|
1022
|
+
// 2. Compute diff: ancestor → current and ancestor → incoming
|
|
1023
|
+
// 3. Apply both diffs (union of non-conflicting changes)
|
|
1024
|
+
// 4. Report conflicts
|
|
1025
|
+
// 5. Apply strategy to resolve conflicts
|
|
1026
|
+
|
|
1027
|
+
return {
|
|
1028
|
+
mergedState: { ...currentVersion.modelState },
|
|
1029
|
+
partsAdded: 0,
|
|
1030
|
+
partsRemoved: 0,
|
|
1031
|
+
partsModified: 0,
|
|
1032
|
+
conflicts: [],
|
|
1033
|
+
};
|
|
1034
|
+
},
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Compute difference between two model states.
|
|
1038
|
+
*
|
|
1039
|
+
* @private
|
|
1040
|
+
* @param {Object} state1
|
|
1041
|
+
* @param {Object} state2
|
|
1042
|
+
* @returns {Object} { added, removed, modified, unchanged }
|
|
1043
|
+
*/
|
|
1044
|
+
_computeDiff(state1, state2) {
|
|
1045
|
+
// In production:
|
|
1046
|
+
// 1. Compare geometry arrays (by UUID)
|
|
1047
|
+
// 2. Identify added, removed, modified geometries
|
|
1048
|
+
// 3. Compute bounding box deltas
|
|
1049
|
+
// 4. Return visual diff info
|
|
1050
|
+
|
|
1051
|
+
return {
|
|
1052
|
+
added: [],
|
|
1053
|
+
removed: [],
|
|
1054
|
+
modified: [],
|
|
1055
|
+
unchanged: [],
|
|
1056
|
+
};
|
|
1057
|
+
},
|
|
1058
|
+
|
|
1059
|
+
// ========================================================================
|
|
1060
|
+
// INTERNAL HELPERS — UI and Events
|
|
1061
|
+
// ========================================================================
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Show notification toast.
|
|
1065
|
+
*
|
|
1066
|
+
* @private
|
|
1067
|
+
* @param {string} message
|
|
1068
|
+
* @param {string} type 'success' | 'error' | 'info'
|
|
1069
|
+
*/
|
|
1070
|
+
_showNotification(message, type = 'info') {
|
|
1071
|
+
const toast = document.createElement('div');
|
|
1072
|
+
toast.className = `version-toast version-toast-${type}`;
|
|
1073
|
+
toast.textContent = message;
|
|
1074
|
+
document.body.appendChild(toast);
|
|
1075
|
+
|
|
1076
|
+
setTimeout(() => toast.remove(), 4000);
|
|
1077
|
+
},
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Broadcast custom event to app.
|
|
1081
|
+
*
|
|
1082
|
+
* @private
|
|
1083
|
+
* @param {string} eventName
|
|
1084
|
+
* @param {Object} detail
|
|
1085
|
+
*/
|
|
1086
|
+
_broadcastEvent(eventName, detail) {
|
|
1087
|
+
const event = new CustomEvent(eventName, { detail });
|
|
1088
|
+
document.dispatchEvent(event);
|
|
1089
|
+
},
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Generate UUID v4.
|
|
1093
|
+
*
|
|
1094
|
+
* @private
|
|
1095
|
+
* @returns {string}
|
|
1096
|
+
*/
|
|
1097
|
+
_generateUUID() {
|
|
1098
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
1099
|
+
const r = (Math.random() * 16) | 0;
|
|
1100
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
1101
|
+
return v.toString(16);
|
|
1102
|
+
});
|
|
1103
|
+
},
|
|
1104
|
+
|
|
1105
|
+
// ========================================================================
|
|
1106
|
+
// HELP SYSTEM INTEGRATION
|
|
1107
|
+
// ========================================================================
|
|
1108
|
+
|
|
1109
|
+
helpEntries: [
|
|
1110
|
+
{
|
|
1111
|
+
title: 'Save a Version',
|
|
1112
|
+
description:
|
|
1113
|
+
'Press Ctrl+S or click Version → Save to create a snapshot of your model. Add a message describing what you changed.',
|
|
1114
|
+
category: 'Version Control',
|
|
1115
|
+
shortcut: 'Ctrl+S',
|
|
1116
|
+
},
|
|
1117
|
+
{
|
|
1118
|
+
title: 'Restore a Previous Version',
|
|
1119
|
+
description:
|
|
1120
|
+
'Open the Version panel on the left, find an earlier version, and click "Restore". Your model will return to that state.',
|
|
1121
|
+
category: 'Version Control',
|
|
1122
|
+
shortcut: null,
|
|
1123
|
+
},
|
|
1124
|
+
{
|
|
1125
|
+
title: 'Create a Branch',
|
|
1126
|
+
description:
|
|
1127
|
+
'Use branches to experiment without affecting your main design. Create a branch, make changes, then merge back if you like them.',
|
|
1128
|
+
category: 'Version Control',
|
|
1129
|
+
shortcut: null,
|
|
1130
|
+
},
|
|
1131
|
+
{
|
|
1132
|
+
title: 'Compare Versions',
|
|
1133
|
+
description:
|
|
1134
|
+
'Select two versions and click "Compare" to see a side-by-side diff. Changes are highlighted in orange.',
|
|
1135
|
+
category: 'Version Control',
|
|
1136
|
+
shortcut: null,
|
|
1137
|
+
},
|
|
1138
|
+
{
|
|
1139
|
+
title: 'Auto-Save Configuration',
|
|
1140
|
+
description:
|
|
1141
|
+
'Enable auto-save to protect against crashes. Version → Auto-Save → set interval (default 5 minutes).',
|
|
1142
|
+
category: 'Version Control',
|
|
1143
|
+
shortcut: null,
|
|
1144
|
+
},
|
|
1145
|
+
{
|
|
1146
|
+
title: 'Merge Branches',
|
|
1147
|
+
description:
|
|
1148
|
+
'When you\'re done experimenting on a branch, merge it back to main. Click Merge and choose a conflict resolution strategy.',
|
|
1149
|
+
category: 'Version Control',
|
|
1150
|
+
shortcut: null,
|
|
1151
|
+
},
|
|
1152
|
+
],
|
|
1153
|
+
|
|
1154
|
+
// ========================================================================
|
|
1155
|
+
// UI PANEL — HTML and Styling
|
|
1156
|
+
// ========================================================================
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Get the HTML for the version control panel.
|
|
1160
|
+
*
|
|
1161
|
+
* @returns {string} HTML markup
|
|
1162
|
+
*/
|
|
1163
|
+
getUI() {
|
|
1164
|
+
return `
|
|
1165
|
+
<div class="version-panel" id="version-panel">
|
|
1166
|
+
<div class="version-header">
|
|
1167
|
+
<h3>Versions</h3>
|
|
1168
|
+
<button class="version-close-btn" data-close-panel="version-panel">×</button>
|
|
1169
|
+
</div>
|
|
1170
|
+
|
|
1171
|
+
<div class="version-content">
|
|
1172
|
+
<div class="version-toolbar">
|
|
1173
|
+
<button id="version-save-btn" class="version-btn version-btn-primary">
|
|
1174
|
+
💾 Save
|
|
1175
|
+
</button>
|
|
1176
|
+
<button id="version-branch-btn" class="version-btn">
|
|
1177
|
+
🌿 Branch
|
|
1178
|
+
</button>
|
|
1179
|
+
</div>
|
|
1180
|
+
|
|
1181
|
+
<div class="version-tabs">
|
|
1182
|
+
<button class="version-tab active" data-tab="timeline">Timeline</button>
|
|
1183
|
+
<button class="version-tab" data-tab="branches">Branches</button>
|
|
1184
|
+
<button class="version-tab" data-tab="compare">Compare</button>
|
|
1185
|
+
</div>
|
|
1186
|
+
|
|
1187
|
+
<!-- Timeline Tab -->
|
|
1188
|
+
<div id="version-timeline" class="version-tab-content active">
|
|
1189
|
+
<div id="version-list" class="version-list"></div>
|
|
1190
|
+
</div>
|
|
1191
|
+
|
|
1192
|
+
<!-- Branches Tab -->
|
|
1193
|
+
<div id="version-branches" class="version-tab-content">
|
|
1194
|
+
<div id="version-branch-list" class="version-branch-list"></div>
|
|
1195
|
+
</div>
|
|
1196
|
+
|
|
1197
|
+
<!-- Compare Tab -->
|
|
1198
|
+
<div id="version-compare" class="version-tab-content">
|
|
1199
|
+
<p style="color: #999; font-size: 11px;">
|
|
1200
|
+
Select two versions to compare them side-by-side.
|
|
1201
|
+
</p>
|
|
1202
|
+
</div>
|
|
1203
|
+
</div>
|
|
1204
|
+
</div>
|
|
1205
|
+
|
|
1206
|
+
<style>
|
|
1207
|
+
.version-panel {
|
|
1208
|
+
position: fixed;
|
|
1209
|
+
left: 0;
|
|
1210
|
+
top: 80px;
|
|
1211
|
+
width: 320px;
|
|
1212
|
+
height: 600px;
|
|
1213
|
+
background: #1e1e1e;
|
|
1214
|
+
border-right: 1px solid #333;
|
|
1215
|
+
display: flex;
|
|
1216
|
+
flex-direction: column;
|
|
1217
|
+
z-index: 1000;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
.version-header {
|
|
1221
|
+
display: flex;
|
|
1222
|
+
justify-content: space-between;
|
|
1223
|
+
align-items: center;
|
|
1224
|
+
padding: 12px;
|
|
1225
|
+
border-bottom: 1px solid #333;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
.version-header h3 {
|
|
1229
|
+
margin: 0;
|
|
1230
|
+
color: #e0e0e0;
|
|
1231
|
+
font-size: 14px;
|
|
1232
|
+
font-weight: 600;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
.version-close-btn {
|
|
1236
|
+
background: none;
|
|
1237
|
+
border: none;
|
|
1238
|
+
color: #999;
|
|
1239
|
+
font-size: 20px;
|
|
1240
|
+
cursor: pointer;
|
|
1241
|
+
padding: 0;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
.version-content {
|
|
1245
|
+
flex: 1;
|
|
1246
|
+
overflow: hidden;
|
|
1247
|
+
display: flex;
|
|
1248
|
+
flex-direction: column;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
.version-toolbar {
|
|
1252
|
+
display: flex;
|
|
1253
|
+
gap: 6px;
|
|
1254
|
+
padding: 12px;
|
|
1255
|
+
border-bottom: 1px solid #333;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
.version-btn {
|
|
1259
|
+
flex: 1;
|
|
1260
|
+
padding: 8px;
|
|
1261
|
+
border: none;
|
|
1262
|
+
border-radius: 4px;
|
|
1263
|
+
background: #333;
|
|
1264
|
+
color: #e0e0e0;
|
|
1265
|
+
font-size: 12px;
|
|
1266
|
+
cursor: pointer;
|
|
1267
|
+
transition: background 0.2s;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
.version-btn:hover {
|
|
1271
|
+
background: #444;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
.version-btn-primary {
|
|
1275
|
+
background: #0284C7;
|
|
1276
|
+
color: white;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
.version-btn-primary:hover {
|
|
1280
|
+
background: #0369a1;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
.version-tabs {
|
|
1284
|
+
display: flex;
|
|
1285
|
+
border-bottom: 1px solid #333;
|
|
1286
|
+
gap: 0;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
.version-tab {
|
|
1290
|
+
flex: 1;
|
|
1291
|
+
padding: 8px;
|
|
1292
|
+
border: none;
|
|
1293
|
+
background: transparent;
|
|
1294
|
+
color: #999;
|
|
1295
|
+
font-size: 12px;
|
|
1296
|
+
cursor: pointer;
|
|
1297
|
+
border-bottom: 2px solid transparent;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
.version-tab.active {
|
|
1301
|
+
color: #0284C7;
|
|
1302
|
+
border-bottom-color: #0284C7;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
.version-tab-content {
|
|
1306
|
+
flex: 1;
|
|
1307
|
+
overflow-y: auto;
|
|
1308
|
+
padding: 12px;
|
|
1309
|
+
display: none;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
.version-tab-content.active {
|
|
1313
|
+
display: block;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
.version-list {
|
|
1317
|
+
display: flex;
|
|
1318
|
+
flex-direction: column;
|
|
1319
|
+
gap: 8px;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
.version-item {
|
|
1323
|
+
padding: 8px;
|
|
1324
|
+
background: #2a2a2a;
|
|
1325
|
+
border-left: 3px solid #0284C7;
|
|
1326
|
+
border-radius: 2px;
|
|
1327
|
+
cursor: pointer;
|
|
1328
|
+
transition: background 0.2s;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
.version-item:hover {
|
|
1332
|
+
background: #333;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
.version-item-number {
|
|
1336
|
+
font-weight: 600;
|
|
1337
|
+
color: #0284C7;
|
|
1338
|
+
font-size: 11px;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
.version-item-message {
|
|
1342
|
+
color: #e0e0e0;
|
|
1343
|
+
font-size: 12px;
|
|
1344
|
+
margin: 4px 0;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
.version-item-time {
|
|
1348
|
+
color: #666;
|
|
1349
|
+
font-size: 10px;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
.version-branch-list {
|
|
1353
|
+
display: flex;
|
|
1354
|
+
flex-direction: column;
|
|
1355
|
+
gap: 8px;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
.version-branch-item {
|
|
1359
|
+
padding: 8px;
|
|
1360
|
+
background: #2a2a2a;
|
|
1361
|
+
border-radius: 4px;
|
|
1362
|
+
font-size: 12px;
|
|
1363
|
+
color: #e0e0e0;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
.version-branch-item.current {
|
|
1367
|
+
border-left: 3px solid #81c784;
|
|
1368
|
+
background: #1b5e20;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
.version-toast {
|
|
1372
|
+
position: fixed;
|
|
1373
|
+
bottom: 20px;
|
|
1374
|
+
right: 20px;
|
|
1375
|
+
padding: 12px 16px;
|
|
1376
|
+
border-radius: 4px;
|
|
1377
|
+
font-size: 12px;
|
|
1378
|
+
animation: slideInRight 0.3s ease;
|
|
1379
|
+
z-index: 10000;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
@keyframes slideInRight {
|
|
1383
|
+
from {
|
|
1384
|
+
transform: translateX(100%);
|
|
1385
|
+
opacity: 0;
|
|
1386
|
+
}
|
|
1387
|
+
to {
|
|
1388
|
+
transform: translateX(0);
|
|
1389
|
+
opacity: 1;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
.version-toast-success {
|
|
1394
|
+
background: #1b5e20;
|
|
1395
|
+
color: #81c784;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
.version-toast-error {
|
|
1399
|
+
background: #b71c1c;
|
|
1400
|
+
color: #ff5252;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
.version-toast-info {
|
|
1404
|
+
background: #01579b;
|
|
1405
|
+
color: #81d4fa;
|
|
1406
|
+
}
|
|
1407
|
+
</style>
|
|
1408
|
+
`;
|
|
1409
|
+
},
|
|
1410
|
+
};
|