cyclecad 3.9.14 → 3.9.18

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.
@@ -0,0 +1,1322 @@
1
+ /**
2
+ * cycleCAD Parametric Sliders Module
3
+ *
4
+ * Real-time parametric control with auto-detection, dimension annotations,
5
+ * presets, expressions, and history. Beats CADAM's interactive sliders.
6
+ *
7
+ * @module ParametricSliders
8
+ * @version 1.0.0
9
+ * @author Sachin Kumar
10
+ */
11
+
12
+ (function() {
13
+ 'use strict';
14
+
15
+ // ============================================================================
16
+ // STATE
17
+ // ============================================================================
18
+
19
+ let scene = null;
20
+ let renderer = null;
21
+ let activeMesh = null;
22
+ let originalGeometry = null;
23
+ let currentParameters = {};
24
+ let parameterDefinitions = {};
25
+ let parameterHistory = [];
26
+ let presets = {};
27
+ let annotations = {};
28
+ let annotationRenderer = null;
29
+ let annotationScene = null;
30
+ let annotationCamera = null;
31
+ let linkedParams = {};
32
+ let constraints = {};
33
+ let isUpdating = false;
34
+ let updateTimeouts = new Map();
35
+
36
+ // CSS2DRenderer for dimension labels
37
+ let CSS2DRenderer = null;
38
+ let CSS2DObject = null;
39
+
40
+ // ============================================================================
41
+ // GEOMETRY TYPE DETECTION & PARAMETER EXTRACTION
42
+ // ============================================================================
43
+
44
+ /**
45
+ * Detect geometry type and extract parametric dimensions
46
+ * @param {THREE.BufferGeometry} geometry
47
+ * @returns {Object} {type, parameters, original}
48
+ */
49
+ function analyzeGeometry(geometry) {
50
+ const params = {};
51
+ const original = {};
52
+
53
+ // BoxGeometry
54
+ if (geometry.type === 'BoxGeometry' && geometry.parameters) {
55
+ const p = geometry.parameters;
56
+ params.width = p.width || 100;
57
+ params.height = p.height || 100;
58
+ params.depth = p.depth || 100;
59
+ original.width = params.width;
60
+ original.height = params.height;
61
+ original.depth = params.depth;
62
+ return { type: 'BoxGeometry', parameters: params, original };
63
+ }
64
+
65
+ // CylinderGeometry
66
+ if (geometry.type === 'CylinderGeometry' && geometry.parameters) {
67
+ const p = geometry.parameters;
68
+ params.radiusTop = p.radiusTop || 20;
69
+ params.radiusBottom = p.radiusBottom || 20;
70
+ params.height = p.height || 100;
71
+ params.radialSegments = p.radialSegments || 32;
72
+ params.heightSegments = p.heightSegments || 1;
73
+ original.radiusTop = params.radiusTop;
74
+ original.radiusBottom = params.radiusBottom;
75
+ original.height = params.height;
76
+ original.radialSegments = params.radialSegments;
77
+ original.heightSegments = params.heightSegments;
78
+ return { type: 'CylinderGeometry', parameters: params, original };
79
+ }
80
+
81
+ // SphereGeometry
82
+ if (geometry.type === 'SphereGeometry' && geometry.parameters) {
83
+ const p = geometry.parameters;
84
+ params.radius = p.radius || 50;
85
+ params.widthSegments = p.widthSegments || 32;
86
+ params.heightSegments = p.heightSegments || 16;
87
+ original.radius = params.radius;
88
+ original.widthSegments = params.widthSegments;
89
+ original.heightSegments = params.heightSegments;
90
+ return { type: 'SphereGeometry', parameters: params, original };
91
+ }
92
+
93
+ // TorusGeometry
94
+ if (geometry.type === 'TorusGeometry' && geometry.parameters) {
95
+ const p = geometry.parameters;
96
+ params.radius = p.radius || 100;
97
+ params.tube = p.tube || 40;
98
+ params.radialSegments = p.radialSegments || 100;
99
+ params.tubularSegments = p.tubularSegments || 10;
100
+ original.radius = params.radius;
101
+ original.tube = params.tube;
102
+ original.radialSegments = params.radialSegments;
103
+ original.tubularSegments = params.tubularSegments;
104
+ return { type: 'TorusGeometry', parameters: params, original };
105
+ }
106
+
107
+ // LatheGeometry
108
+ if (geometry.type === 'LatheGeometry' && geometry.parameters) {
109
+ const p = geometry.parameters;
110
+ params.segments = p.segments || 12;
111
+ params.phiStart = p.phiStart || 0;
112
+ params.phiLength = p.phiLength || Math.PI * 2;
113
+ original.segments = params.segments;
114
+ original.phiStart = params.phiStart;
115
+ original.phiLength = params.phiLength;
116
+ return { type: 'LatheGeometry', parameters: params, original };
117
+ }
118
+
119
+ // ConvexGeometry / custom shapes: use bounding box
120
+ const bbox = geometry.boundingBox || new THREE.Box3().setFromBufferAttribute(geometry.getAttribute('position'));
121
+ const size = bbox.getSize(new THREE.Vector3());
122
+ params.width = parseFloat(size.x.toFixed(2));
123
+ params.height = parseFloat(size.y.toFixed(2));
124
+ params.depth = parseFloat(size.z.toFixed(2));
125
+ original.width = params.width;
126
+ original.height = params.height;
127
+ original.depth = params.depth;
128
+ return { type: 'CustomGeometry', parameters: params, original };
129
+ }
130
+
131
+ /**
132
+ * Rebuild geometry based on type and new parameters
133
+ * @param {string} type - Geometry type
134
+ * @param {Object} params - New parameters
135
+ * @returns {THREE.BufferGeometry}
136
+ */
137
+ function rebuildGeometry(type, params) {
138
+ switch (type) {
139
+ case 'BoxGeometry':
140
+ return new THREE.BoxGeometry(params.width, params.height, params.depth);
141
+
142
+ case 'CylinderGeometry':
143
+ return new THREE.CylinderGeometry(
144
+ params.radiusTop,
145
+ params.radiusBottom,
146
+ params.height,
147
+ Math.max(3, Math.floor(params.radialSegments || 32)),
148
+ Math.max(1, Math.floor(params.heightSegments || 1))
149
+ );
150
+
151
+ case 'SphereGeometry':
152
+ return new THREE.SphereGeometry(
153
+ params.radius,
154
+ Math.max(3, Math.floor(params.widthSegments || 32)),
155
+ Math.max(2, Math.floor(params.heightSegments || 16))
156
+ );
157
+
158
+ case 'TorusGeometry':
159
+ return new THREE.TorusGeometry(
160
+ params.radius,
161
+ params.tube,
162
+ Math.max(3, Math.floor(params.radialSegments || 100)),
163
+ Math.max(2, Math.floor(params.tubularSegments || 10))
164
+ );
165
+
166
+ case 'LatheGeometry':
167
+ return new THREE.LatheGeometry(
168
+ new THREE.LineCurve3(new THREE.Vector3(0, -50, 0), new THREE.Vector3(50, 50, 0)),
169
+ Math.max(4, Math.floor(params.segments || 12))
170
+ );
171
+
172
+ default:
173
+ return new THREE.BoxGeometry(params.width, params.height, params.depth);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Categorize parameter for UI styling
179
+ * @param {string} name - Parameter name
180
+ * @returns {string} Category: 'size' | 'detail' | 'angle' | 'other'
181
+ */
182
+ function categorizeParameter(name) {
183
+ const lower = name.toLowerCase();
184
+ if (lower.includes('width') || lower.includes('height') || lower.includes('depth') ||
185
+ lower.includes('radius') || lower.includes('tube')) {
186
+ return 'size';
187
+ }
188
+ if (lower.includes('segment') || lower.includes('point')) {
189
+ return 'detail';
190
+ }
191
+ if (lower.includes('angle') || lower.includes('phi') || lower.includes('theta')) {
192
+ return 'angle';
193
+ }
194
+ return 'other';
195
+ }
196
+
197
+ /**
198
+ * Get unit label for parameter
199
+ * @param {string} name - Parameter name
200
+ * @returns {string}
201
+ */
202
+ function getUnit(name) {
203
+ const lower = name.toLowerCase();
204
+ if (lower.includes('segment') || lower.includes('point') || lower.includes('count')) {
205
+ return '#';
206
+ }
207
+ if (lower.includes('angle') || lower.includes('phi') || lower.includes('theta')) {
208
+ return '°';
209
+ }
210
+ return 'mm';
211
+ }
212
+
213
+ // ============================================================================
214
+ // PARAMETER DEFINITIONS & CONSTRAINTS
215
+ // ============================================================================
216
+
217
+ /**
218
+ * Create parameter definitions with constraints
219
+ * @param {Object} params - Raw parameters
220
+ * @returns {Object}
221
+ */
222
+ function createParameterDefinitions(params) {
223
+ const defs = {};
224
+ for (const [name, value] of Object.entries(params)) {
225
+ const category = categorizeParameter(name);
226
+ const isSegment = category === 'detail';
227
+ const isAngle = category === 'angle';
228
+
229
+ defs[name] = {
230
+ name,
231
+ value,
232
+ original: value,
233
+ category,
234
+ unit: getUnit(name),
235
+ min: isSegment ? 1 : isAngle ? 0 : value * 0.1,
236
+ max: value * 10,
237
+ step: isSegment ? 1 : isAngle ? 5 : value > 100 ? 1 : 0.1,
238
+ locked: false,
239
+ linkedTo: null,
240
+ expression: null
241
+ };
242
+ }
243
+ return defs;
244
+ }
245
+
246
+ /**
247
+ * Evaluate parameter expression
248
+ * @param {string} expr - Expression like "2*radius" or "height/3"
249
+ * @param {Object} context - Parameter context
250
+ * @returns {number}
251
+ */
252
+ function evaluateExpression(expr, context) {
253
+ try {
254
+ // Safe evaluation: only allow parameter names, numbers, and math operators
255
+ let sanitized = expr;
256
+ const paramNames = Object.keys(context);
257
+ for (const name of paramNames) {
258
+ sanitized = sanitized.replace(new RegExp(`\\b${name}\\b`, 'g'), context[name]);
259
+ }
260
+ // eslint-disable-next-line no-eval
261
+ const result = Function('"use strict"; return (' + sanitized + ')')();
262
+ return isFinite(result) ? result : NaN;
263
+ } catch {
264
+ return NaN;
265
+ }
266
+ }
267
+
268
+ // ============================================================================
269
+ // DIMENSION ANNOTATIONS
270
+ // ============================================================================
271
+
272
+ /**
273
+ * Create dimension annotation label
274
+ * @param {string} label - Label text
275
+ * @param {THREE.Vector3} position - World position
276
+ * @returns {THREE.CSS2DObject}
277
+ */
278
+ function createAnnotationLabel(label, position) {
279
+ if (!CSS2DObject) {
280
+ // Fallback: log error (proper CSS2DRenderer requires three-stdlib)
281
+ return null;
282
+ }
283
+
284
+ const div = document.createElement('div');
285
+ div.className = 'dimension-label';
286
+ div.textContent = label;
287
+ div.style.cssText = `
288
+ background: rgba(0, 100, 200, 0.9);
289
+ color: white;
290
+ padding: 4px 8px;
291
+ border-radius: 3px;
292
+ font-size: 12px;
293
+ font-weight: bold;
294
+ pointer-events: none;
295
+ white-space: nowrap;
296
+ box-shadow: 0 2px 4px rgba(0,0,0,0.3);
297
+ transform: translate(-50%, -50%);
298
+ `;
299
+
300
+ const obj = new CSS2DObject(div);
301
+ obj.position.copy(position);
302
+ return obj;
303
+ }
304
+
305
+ /**
306
+ * Add dimension lines to scene
307
+ * @param {THREE.Mesh} mesh
308
+ */
309
+ function addDimensionAnnotations(mesh) {
310
+ if (!mesh || !mesh.geometry.parameters) {
311
+ return;
312
+ }
313
+
314
+ clearAnnotations();
315
+
316
+ const params = parameterDefinitions;
317
+ const bbox = new THREE.Box3().setFromObject(mesh);
318
+ const size = bbox.getSize(new THREE.Vector3());
319
+ const center = bbox.getCenter(new THREE.Vector3());
320
+
321
+ // Width annotation
322
+ if (params.width) {
323
+ const widthLabel = `W: ${params.width.value.toFixed(1)}${params.width.unit}`;
324
+ const wPos = new THREE.Vector3(center.x + size.x / 2 + 30, center.y, center.z);
325
+ const label = createAnnotationLabel(widthLabel, wPos);
326
+ if (label) {
327
+ scene.add(label);
328
+ annotations.width = label;
329
+ }
330
+ }
331
+
332
+ // Height annotation
333
+ if (params.height) {
334
+ const heightLabel = `H: ${params.height.value.toFixed(1)}${params.height.unit}`;
335
+ const hPos = new THREE.Vector3(center.x, center.y + size.y / 2 + 30, center.z);
336
+ const label = createAnnotationLabel(heightLabel, hPos);
337
+ if (label) {
338
+ scene.add(label);
339
+ annotations.height = label;
340
+ }
341
+ }
342
+
343
+ // Depth annotation
344
+ if (params.depth) {
345
+ const depthLabel = `D: ${params.depth.value.toFixed(1)}${params.depth.unit}`;
346
+ const dPos = new THREE.Vector3(center.x, center.y, center.z + size.z / 2 + 30);
347
+ const label = createAnnotationLabel(depthLabel, dPos);
348
+ if (label) {
349
+ scene.add(label);
350
+ annotations.depth = label;
351
+ }
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Clear all dimension annotations
357
+ */
358
+ function clearAnnotations() {
359
+ for (const label of Object.values(annotations)) {
360
+ if (label && label.parent) {
361
+ scene.remove(label);
362
+ }
363
+ }
364
+ annotations = {};
365
+ }
366
+
367
+ // ============================================================================
368
+ // PARAMETER UPDATES & HISTORY
369
+ // ============================================================================
370
+
371
+ /**
372
+ * Update single parameter and rebuild geometry
373
+ * @param {string} name - Parameter name
374
+ * @param {number} value - New value
375
+ * @param {boolean} [addToHistory=true] - Add to undo history
376
+ */
377
+ function updateParameter(name, value, addToHistory = true) {
378
+ if (!activeMesh || !parameterDefinitions[name]) {
379
+ return;
380
+ }
381
+
382
+ const def = parameterDefinitions[name];
383
+
384
+ // Apply constraints
385
+ value = Math.max(def.min, Math.min(def.max, value));
386
+
387
+ // Evaluate expression if present
388
+ if (def.expression) {
389
+ const evaluated = evaluateExpression(def.expression, currentParameters);
390
+ if (!isNaN(evaluated)) {
391
+ value = evaluated;
392
+ }
393
+ }
394
+
395
+ // Round segment counts
396
+ if (def.category === 'detail') {
397
+ value = Math.floor(value);
398
+ }
399
+
400
+ // Check if value actually changed
401
+ if (def.value === value) {
402
+ return;
403
+ }
404
+
405
+ // Update parameter
406
+ def.value = value;
407
+ currentParameters[name] = value;
408
+
409
+ // Clear any pending updates for this param
410
+ if (updateTimeouts.has(name)) {
411
+ clearTimeout(updateTimeouts.get(name));
412
+ }
413
+
414
+ // Batch updates: wait 50ms before rebuilding geometry
415
+ // (allows multiple sliders to move before expensive rebuild)
416
+ if (!isUpdating) {
417
+ isUpdating = true;
418
+ const timeout = setTimeout(() => {
419
+ rebuildMeshGeometry();
420
+ updateDimensionLabels();
421
+ isUpdating = false;
422
+ }, 50);
423
+ updateTimeouts.set(name, timeout);
424
+ }
425
+
426
+ // Add to history
427
+ if (addToHistory) {
428
+ parameterHistory.push({
429
+ timestamp: Date.now(),
430
+ action: 'parameter_change',
431
+ parameter: name,
432
+ oldValue: def.original,
433
+ newValue: value,
434
+ snapshot: JSON.stringify(currentParameters)
435
+ });
436
+ }
437
+
438
+ // Update linked parameters
439
+ if (linkedParams[name]) {
440
+ for (const linkedName of linkedParams[name]) {
441
+ const ratio = (value / def.original);
442
+ updateParameter(linkedName, parameterDefinitions[linkedName].original * ratio, false);
443
+ }
444
+ }
445
+
446
+ // Update UI slider
447
+ const slider = document.querySelector(`[data-param="${name}"][type="range"]`);
448
+ if (slider) {
449
+ slider.value = value;
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Rebuild mesh geometry in place
455
+ */
456
+ function rebuildMeshGeometry() {
457
+ if (!activeMesh || !scene) {
458
+ return;
459
+ }
460
+
461
+ try {
462
+ // Build new geometry
463
+ let newGeometry;
464
+ if (originalGeometry && originalGeometry.type) {
465
+ newGeometry = rebuildGeometry(originalGeometry.type, currentParameters);
466
+ } else {
467
+ newGeometry = rebuildGeometry('BoxGeometry', currentParameters);
468
+ }
469
+
470
+ if (newGeometry) {
471
+ // Preserve position, rotation, scale
472
+ const oldGeom = activeMesh.geometry;
473
+ activeMesh.geometry = newGeometry;
474
+
475
+ // Dispose old geometry
476
+ if (oldGeom && oldGeom.dispose) {
477
+ oldGeom.dispose();
478
+ }
479
+
480
+ // Update bounding box
481
+ newGeometry.computeBoundingBox();
482
+
483
+ // Trigger render
484
+ if (renderer) {
485
+ renderer.render(scene, renderer._camera || window._camera);
486
+ }
487
+ }
488
+ } catch (error) {
489
+ console.error('Failed to rebuild geometry:', error);
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Update dimension label text and positions
495
+ */
496
+ function updateDimensionLabels() {
497
+ if (!activeMesh || !Object.keys(annotations).length) {
498
+ return;
499
+ }
500
+
501
+ const params = parameterDefinitions;
502
+ const bbox = new THREE.Box3().setFromObject(activeMesh);
503
+ const size = bbox.getSize(new THREE.Vector3());
504
+ const center = bbox.getCenter(new THREE.Vector3());
505
+
506
+ if (annotations.width && params.width) {
507
+ const label = `W: ${params.width.value.toFixed(1)}${params.width.unit}`;
508
+ annotations.width.element.textContent = label;
509
+ annotations.width.position.set(center.x + size.x / 2 + 30, center.y, center.z);
510
+ }
511
+
512
+ if (annotations.height && params.height) {
513
+ const label = `H: ${params.height.value.toFixed(1)}${params.height.unit}`;
514
+ annotations.height.element.textContent = label;
515
+ annotations.height.position.set(center.x, center.y + size.y / 2 + 30, center.z);
516
+ }
517
+
518
+ if (annotations.depth && params.depth) {
519
+ const label = `D: ${params.depth.value.toFixed(1)}${params.depth.unit}`;
520
+ annotations.depth.element.textContent = label;
521
+ annotations.depth.position.set(center.x, center.y, center.z + size.z / 2 + 30);
522
+ }
523
+ }
524
+
525
+ // ============================================================================
526
+ // PRESETS
527
+ // ============================================================================
528
+
529
+ /**
530
+ * Save current parameters as preset
531
+ * @param {string} name - Preset name
532
+ */
533
+ function savePreset(name) {
534
+ presets[name] = JSON.parse(JSON.stringify(currentParameters));
535
+ }
536
+
537
+ /**
538
+ * Load preset
539
+ * @param {string} name - Preset name
540
+ */
541
+ function loadPreset(name) {
542
+ if (!presets[name]) {
543
+ console.warn(`Preset "${name}" not found`);
544
+ return;
545
+ }
546
+
547
+ const preset = presets[name];
548
+ for (const [paramName, value] of Object.entries(preset)) {
549
+ updateParameter(paramName, value, true);
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Create standard size presets (metric bolts, etc.)
555
+ */
556
+ function createStandardPresets() {
557
+ presets['Standard - M3'] = { radiusTop: 1.5, radiusBottom: 1.5, height: 30, radialSegments: 32 };
558
+ presets['Standard - M4'] = { radiusTop: 2, radiusBottom: 2, height: 40, radialSegments: 32 };
559
+ presets['Standard - M5'] = { radiusTop: 2.5, radiusBottom: 2.5, height: 50, radialSegments: 32 };
560
+ presets['Standard - M6'] = { radiusTop: 3, radiusBottom: 3, height: 60, radialSegments: 32 };
561
+ presets['Standard - 1/4" NPT'] = { radiusTop: 3.3, radiusBottom: 3.3, height: 40, radialSegments: 32 };
562
+ presets['Standard - 1/2" NPT'] = { radiusTop: 6.35, radiusBottom: 6.35, height: 50, radialSegments: 32 };
563
+ }
564
+
565
+ // ============================================================================
566
+ // UI GENERATION
567
+ // ============================================================================
568
+
569
+ /**
570
+ * Generate HTML for slider controls
571
+ * @returns {string}
572
+ */
573
+ function generateSlidersHTML() {
574
+ let html = `
575
+ <div id="parametric-panel" class="module-panel">
576
+ <div class="panel-header">
577
+ <h3>Parametric Controls</h3>
578
+ <button id="toggle-annotations" class="icon-btn" title="Toggle Dimension Annotations">📏</button>
579
+ </div>
580
+ <div id="param-controls" class="param-controls">
581
+ `;
582
+
583
+ for (const [name, def] of Object.entries(parameterDefinitions)) {
584
+ const categoryColor = {
585
+ size: '#3b82f6',
586
+ detail: '#10b981',
587
+ angle: '#f97316',
588
+ other: '#6b7280'
589
+ }[def.category];
590
+
591
+ html += `
592
+ <div class="param-control" data-param="${name}">
593
+ <div class="param-header">
594
+ <label>${name}</label>
595
+ <div class="param-actions">
596
+ <button class="lock-btn" data-param="${name}" title="Lock/Unlock">🔓</button>
597
+ <button class="reset-btn" data-param="${name}" title="Reset">↻</button>
598
+ </div>
599
+ </div>
600
+ <div class="param-input-group">
601
+ <input type="range"
602
+ class="param-slider"
603
+ data-param="${name}"
604
+ min="${def.min.toFixed(2)}"
605
+ max="${def.max.toFixed(2)}"
606
+ step="${def.step.toFixed(3)}"
607
+ value="${def.value.toFixed(2)}"
608
+ style="accent-color: ${categoryColor}">
609
+ <input type="number"
610
+ class="param-input"
611
+ data-param="${name}"
612
+ min="${def.min.toFixed(2)}"
613
+ max="${def.max.toFixed(2)}"
614
+ step="${def.step.toFixed(3)}"
615
+ value="${def.value.toFixed(2)}">
616
+ <span class="param-unit">${def.unit}</span>
617
+ </div>
618
+ <div class="param-footer">
619
+ <span class="param-original">Original: ${def.original.toFixed(2)}</span>
620
+ <span class="param-category" style="color: ${categoryColor}">●</span>
621
+ </div>
622
+ </div>
623
+ `;
624
+ }
625
+
626
+ html += `
627
+ </div>
628
+
629
+ <div class="preset-section">
630
+ <h4>Presets</h4>
631
+ <div id="preset-list" class="preset-list">
632
+ `;
633
+
634
+ for (const presetName of Object.keys(presets)) {
635
+ html += `<button class="preset-btn" data-preset="${presetName}">${presetName}</button>`;
636
+ }
637
+
638
+ html += `
639
+ </div>
640
+ <div class="preset-actions">
641
+ <input type="text" id="preset-name" placeholder="Preset name" maxlength="30">
642
+ <button id="save-preset-btn" class="btn-primary">Save</button>
643
+ </div>
644
+ </div>
645
+
646
+ <div class="history-section">
647
+ <h4>History (<span id="history-count">0</span>)</h4>
648
+ <div id="history-timeline" class="history-timeline"></div>
649
+ <button id="export-history-btn" class="btn-secondary">Export JSON</button>
650
+ </div>
651
+ </div>
652
+ `;
653
+
654
+ return html;
655
+ }
656
+
657
+ /**
658
+ * Generate CSS for slider panel
659
+ * @returns {string}
660
+ */
661
+ function generateSliderCSS() {
662
+ return `
663
+ <style id="parametric-sliders-css">
664
+ #parametric-panel {
665
+ display: flex;
666
+ flex-direction: column;
667
+ height: 100%;
668
+ padding: 16px;
669
+ background: var(--bg-secondary, #1f2937);
670
+ border-left: 1px solid var(--border-color, #374151);
671
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif;
672
+ color: var(--text-primary, #f3f4f6);
673
+ overflow: auto;
674
+ }
675
+
676
+ #parametric-panel .panel-header {
677
+ display: flex;
678
+ justify-content: space-between;
679
+ align-items: center;
680
+ margin-bottom: 16px;
681
+ padding-bottom: 8px;
682
+ border-bottom: 1px solid var(--border-color, #374151);
683
+ }
684
+
685
+ #parametric-panel h3 {
686
+ margin: 0;
687
+ font-size: 16px;
688
+ font-weight: 600;
689
+ }
690
+
691
+ .icon-btn {
692
+ background: none;
693
+ border: 1px solid var(--border-color, #374151);
694
+ color: var(--text-primary, #f3f4f6);
695
+ padding: 6px 10px;
696
+ border-radius: 4px;
697
+ cursor: pointer;
698
+ font-size: 14px;
699
+ }
700
+
701
+ .icon-btn:hover {
702
+ background: var(--bg-tertiary, #111827);
703
+ border-color: var(--text-primary, #f3f4f6);
704
+ }
705
+
706
+ .param-controls {
707
+ flex: 1;
708
+ overflow-y: auto;
709
+ margin-bottom: 16px;
710
+ }
711
+
712
+ .param-control {
713
+ margin-bottom: 12px;
714
+ padding: 10px;
715
+ background: var(--bg-tertiary, #111827);
716
+ border-radius: 6px;
717
+ border: 1px solid var(--border-color, #374151);
718
+ }
719
+
720
+ .param-header {
721
+ display: flex;
722
+ justify-content: space-between;
723
+ align-items: center;
724
+ margin-bottom: 8px;
725
+ }
726
+
727
+ .param-header label {
728
+ font-weight: 500;
729
+ font-size: 13px;
730
+ text-transform: capitalize;
731
+ }
732
+
733
+ .param-actions {
734
+ display: flex;
735
+ gap: 4px;
736
+ }
737
+
738
+ .lock-btn, .reset-btn {
739
+ background: none;
740
+ border: none;
741
+ color: var(--text-secondary, #d1d5db);
742
+ cursor: pointer;
743
+ font-size: 12px;
744
+ padding: 2px 4px;
745
+ border-radius: 2px;
746
+ }
747
+
748
+ .lock-btn:hover, .reset-btn:hover {
749
+ background: var(--bg-secondary, #1f2937);
750
+ }
751
+
752
+ .lock-btn.locked {
753
+ color: #f97316;
754
+ }
755
+
756
+ .param-input-group {
757
+ display: grid;
758
+ grid-template-columns: 1fr auto auto;
759
+ gap: 8px;
760
+ margin-bottom: 6px;
761
+ align-items: center;
762
+ }
763
+
764
+ .param-slider {
765
+ width: 100%;
766
+ height: 24px;
767
+ cursor: pointer;
768
+ -webkit-appearance: none;
769
+ appearance: none;
770
+ background: transparent;
771
+ border: none;
772
+ padding: 0;
773
+ }
774
+
775
+ .param-slider::-webkit-slider-track {
776
+ height: 4px;
777
+ background: var(--border-color, #374151);
778
+ border-radius: 2px;
779
+ }
780
+
781
+ .param-slider::-webkit-slider-thumb {
782
+ -webkit-appearance: none;
783
+ appearance: none;
784
+ width: 16px;
785
+ height: 16px;
786
+ border-radius: 50%;
787
+ background: var(--accent-color, #0284c7);
788
+ cursor: pointer;
789
+ box-shadow: 0 2px 4px rgba(0,0,0,0.4);
790
+ }
791
+
792
+ .param-slider::-moz-range-track {
793
+ height: 4px;
794
+ background: var(--border-color, #374151);
795
+ border-radius: 2px;
796
+ border: none;
797
+ }
798
+
799
+ .param-slider::-moz-range-thumb {
800
+ width: 16px;
801
+ height: 16px;
802
+ border-radius: 50%;
803
+ background: var(--accent-color, #0284c7);
804
+ cursor: pointer;
805
+ border: none;
806
+ box-shadow: 0 2px 4px rgba(0,0,0,0.4);
807
+ }
808
+
809
+ .param-input {
810
+ width: 70px;
811
+ padding: 4px 6px;
812
+ background: var(--bg-secondary, #1f2937);
813
+ border: 1px solid var(--border-color, #374151);
814
+ border-radius: 3px;
815
+ color: var(--text-primary, #f3f4f6);
816
+ font-size: 12px;
817
+ text-align: right;
818
+ }
819
+
820
+ .param-input:focus {
821
+ outline: none;
822
+ border-color: var(--accent-color, #0284c7);
823
+ box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.2);
824
+ }
825
+
826
+ .param-unit {
827
+ font-size: 11px;
828
+ color: var(--text-secondary, #d1d5db);
829
+ min-width: 24px;
830
+ text-align: right;
831
+ }
832
+
833
+ .param-footer {
834
+ display: flex;
835
+ justify-content: space-between;
836
+ align-items: center;
837
+ font-size: 11px;
838
+ color: var(--text-secondary, #d1d5db);
839
+ }
840
+
841
+ .param-original {
842
+ font-style: italic;
843
+ }
844
+
845
+ .preset-section {
846
+ margin-bottom: 16px;
847
+ padding: 10px;
848
+ background: var(--bg-tertiary, #111827);
849
+ border-radius: 6px;
850
+ border: 1px solid var(--border-color, #374151);
851
+ }
852
+
853
+ .preset-section h4 {
854
+ margin: 0 0 8px 0;
855
+ font-size: 13px;
856
+ font-weight: 600;
857
+ }
858
+
859
+ .preset-list {
860
+ display: flex;
861
+ flex-wrap: wrap;
862
+ gap: 6px;
863
+ margin-bottom: 8px;
864
+ }
865
+
866
+ .preset-btn {
867
+ flex: 0 1 auto;
868
+ padding: 4px 8px;
869
+ background: var(--bg-secondary, #1f2937);
870
+ border: 1px solid var(--border-color, #374151);
871
+ color: var(--text-primary, #f3f4f6);
872
+ border-radius: 3px;
873
+ font-size: 11px;
874
+ cursor: pointer;
875
+ white-space: nowrap;
876
+ overflow: hidden;
877
+ text-overflow: ellipsis;
878
+ }
879
+
880
+ .preset-btn:hover {
881
+ background: var(--accent-color, #0284c7);
882
+ border-color: var(--accent-color, #0284c7);
883
+ }
884
+
885
+ .preset-actions {
886
+ display: flex;
887
+ gap: 6px;
888
+ }
889
+
890
+ #preset-name {
891
+ flex: 1;
892
+ padding: 4px 6px;
893
+ background: var(--bg-secondary, #1f2937);
894
+ border: 1px solid var(--border-color, #374151);
895
+ border-radius: 3px;
896
+ color: var(--text-primary, #f3f4f6);
897
+ font-size: 12px;
898
+ }
899
+
900
+ #preset-name:focus {
901
+ outline: none;
902
+ border-color: var(--accent-color, #0284c7);
903
+ }
904
+
905
+ .history-section {
906
+ padding: 10px;
907
+ background: var(--bg-tertiary, #111827);
908
+ border-radius: 6px;
909
+ border: 1px solid var(--border-color, #374151);
910
+ }
911
+
912
+ .history-section h4 {
913
+ margin: 0 0 8px 0;
914
+ font-size: 13px;
915
+ font-weight: 600;
916
+ }
917
+
918
+ .history-timeline {
919
+ max-height: 120px;
920
+ overflow-y: auto;
921
+ margin-bottom: 8px;
922
+ font-size: 11px;
923
+ }
924
+
925
+ .history-item {
926
+ padding: 4px;
927
+ margin-bottom: 2px;
928
+ background: var(--bg-secondary, #1f2937);
929
+ border-left: 3px solid var(--accent-color, #0284c7);
930
+ border-radius: 2px;
931
+ overflow: hidden;
932
+ text-overflow: ellipsis;
933
+ white-space: nowrap;
934
+ }
935
+
936
+ .btn-primary, .btn-secondary {
937
+ padding: 6px 12px;
938
+ border: 1px solid var(--border-color, #374151);
939
+ border-radius: 4px;
940
+ font-size: 12px;
941
+ cursor: pointer;
942
+ font-weight: 500;
943
+ }
944
+
945
+ .btn-primary {
946
+ background: var(--accent-color, #0284c7);
947
+ color: white;
948
+ border-color: var(--accent-color, #0284c7);
949
+ }
950
+
951
+ .btn-primary:hover {
952
+ opacity: 0.9;
953
+ }
954
+
955
+ .btn-secondary {
956
+ background: var(--bg-secondary, #1f2937);
957
+ color: var(--text-primary, #f3f4f6);
958
+ width: 100%;
959
+ margin-top: 4px;
960
+ }
961
+
962
+ .btn-secondary:hover {
963
+ background: var(--bg-secondary, #1f2937);
964
+ opacity: 0.8;
965
+ }
966
+
967
+ .dimension-label {
968
+ pointer-events: none;
969
+ }
970
+ </style>
971
+ `;
972
+ }
973
+
974
+ // ============================================================================
975
+ // EVENT HANDLERS
976
+ // ============================================================================
977
+
978
+ /**
979
+ * Attach event listeners to slider UI
980
+ */
981
+ function attachEventListeners() {
982
+ const panel = document.getElementById('parametric-panel');
983
+ if (!panel) {
984
+ return;
985
+ }
986
+
987
+ // Slider inputs
988
+ panel.querySelectorAll('.param-slider').forEach(slider => {
989
+ slider.addEventListener('input', (e) => {
990
+ const paramName = e.target.dataset.param;
991
+ const value = parseFloat(e.target.value);
992
+ updateParameter(paramName, value);
993
+
994
+ // Update corresponding number input
995
+ const input = panel.querySelector(`.param-input[data-param="${paramName}"]`);
996
+ if (input) {
997
+ input.value = value.toFixed(parameterDefinitions[paramName].step > 1 ? 0 : 2);
998
+ }
999
+ });
1000
+ });
1001
+
1002
+ // Number inputs
1003
+ panel.querySelectorAll('.param-input').forEach(input => {
1004
+ input.addEventListener('change', (e) => {
1005
+ const paramName = e.target.dataset.param;
1006
+ const value = parseFloat(e.target.value);
1007
+ if (!isNaN(value)) {
1008
+ updateParameter(paramName, value);
1009
+
1010
+ // Update corresponding slider
1011
+ const slider = panel.querySelector(`.param-slider[data-param="${paramName}"]`);
1012
+ if (slider) {
1013
+ slider.value = value;
1014
+ }
1015
+ }
1016
+ });
1017
+ });
1018
+
1019
+ // Lock buttons
1020
+ panel.querySelectorAll('.lock-btn').forEach(btn => {
1021
+ btn.addEventListener('click', (e) => {
1022
+ const paramName = e.target.dataset.param;
1023
+ const def = parameterDefinitions[paramName];
1024
+ def.locked = !def.locked;
1025
+ e.target.textContent = def.locked ? '🔒' : '🔓';
1026
+ e.target.classList.toggle('locked', def.locked);
1027
+ });
1028
+ });
1029
+
1030
+ // Reset buttons
1031
+ panel.querySelectorAll('.reset-btn').forEach(btn => {
1032
+ btn.addEventListener('click', (e) => {
1033
+ const paramName = e.target.dataset.param;
1034
+ const def = parameterDefinitions[paramName];
1035
+ updateParameter(paramName, def.original, true);
1036
+
1037
+ // Update UI
1038
+ const slider = panel.querySelector(`.param-slider[data-param="${paramName}"]`);
1039
+ const input = panel.querySelector(`.param-input[data-param="${paramName}"]`);
1040
+ if (slider) slider.value = def.original;
1041
+ if (input) input.value = def.original.toFixed(2);
1042
+ });
1043
+ });
1044
+
1045
+ // Preset buttons
1046
+ panel.querySelectorAll('.preset-btn').forEach(btn => {
1047
+ btn.addEventListener('click', (e) => {
1048
+ const presetName = e.target.dataset.preset;
1049
+ loadPreset(presetName);
1050
+ });
1051
+ });
1052
+
1053
+ // Save preset
1054
+ const saveBtn = document.getElementById('save-preset-btn');
1055
+ const presetInput = document.getElementById('preset-name');
1056
+ if (saveBtn && presetInput) {
1057
+ saveBtn.addEventListener('click', () => {
1058
+ const name = presetInput.value.trim();
1059
+ if (name) {
1060
+ savePreset(name);
1061
+ presetInput.value = '';
1062
+
1063
+ // Add button to preset list
1064
+ const btn = document.createElement('button');
1065
+ btn.className = 'preset-btn';
1066
+ btn.dataset.preset = name;
1067
+ btn.textContent = name;
1068
+ btn.addEventListener('click', () => loadPreset(name));
1069
+ panel.querySelector('#preset-list').appendChild(btn);
1070
+ }
1071
+ });
1072
+ }
1073
+
1074
+ // Annotations toggle
1075
+ const annotBtn = document.getElementById('toggle-annotations');
1076
+ if (annotBtn) {
1077
+ annotBtn.addEventListener('click', () => {
1078
+ if (Object.keys(annotations).length) {
1079
+ clearAnnotations();
1080
+ } else if (activeMesh) {
1081
+ addDimensionAnnotations(activeMesh);
1082
+ }
1083
+ });
1084
+ }
1085
+
1086
+ // Export history
1087
+ const exportBtn = document.getElementById('export-history-btn');
1088
+ if (exportBtn) {
1089
+ exportBtn.addEventListener('click', () => {
1090
+ const json = JSON.stringify(parameterHistory, null, 2);
1091
+ const blob = new Blob([json], { type: 'application/json' });
1092
+ const url = URL.createObjectURL(blob);
1093
+ const a = document.createElement('a');
1094
+ a.href = url;
1095
+ a.download = `parameter-history-${Date.now()}.json`;
1096
+ a.click();
1097
+ URL.revokeObjectURL(url);
1098
+ });
1099
+ }
1100
+ }
1101
+
1102
+ /**
1103
+ * Update history timeline display
1104
+ */
1105
+ function updateHistoryDisplay() {
1106
+ const timeline = document.getElementById('history-timeline');
1107
+ const count = document.getElementById('history-count');
1108
+
1109
+ if (!timeline || !count) {
1110
+ return;
1111
+ }
1112
+
1113
+ count.textContent = parameterHistory.length;
1114
+ timeline.innerHTML = '';
1115
+
1116
+ const recent = parameterHistory.slice(-10).reverse();
1117
+ for (const entry of recent) {
1118
+ const item = document.createElement('div');
1119
+ item.className = 'history-item';
1120
+ item.title = `${entry.parameter}: ${entry.oldValue.toFixed(2)} → ${entry.newValue.toFixed(2)}`;
1121
+ item.textContent = `${entry.parameter} = ${entry.newValue.toFixed(2)}`;
1122
+ timeline.appendChild(item);
1123
+ }
1124
+ }
1125
+
1126
+ // ============================================================================
1127
+ // PUBLIC API
1128
+ // ============================================================================
1129
+
1130
+ /**
1131
+ * Initialize module
1132
+ * @param {THREE.Scene} _scene
1133
+ * @param {THREE.WebGLRenderer} _renderer
1134
+ */
1135
+ function init(_scene, _renderer) {
1136
+ scene = _scene;
1137
+ renderer = _renderer;
1138
+ createStandardPresets();
1139
+ }
1140
+
1141
+ /**
1142
+ * Get UI HTML
1143
+ * @returns {string}
1144
+ */
1145
+ function getUI() {
1146
+ return generateSliderCSS() + generateSlidersHTML();
1147
+ }
1148
+
1149
+ /**
1150
+ * Attach to mesh and create sliders
1151
+ * @param {THREE.Mesh} mesh
1152
+ */
1153
+ function attachToMesh(mesh) {
1154
+ if (!mesh || !mesh.geometry) {
1155
+ console.warn('ParametricSliders: Invalid mesh');
1156
+ return;
1157
+ }
1158
+
1159
+ activeMesh = mesh;
1160
+ originalGeometry = analyzeGeometry(mesh.geometry);
1161
+ currentParameters = { ...originalGeometry.parameters };
1162
+ parameterDefinitions = createParameterDefinitions(originalGeometry.parameters);
1163
+
1164
+ // Render UI
1165
+ const container = document.getElementById('parametric-panel');
1166
+ if (!container) {
1167
+ const html = getUI();
1168
+ const div = document.createElement('div');
1169
+ div.innerHTML = html;
1170
+ document.body.appendChild(div);
1171
+ }
1172
+
1173
+ attachEventListeners();
1174
+ }
1175
+
1176
+ /**
1177
+ * Detach from mesh
1178
+ */
1179
+ function detach() {
1180
+ clearAnnotations();
1181
+ activeMesh = null;
1182
+ originalGeometry = null;
1183
+ currentParameters = {};
1184
+ parameterDefinitions = {};
1185
+ }
1186
+
1187
+ /**
1188
+ * Execute action
1189
+ * @param {string} action
1190
+ * @param {Object} params
1191
+ */
1192
+ function execute(action, params = {}) {
1193
+ switch (action) {
1194
+ case 'update_parameter':
1195
+ updateParameter(params.name, params.value);
1196
+ break;
1197
+
1198
+ case 'set_parameter':
1199
+ if (parameterDefinitions[params.name]) {
1200
+ parameterDefinitions[params.name].value = params.value;
1201
+ currentParameters[params.name] = params.value;
1202
+ rebuildMeshGeometry();
1203
+ updateDimensionLabels();
1204
+ }
1205
+ break;
1206
+
1207
+ case 'save_preset':
1208
+ savePreset(params.name);
1209
+ break;
1210
+
1211
+ case 'load_preset':
1212
+ loadPreset(params.name);
1213
+ break;
1214
+
1215
+ case 'toggle_annotations':
1216
+ if (Object.keys(annotations).length) {
1217
+ clearAnnotations();
1218
+ } else if (activeMesh) {
1219
+ addDimensionAnnotations(activeMesh);
1220
+ }
1221
+ break;
1222
+
1223
+ case 'link_parameters':
1224
+ linkedParams[params.main] = params.linked || [];
1225
+ break;
1226
+
1227
+ case 'set_constraint':
1228
+ if (parameterDefinitions[params.name]) {
1229
+ constraints[params.name] = params.constraint;
1230
+ const def = parameterDefinitions[params.name];
1231
+ if (params.constraint.min !== undefined) def.min = params.constraint.min;
1232
+ if (params.constraint.max !== undefined) def.max = params.constraint.max;
1233
+ if (params.constraint.step !== undefined) def.step = params.constraint.step;
1234
+ }
1235
+ break;
1236
+
1237
+ case 'undo':
1238
+ if (parameterHistory.length > 0) {
1239
+ const entry = parameterHistory[parameterHistory.length - 1];
1240
+ updateParameter(entry.parameter, entry.oldValue, false);
1241
+ parameterHistory.pop();
1242
+ }
1243
+ break;
1244
+
1245
+ case 'reset_all':
1246
+ for (const [name, def] of Object.entries(parameterDefinitions)) {
1247
+ updateParameter(name, def.original, true);
1248
+ }
1249
+ break;
1250
+
1251
+ default:
1252
+ console.warn(`ParametricSliders: Unknown action "${action}"`);
1253
+ }
1254
+
1255
+ updateHistoryDisplay();
1256
+ }
1257
+
1258
+ /**
1259
+ * Get current parameters
1260
+ * @returns {Object}
1261
+ */
1262
+ function getParameters() {
1263
+ return JSON.parse(JSON.stringify(currentParameters));
1264
+ }
1265
+
1266
+ /**
1267
+ * Set parameter value
1268
+ * @param {string} name
1269
+ * @param {number} value
1270
+ */
1271
+ function setParameter(name, value) {
1272
+ updateParameter(name, value, true);
1273
+ }
1274
+
1275
+ /**
1276
+ * Export config as JSON
1277
+ * @returns {Object}
1278
+ */
1279
+ function exportConfig() {
1280
+ return {
1281
+ type: originalGeometry.type,
1282
+ parameters: currentParameters,
1283
+ presets,
1284
+ history: parameterHistory,
1285
+ timestamp: Date.now()
1286
+ };
1287
+ }
1288
+
1289
+ /**
1290
+ * Export as OpenSCAD variables
1291
+ * @returns {string}
1292
+ */
1293
+ function exportOpenSCAD() {
1294
+ let scad = '// cycleCAD → OpenSCAD parametric variables\n\n';
1295
+ for (const [name, value] of Object.entries(currentParameters)) {
1296
+ const def = parameterDefinitions[name];
1297
+ scad += `${name} = ${value}; // ${def.category}\n`;
1298
+ }
1299
+ return scad;
1300
+ }
1301
+
1302
+ // ============================================================================
1303
+ // MODULE EXPORT
1304
+ // ============================================================================
1305
+
1306
+ window.CycleCAD = window.CycleCAD || {};
1307
+ window.CycleCAD.ParametricSliders = {
1308
+ init,
1309
+ getUI,
1310
+ execute,
1311
+ attachToMesh,
1312
+ detach,
1313
+ getParameters,
1314
+ setParameter,
1315
+ savePreset,
1316
+ loadPreset,
1317
+ exportConfig,
1318
+ exportOpenSCAD
1319
+ };
1320
+
1321
+ console.log('✓ ParametricSliders module loaded');
1322
+ })();