cyclecad 0.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/app/js/tree.js ADDED
@@ -0,0 +1,479 @@
1
+ /**
2
+ * tree.js - Feature tree panel for cycleCAD
3
+ * Manages feature list, selection, and tree operations (rename, delete, suppress, edit)
4
+ */
5
+
6
+ const FEATURE_ICONS = {
7
+ Origin: '⊕',
8
+ Sketch: '✏️',
9
+ Extrude: '📦',
10
+ Revolve: '🔄',
11
+ Fillet: '⭕',
12
+ Chamfer: '📐',
13
+ Cut: '✂️',
14
+ Union: '∪',
15
+ Box: '□',
16
+ Cylinder: '🔵',
17
+ Sphere: '🔴',
18
+ Pad: '📦',
19
+ };
20
+
21
+ let treeState = {
22
+ containerEl: null,
23
+ features: [],
24
+ selectedIndex: -1,
25
+ onSelectCallback: null,
26
+ contextMenuTarget: null,
27
+ };
28
+
29
+ /**
30
+ * Initialize the feature tree in the left panel
31
+ * @param {HTMLElement} containerEl - Container for the tree
32
+ */
33
+ export function initTree(containerEl) {
34
+ treeState.containerEl = containerEl;
35
+ treeState.features = [];
36
+ treeState.selectedIndex = -1;
37
+
38
+ // Create tree structure
39
+ containerEl.innerHTML = `
40
+ <div class="tree-panel">
41
+ <div class="tree-header">
42
+ <h3>Features</h3>
43
+ <div class="tree-counter"><span id="feature-count">0</span> features</div>
44
+ </div>
45
+ <div class="tree-list" id="tree-list"></div>
46
+ <div class="tree-empty" id="tree-empty">
47
+ <p>Start with a sketch</p>
48
+ </div>
49
+ </div>
50
+ `;
51
+
52
+ // Add styles if not already present
53
+ if (!document.getElementById('tree-styles')) {
54
+ const style = document.createElement('style');
55
+ style.id = 'tree-styles';
56
+ style.textContent = `
57
+ .tree-panel {
58
+ display: flex;
59
+ flex-direction: column;
60
+ height: 100%;
61
+ background: var(--surface, #1e1e1e);
62
+ color: var(--text, #e0e0e0);
63
+ border-right: 1px solid var(--border, #333);
64
+ }
65
+
66
+ .tree-header {
67
+ padding: 16px;
68
+ border-bottom: 1px solid var(--border, #333);
69
+ }
70
+
71
+ .tree-header h3 {
72
+ margin: 0 0 8px 0;
73
+ font-size: 14px;
74
+ font-weight: 600;
75
+ color: var(--text, #e0e0e0);
76
+ }
77
+
78
+ .tree-counter {
79
+ font-size: 12px;
80
+ color: var(--text2, #a0a0a0);
81
+ }
82
+
83
+ .tree-list {
84
+ flex: 1;
85
+ overflow-y: auto;
86
+ min-height: 0;
87
+ }
88
+
89
+ .tree-list::-webkit-scrollbar {
90
+ width: 8px;
91
+ }
92
+
93
+ .tree-list::-webkit-scrollbar-track {
94
+ background: transparent;
95
+ }
96
+
97
+ .tree-list::-webkit-scrollbar-thumb {
98
+ background: var(--border, #333);
99
+ border-radius: 4px;
100
+ }
101
+
102
+ .tree-list::-webkit-scrollbar-thumb:hover {
103
+ background: var(--text2, #a0a0a0);
104
+ }
105
+
106
+ .tree-item {
107
+ padding: 12px 16px;
108
+ cursor: pointer;
109
+ border-left: 3px solid transparent;
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 10px;
113
+ font-size: 13px;
114
+ user-select: none;
115
+ transition: background 0.15s, border-color 0.15s;
116
+ }
117
+
118
+ .tree-item:hover {
119
+ background: rgba(255, 255, 255, 0.05);
120
+ }
121
+
122
+ .tree-item.selected {
123
+ background: var(--accent, rgba(100, 150, 255, 0.15));
124
+ border-left-color: var(--accent, #6496ff);
125
+ }
126
+
127
+ .tree-item-icon {
128
+ min-width: 20px;
129
+ text-align: center;
130
+ }
131
+
132
+ .tree-item-name {
133
+ flex: 1;
134
+ white-space: nowrap;
135
+ overflow: hidden;
136
+ text-overflow: ellipsis;
137
+ }
138
+
139
+ .tree-item-menu {
140
+ opacity: 0;
141
+ cursor: pointer;
142
+ font-size: 12px;
143
+ padding: 4px;
144
+ transition: opacity 0.15s;
145
+ }
146
+
147
+ .tree-item:hover .tree-item-menu {
148
+ opacity: 1;
149
+ }
150
+
151
+ .tree-empty {
152
+ display: flex;
153
+ align-items: center;
154
+ justify-content: center;
155
+ height: 100%;
156
+ color: var(--text2, #a0a0a0);
157
+ font-size: 13px;
158
+ }
159
+
160
+ .tree-context-menu {
161
+ position: fixed;
162
+ background: var(--surface, #1e1e1e);
163
+ border: 1px solid var(--border, #333);
164
+ border-radius: 4px;
165
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
166
+ z-index: 10000;
167
+ min-width: 160px;
168
+ }
169
+
170
+ .tree-context-item {
171
+ padding: 8px 16px;
172
+ cursor: pointer;
173
+ font-size: 13px;
174
+ color: var(--text, #e0e0e0);
175
+ display: flex;
176
+ align-items: center;
177
+ gap: 8px;
178
+ transition: background 0.15s;
179
+ }
180
+
181
+ .tree-context-item:hover {
182
+ background: var(--accent, rgba(100, 150, 255, 0.2));
183
+ }
184
+
185
+ .tree-context-item.danger {
186
+ color: #ff6b6b;
187
+ }
188
+
189
+ .tree-context-item.danger:hover {
190
+ background: rgba(255, 107, 107, 0.15);
191
+ }
192
+ `;
193
+ document.head.appendChild(style);
194
+ }
195
+
196
+ // Attach event listeners
197
+ attachTreeListeners();
198
+ }
199
+
200
+ /**
201
+ * Add a feature to the tree
202
+ * @param {Object} feature - Feature object { type, name, icon, params, mesh }
203
+ */
204
+ export function addFeature(feature) {
205
+ if (!feature.type || !feature.name) {
206
+ console.warn('Feature missing type or name', feature);
207
+ return;
208
+ }
209
+
210
+ feature.icon = feature.icon || FEATURE_ICONS[feature.type] || '?';
211
+ feature.suppressed = false;
212
+ feature.mesh = feature.mesh || null;
213
+ feature.params = feature.params || {};
214
+
215
+ treeState.features.push(feature);
216
+ renderTree();
217
+ updateFeatureCount();
218
+ }
219
+
220
+ /**
221
+ * Remove a feature from the tree
222
+ * @param {number} index - Feature index
223
+ */
224
+ export function removeFeature(index) {
225
+ if (index < 0 || index >= treeState.features.length) {
226
+ console.warn('Invalid feature index', index);
227
+ return;
228
+ }
229
+
230
+ treeState.features.splice(index, 1);
231
+
232
+ // Adjust selected index if needed
233
+ if (treeState.selectedIndex >= treeState.features.length) {
234
+ treeState.selectedIndex = treeState.features.length - 1;
235
+ }
236
+
237
+ if (treeState.selectedIndex < 0) {
238
+ treeState.selectedIndex = -1;
239
+ }
240
+
241
+ renderTree();
242
+ updateFeatureCount();
243
+ }
244
+
245
+ /**
246
+ * Select a feature in the tree
247
+ * @param {number} index - Feature index
248
+ * @returns {Object} Selected feature or null
249
+ */
250
+ export function selectFeature(index) {
251
+ if (index < 0 || index >= treeState.features.length) {
252
+ treeState.selectedIndex = -1;
253
+ renderTree();
254
+ return null;
255
+ }
256
+
257
+ treeState.selectedIndex = index;
258
+ renderTree();
259
+
260
+ if (treeState.onSelectCallback) {
261
+ treeState.onSelectCallback(treeState.features[index], index);
262
+ }
263
+
264
+ return treeState.features[index];
265
+ }
266
+
267
+ /**
268
+ * Get all features
269
+ * @returns {Array} Array of feature objects
270
+ */
271
+ export function getFeatures() {
272
+ return [...treeState.features];
273
+ }
274
+
275
+ /**
276
+ * Get currently selected feature
277
+ * @returns {Object|null} Selected feature or null
278
+ */
279
+ export function getSelectedFeature() {
280
+ if (treeState.selectedIndex < 0) return null;
281
+ return treeState.features[treeState.selectedIndex];
282
+ }
283
+
284
+ /**
285
+ * Update the feature counter in the header
286
+ */
287
+ export function updateFeatureCount() {
288
+ const countEl = document.getElementById('feature-count');
289
+ if (countEl) {
290
+ countEl.textContent = treeState.features.length;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Register a callback when a feature is selected
296
+ * @param {Function} callback - Called with (feature, index)
297
+ */
298
+ export function onSelect(callback) {
299
+ treeState.onSelectCallback = callback;
300
+ }
301
+
302
+ /**
303
+ * Suppress/unsuppress a feature
304
+ * @param {number} index - Feature index
305
+ */
306
+ export function suppressFeature(index) {
307
+ if (index >= 0 && index < treeState.features.length) {
308
+ treeState.features[index].suppressed = !treeState.features[index].suppressed;
309
+ renderTree();
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Rename a feature
315
+ * @param {number} index - Feature index
316
+ * @param {string} newName - New name
317
+ */
318
+ export function renameFeature(index, newName) {
319
+ if (index >= 0 && index < treeState.features.length && newName.trim()) {
320
+ treeState.features[index].name = newName.trim();
321
+ renderTree();
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Internal: Render the tree list
327
+ */
328
+ function renderTree() {
329
+ const treeList = document.getElementById('tree-list');
330
+ const treeEmpty = document.getElementById('tree-empty');
331
+
332
+ if (!treeList) return;
333
+
334
+ if (treeState.features.length === 0) {
335
+ treeList.innerHTML = '';
336
+ if (treeEmpty) treeEmpty.style.display = 'flex';
337
+ return;
338
+ }
339
+
340
+ if (treeEmpty) treeEmpty.style.display = 'none';
341
+
342
+ treeList.innerHTML = treeState.features
343
+ .map(
344
+ (feature, idx) => `
345
+ <div class="tree-item ${idx === treeState.selectedIndex ? 'selected' : ''}"
346
+ data-index="${idx}">
347
+ <div class="tree-item-icon">${feature.icon}</div>
348
+ <div class="tree-item-name" title="${feature.name}">
349
+ ${feature.suppressed ? '🚫 ' : ''}${feature.name}
350
+ </div>
351
+ <div class="tree-item-menu" data-menu="${idx}">⋮</div>
352
+ </div>
353
+ `
354
+ )
355
+ .join('');
356
+
357
+ attachTreeListeners();
358
+ }
359
+
360
+ /**
361
+ * Internal: Attach event listeners to tree items
362
+ */
363
+ function attachTreeListeners() {
364
+ const treeItems = document.querySelectorAll('.tree-item');
365
+
366
+ treeItems.forEach((item) => {
367
+ // Select on click
368
+ item.addEventListener('click', (e) => {
369
+ if (e.target.closest('.tree-item-menu')) return;
370
+ const idx = parseInt(item.dataset.index);
371
+ selectFeature(idx);
372
+ });
373
+
374
+ // Context menu on right-click or menu button click
375
+ item.addEventListener('contextmenu', (e) => {
376
+ e.preventDefault();
377
+ const idx = parseInt(item.dataset.index);
378
+ showContextMenu(idx, e.clientX, e.clientY);
379
+ });
380
+
381
+ const menuBtn = item.querySelector('.tree-item-menu');
382
+ if (menuBtn) {
383
+ menuBtn.addEventListener('click', (e) => {
384
+ e.stopPropagation();
385
+ const idx = parseInt(item.dataset.index);
386
+ const rect = menuBtn.getBoundingClientRect();
387
+ showContextMenu(idx, rect.right - 10, rect.bottom + 5);
388
+ });
389
+ }
390
+ });
391
+ }
392
+
393
+ /**
394
+ * Internal: Show context menu
395
+ */
396
+ function showContextMenu(index, x, y) {
397
+ if (index < 0 || index >= treeState.features.length) return;
398
+
399
+ // Remove existing context menu
400
+ const existing = document.querySelector('.tree-context-menu');
401
+ if (existing) existing.remove();
402
+
403
+ const feature = treeState.features[index];
404
+ const menu = document.createElement('div');
405
+ menu.className = 'tree-context-menu';
406
+ menu.style.left = x + 'px';
407
+ menu.style.top = y + 'px';
408
+
409
+ menu.innerHTML = `
410
+ <div class="tree-context-item" data-action="rename">
411
+ ✏️ Rename
412
+ </div>
413
+ <div class="tree-context-item" data-action="edit-sketch" ${feature.type !== 'Sketch' ? 'style="opacity:0.5; cursor:not-allowed;"' : ''}>
414
+ 📐 Edit Sketch
415
+ </div>
416
+ <div class="tree-context-item" data-action="suppress">
417
+ ${feature.suppressed ? '✓' : '⊘'} ${feature.suppressed ? 'Unsuppress' : 'Suppress'}
418
+ </div>
419
+ <div class="tree-context-item danger" data-action="delete">
420
+ 🗑️ Delete
421
+ </div>
422
+ `;
423
+
424
+ document.body.appendChild(menu);
425
+
426
+ // Close menu on click outside
427
+ const closeMenu = () => {
428
+ menu.remove();
429
+ document.removeEventListener('click', closeMenu);
430
+ };
431
+
432
+ setTimeout(() => {
433
+ document.addEventListener('click', closeMenu);
434
+ }, 0);
435
+
436
+ // Handle menu actions
437
+ menu.querySelectorAll('.tree-context-item').forEach((item) => {
438
+ item.addEventListener('click', () => {
439
+ const action = item.dataset.action;
440
+
441
+ if (action === 'rename') {
442
+ const newName = prompt('Enter new name:', feature.name);
443
+ if (newName !== null) {
444
+ renameFeature(index, newName);
445
+ }
446
+ } else if (action === 'edit-sketch') {
447
+ if (feature.type === 'Sketch') {
448
+ // Dispatch custom event for sketch editor
449
+ window.dispatchEvent(
450
+ new CustomEvent('cyclecad:edit-sketch', {
451
+ detail: { index, feature },
452
+ })
453
+ );
454
+ }
455
+ } else if (action === 'suppress') {
456
+ suppressFeature(index);
457
+ } else if (action === 'delete') {
458
+ if (confirm(`Delete "${feature.name}"?`)) {
459
+ removeFeature(index);
460
+ }
461
+ }
462
+
463
+ menu.remove();
464
+ });
465
+ });
466
+ }
467
+
468
+ export default {
469
+ initTree,
470
+ addFeature,
471
+ removeFeature,
472
+ selectFeature,
473
+ getFeatures,
474
+ getSelectedFeature,
475
+ updateFeatureCount,
476
+ onSelect,
477
+ suppressFeature,
478
+ renameFeature,
479
+ };