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.
Files changed (33) hide show
  1. package/IMPLEMENTATION_GUIDE.md +502 -0
  2. package/INTEGRATION-GUIDE.md +377 -0
  3. package/MODULES_PHASES_6_7.md +780 -0
  4. package/app/index.html +106 -2
  5. package/app/js/brep-kernel.js +1353 -455
  6. package/app/js/help-module.js +1437 -0
  7. package/app/js/kernel.js +364 -40
  8. package/app/js/modules/animation-module.js +967 -0
  9. package/app/js/modules/assembly-module.js +47 -3
  10. package/app/js/modules/cam-module.js +1067 -0
  11. package/app/js/modules/collaboration-module.js +1102 -0
  12. package/app/js/modules/data-module.js +1656 -0
  13. package/app/js/modules/drawing-module.js +54 -8
  14. package/app/js/modules/formats-module.js +1173 -0
  15. package/app/js/modules/inspection-module.js +937 -0
  16. package/app/js/modules/mesh-module.js +968 -0
  17. package/app/js/modules/operations-module.js +40 -7
  18. package/app/js/modules/plugin-module.js +957 -0
  19. package/app/js/modules/rendering-module.js +1306 -0
  20. package/app/js/modules/scripting-module.js +955 -0
  21. package/app/js/modules/simulation-module.js +60 -3
  22. package/app/js/modules/sketch-module.js +1032 -90
  23. package/app/js/modules/step-module.js +47 -6
  24. package/app/js/modules/surface-module.js +728 -0
  25. package/app/js/modules/version-module.js +1410 -0
  26. package/app/js/modules/viewport-module.js +95 -8
  27. package/app/test-agent-v2.html +881 -1316
  28. package/docs/ARCHITECTURE.html +838 -1408
  29. package/docs/DEVELOPER-GUIDE.md +1504 -0
  30. package/docs/TUTORIAL.md +740 -0
  31. package/package.json +1 -1
  32. package/.github/scripts/cad-diff.js +0 -590
  33. 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
+ };