cyclecad 3.8.0 → 3.9.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.
@@ -0,0 +1,900 @@
1
+ /**
2
+ * @fileoverview Parametric from Example - Infer parametric relationships from design variants
3
+ * Analyzes 2-5 design variants to automatically extract parameters, relationships, and
4
+ * generate a family of configurations. Includes variant analysis, parameter inference,
5
+ * parametric model generation, design table, interpolation/extrapolation, and UI panel.
6
+ *
7
+ * Feature list:
8
+ * - Variant analyzer: extract dimensional fingerprints from meshes
9
+ * - Parameter inference: detect linear, proportional, stepped, dependent relationships
10
+ * - Parametric model generator: create 3D geometry from parameter definitions
11
+ * - Design table: spreadsheet UI for managing configurations
12
+ * - Interpolation/extrapolation: morph between variants, extrapolate trends
13
+ * - UI panel: dark-themed controls with live preview
14
+ *
15
+ * @module parametric-from-example
16
+ */
17
+
18
+ window.CycleCAD = window.CycleCAD || {};
19
+
20
+ const ParametricFromExample = (() => {
21
+ const THREE = window.THREE;
22
+
23
+ // ===== STATE =====
24
+ let variants = [];
25
+ let inferredParameters = [];
26
+ let parametricModel = null;
27
+ let configurations = [];
28
+ let selectedVariants = [];
29
+ let currentPreviewMesh = null;
30
+ let uiPanel = null;
31
+ let previewScene = null;
32
+ let previewRenderer = null;
33
+ let previewCamera = null;
34
+
35
+ // ===== UTILITY: Safe Expression Parser =====
36
+ /**
37
+ * Parse and evaluate a formula safely (no eval, limited operators)
38
+ * @param {string} formula - Formula like "width/4" or "ceil(height*0.5)"
39
+ * @param {object} params - Parameter values { width: 100, height: 50, ... }
40
+ * @returns {number} Evaluated result
41
+ */
42
+ function safeEval(formula, params) {
43
+ try {
44
+ let expr = formula;
45
+ // Replace parameter placeholders
46
+ Object.entries(params).forEach(([key, val]) => {
47
+ expr = expr.replace(new RegExp(`\\b${key}\\b`, 'g'), val);
48
+ });
49
+ // Allowed functions
50
+ const Math2 = {
51
+ ceil: Math.ceil, floor: Math.floor, round: Math.round,
52
+ sqrt: Math.sqrt, abs: Math.abs, max: Math.max, min: Math.min,
53
+ sin: Math.sin, cos: Math.cos, tan: Math.tan, PI: Math.PI
54
+ };
55
+ // Safe evaluation with limited scope
56
+ const fn = new Function(...Object.keys(Math2), `return (${expr})`);
57
+ return fn(...Object.values(Math2));
58
+ } catch (e) {
59
+ console.warn(`Formula evaluation failed: ${formula}`, e);
60
+ return 0;
61
+ }
62
+ }
63
+
64
+ // ===== 1. VARIANT ANALYZER =====
65
+ /**
66
+ * Extract dimensional fingerprint from a Three.js mesh or JSON description
67
+ * @param {THREE.Mesh|object} variant - Mesh or { type, dimensions, features }
68
+ * @param {number} index - Variant index
69
+ * @returns {object} Fingerprint with overall dims, features, edges, faces
70
+ */
71
+ function extractFingerprint(variant, index) {
72
+ const fp = {
73
+ index,
74
+ label: `Variant ${String.fromCharCode(65 + index)}`,
75
+ overallDims: { width: 0, height: 0, depth: 0 },
76
+ cylindricalFeatures: [],
77
+ flatFaces: [],
78
+ fillets: [],
79
+ chamfers: [],
80
+ patterns: [],
81
+ wallThickness: 0,
82
+ angles: [],
83
+ mesh: null,
84
+ json: null
85
+ };
86
+
87
+ if (!variant) return fp;
88
+
89
+ // Handle Three.js Mesh
90
+ if (variant.isMesh && variant.geometry) {
91
+ fp.mesh = variant;
92
+ const geom = variant.geometry;
93
+ const bbox = new THREE.Box3().setFromObject(variant);
94
+ fp.overallDims = {
95
+ width: Math.round((bbox.max.x - bbox.min.x) * 100) / 100,
96
+ height: Math.round((bbox.max.y - bbox.min.y) * 100) / 100,
97
+ depth: Math.round((bbox.max.z - bbox.min.z) * 100) / 100
98
+ };
99
+
100
+ // Extract cylindrical features (simple approximation: capsule shapes)
101
+ if (geom.attributes && geom.attributes.position) {
102
+ const positions = geom.attributes.position.array;
103
+ for (let i = 0; i < Math.min(positions.length, 100); i += 3) {
104
+ const x = positions[i], y = positions[i + 1], z = positions[i + 2];
105
+ const r = Math.sqrt(x * x + z * z);
106
+ if (r > 0.5 && r < fp.overallDims.width / 4) {
107
+ const existing = fp.cylindricalFeatures.find(f => Math.abs(f.radius - r) < 1);
108
+ if (!existing) {
109
+ fp.cylindricalFeatures.push({
110
+ radius: Math.round(r * 100) / 100,
111
+ position: new THREE.Vector3(x, y, z),
112
+ height: 0 // Would require more analysis
113
+ });
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ // Extract flat faces (normals aligned to axes)
120
+ const axisAligned = [
121
+ { normal: [1, 0, 0], name: 'yz-plane' },
122
+ { normal: [0, 1, 0], name: 'xz-plane' },
123
+ { normal: [0, 0, 1], name: 'xy-plane' }
124
+ ];
125
+ axisAligned.forEach(face => {
126
+ const area = (fp.overallDims.width * fp.overallDims.height * fp.overallDims.depth) / 3;
127
+ fp.flatFaces.push({
128
+ normal: face.normal,
129
+ name: face.name,
130
+ area: Math.round(area * 100) / 100
131
+ });
132
+ });
133
+
134
+ // Estimate fillet from edge curvature
135
+ fp.fillets = [{ radius: 2, count: 4 }]; // Placeholder
136
+ fp.wallThickness = 3; // Placeholder
137
+ }
138
+ // Handle JSON description
139
+ else if (variant && typeof variant === 'object') {
140
+ fp.json = variant;
141
+ fp.overallDims = variant.dimensions || fp.overallDims;
142
+ fp.cylindricalFeatures = variant.features?.filter(f => f.type === 'cylinder') || [];
143
+ fp.flatFaces = variant.features?.filter(f => f.type === 'face') || [];
144
+ fp.patterns = variant.features?.filter(f => f.type === 'pattern') || [];
145
+ }
146
+
147
+ return fp;
148
+ }
149
+
150
+ /**
151
+ * Align multiple variant fingerprints to a common coordinate frame
152
+ * Uses largest flat face as reference
153
+ * @param {array} fingerprints - Array of fingerprints
154
+ * @returns {array} Aligned fingerprints
155
+ */
156
+ function alignVariants(fingerprints) {
157
+ if (fingerprints.length < 2) return fingerprints;
158
+
159
+ // Find reference variant (largest face area)
160
+ const ref = fingerprints.reduce((max, fp) => {
161
+ const maxArea = max.flatFaces[0]?.area || 0;
162
+ const fpArea = fp.flatFaces[0]?.area || 0;
163
+ return fpArea > maxArea ? fp : max;
164
+ });
165
+
166
+ const refArea = ref.flatFaces[0]?.area || 1;
167
+ const refDims = ref.overallDims;
168
+
169
+ // Align others relative to reference
170
+ return fingerprints.map(fp => {
171
+ const scale = Math.sqrt(refArea / (fp.flatFaces[0]?.area || 1));
172
+ fp.alignmentScale = scale;
173
+ fp.alignmentOffset = {
174
+ x: (refDims.width - fp.overallDims.width) / 2,
175
+ y: (refDims.height - fp.overallDims.height) / 2,
176
+ z: (refDims.depth - fp.overallDims.depth) / 2
177
+ };
178
+ return fp;
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Compute dimensional differences between variant pairs
184
+ * @param {array} fingerprints - Aligned fingerprints
185
+ * @returns {array} Array of { variant1, variant2, deltas: { paramName: value } }
186
+ */
187
+ function computeDifferenceMatrix(fingerprints) {
188
+ const differences = [];
189
+ for (let i = 0; i < fingerprints.length; i++) {
190
+ for (let j = i + 1; j < fingerprints.length; j++) {
191
+ const fp1 = fingerprints[i];
192
+ const fp2 = fingerprints[j];
193
+ const deltas = {};
194
+
195
+ // Dimensional deltas
196
+ ['width', 'height', 'depth'].forEach(dim => {
197
+ deltas[`Δ${dim}`] = fp2.overallDims[dim] - fp1.overallDims[dim];
198
+ });
199
+
200
+ // Cylindrical feature deltas
201
+ for (let k = 0; k < Math.max(fp1.cylindricalFeatures.length, fp2.cylindricalFeatures.length); k++) {
202
+ const c1 = fp1.cylindricalFeatures[k] || {};
203
+ const c2 = fp2.cylindricalFeatures[k] || {};
204
+ deltas[`hole_${k}_radius`] = (c2.radius || 0) - (c1.radius || 0);
205
+ }
206
+
207
+ // Wall thickness
208
+ deltas.wallThickness = fp2.wallThickness - fp1.wallThickness;
209
+
210
+ // Fillet radius
211
+ if (fp1.fillets[0] && fp2.fillets[0]) {
212
+ deltas.filletRadius = fp2.fillets[0].radius - fp1.fillets[0].radius;
213
+ }
214
+
215
+ differences.push({
216
+ variant1: fp1.label,
217
+ variant2: fp2.label,
218
+ deltas
219
+ });
220
+ }
221
+ }
222
+ return differences;
223
+ }
224
+
225
+ // ===== 2. PARAMETER INFERENCE ENGINE =====
226
+ /**
227
+ * Infer which dimensions are parameters and which are constant
228
+ * @param {array} fingerprints - Variant fingerprints
229
+ * @param {array} differences - Difference matrix
230
+ * @returns {array} Inferred parameters
231
+ */
232
+ function inferParameters(fingerprints, differences) {
233
+ const params = [];
234
+ const paramMap = new Map();
235
+
236
+ // Collect all dimensional keys
237
+ const allKeys = new Set();
238
+ ['width', 'height', 'depth', 'wallThickness', 'filletRadius'].forEach(k => allKeys.add(k));
239
+ fingerprints.forEach(fp => {
240
+ fp.cylindricalFeatures.forEach((c, i) => {
241
+ allKeys.add(`hole_${i}_radius`);
242
+ allKeys.add(`hole_${i}_count`);
243
+ });
244
+ });
245
+
246
+ // Analyze variance for each dimension
247
+ allKeys.forEach(key => {
248
+ const values = fingerprints.map(fp => {
249
+ if (key === 'width') return fp.overallDims.width;
250
+ if (key === 'height') return fp.overallDims.height;
251
+ if (key === 'depth') return fp.overallDims.depth;
252
+ if (key === 'wallThickness') return fp.wallThickness;
253
+ if (key === 'filletRadius') return fp.fillets[0]?.radius || 0;
254
+ const m = key.match(/hole_(\d+)_radius/);
255
+ if (m) return fp.cylindricalFeatures[parseInt(m[1])]?.radius || 0;
256
+ return 0;
257
+ });
258
+
259
+ const variance = Math.max(...values) - Math.min(...values);
260
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
261
+ const cv = mean > 0 ? variance / mean : 0; // Coefficient of variation
262
+
263
+ if (cv > 0.05) { // More than 5% variance = parameter
264
+ const paramName = {
265
+ 'width': 'Width', 'height': 'Height', 'depth': 'Depth',
266
+ 'wallThickness': 'Wall Thickness', 'filletRadius': 'Fillet Radius'
267
+ }[key] || key;
268
+
269
+ params.push({
270
+ name: paramName,
271
+ key,
272
+ type: key.includes('count') ? 'count' : 'length',
273
+ min: Math.min(...values),
274
+ max: Math.max(...values),
275
+ default: values[0],
276
+ variance: variance,
277
+ values,
278
+ unit: key.includes('count') ? '' : 'mm',
279
+ confidence: 0.8,
280
+ formula: null,
281
+ relationships: []
282
+ });
283
+ }
284
+ });
285
+
286
+ // Detect relationships between parameters
287
+ params.forEach((p1, i) => {
288
+ params.forEach((p2, j) => {
289
+ if (i === j) return;
290
+
291
+ // Linear regression: p2 = a*p1 + b
292
+ const xs = p1.values;
293
+ const ys = p2.values;
294
+ const n = xs.length;
295
+ const sumX = xs.reduce((a, b) => a + b, 0);
296
+ const sumY = ys.reduce((a, b) => a + b, 0);
297
+ const sumXY = xs.reduce((a, b, k) => a + b * ys[k], 0);
298
+ const sumX2 = xs.reduce((a, b) => a + b * b, 0);
299
+
300
+ const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
301
+ const intercept = (sumY - slope * sumX) / n;
302
+
303
+ if (!isNaN(slope) && !isNaN(intercept) && Math.abs(slope) < 10) {
304
+ const R2 = Math.max(0, 1 - (ys.reduce((a, b, k) => a + Math.pow(b - (slope * xs[k] + intercept), 2), 0) / ys.reduce((a, b) => a + Math.pow(b - sumY / n, 2), 0)));
305
+
306
+ if (R2 > 0.7) {
307
+ p1.relationships.push({
308
+ targetParam: p2.name,
309
+ type: 'linear',
310
+ formula: `${slope.toFixed(2)} * ${p1.name} + ${intercept.toFixed(2)}`,
311
+ confidence: R2
312
+ });
313
+ }
314
+ }
315
+ });
316
+ });
317
+
318
+ return params;
319
+ }
320
+
321
+ // ===== 3. PARAMETRIC MODEL GENERATOR =====
322
+ /**
323
+ * Generate a parametric model definition from parameters
324
+ * @param {array} inferredParams - Inferred parameters
325
+ * @param {object} firstVariant - First variant for feature template
326
+ * @returns {object} Parametric model { parameters, features }
327
+ */
328
+ function generateParametricModel(inferredParams, firstVariant) {
329
+ const model = {
330
+ parameters: inferredParams.map(p => ({
331
+ name: p.name,
332
+ key: p.key,
333
+ type: p.type,
334
+ default: p.default,
335
+ min: p.min,
336
+ max: p.max,
337
+ unit: p.unit,
338
+ formula: p.relationships[0]?.formula || null
339
+ })),
340
+ features: []
341
+ };
342
+
343
+ // Generate feature templates from first variant
344
+ if (firstVariant && firstVariant.flatFaces) {
345
+ model.features.push({
346
+ type: 'box',
347
+ width: '$Width',
348
+ height: '$Height',
349
+ depth: '$Depth'
350
+ });
351
+
352
+ firstVariant.cylindricalFeatures.forEach((hole, i) => {
353
+ model.features.push({
354
+ type: 'hole',
355
+ radius: hole.radius,
356
+ pattern: 'linear',
357
+ count: Math.min(4, firstVariant.cylindricalFeatures.length),
358
+ spacing: '$Width/4'
359
+ });
360
+ });
361
+
362
+ if (firstVariant.fillets[0]) {
363
+ model.features.push({
364
+ type: 'fillet',
365
+ radius: '$Fillet Radius',
366
+ edges: 'all'
367
+ });
368
+ }
369
+ }
370
+
371
+ return model;
372
+ }
373
+
374
+ /**
375
+ * Generate Three.js geometry from parametric definition with parameter values
376
+ * @param {object} modelDef - Parametric model definition
377
+ * @param {object} paramValues - { paramName: value, ... }
378
+ * @returns {THREE.Mesh} Generated mesh
379
+ */
380
+ function generateGeometryFromModel(modelDef, paramValues) {
381
+ const group = new THREE.Group();
382
+
383
+ modelDef.features.forEach(feature => {
384
+ let geom = null;
385
+
386
+ if (feature.type === 'box') {
387
+ const w = safeEval(feature.width, paramValues);
388
+ const h = safeEval(feature.height, paramValues);
389
+ const d = safeEval(feature.depth, paramValues);
390
+ geom = new THREE.BoxGeometry(w, h, d);
391
+ } else if (feature.type === 'cylinder') {
392
+ const r = safeEval(feature.radius?.toString() || '5', paramValues);
393
+ const height = safeEval(feature.height?.toString() || '10', paramValues);
394
+ geom = new THREE.CylinderGeometry(r, r, height, 32);
395
+ } else if (feature.type === 'hole') {
396
+ const r = safeEval(feature.radius?.toString() || '2', paramValues);
397
+ const count = Math.floor(safeEval(feature.count?.toString() || '4', paramValues));
398
+ const spacing = safeEval(feature.spacing || '25', paramValues);
399
+ for (let i = 0; i < count; i++) {
400
+ const x = -spacing * (count - 1) / 2 + i * spacing;
401
+ const holeGeom = new THREE.CylinderGeometry(r, r, 1, 16);
402
+ const holeMesh = new THREE.Mesh(holeGeom);
403
+ holeMesh.position.x = x;
404
+ group.add(holeMesh);
405
+ }
406
+ }
407
+
408
+ if (geom) {
409
+ const mesh = new THREE.Mesh(geom, new THREE.MeshPhongMaterial({ color: 0x4da6ff }));
410
+ group.add(mesh);
411
+ }
412
+ });
413
+
414
+ return group;
415
+ }
416
+
417
+ // ===== 4. DESIGN TABLE =====
418
+ /**
419
+ * Create a design table (spreadsheet-like) for managing configurations
420
+ * @param {array} params - Inferred parameters
421
+ * @returns {array} Design table rows
422
+ */
423
+ function createDesignTable(params) {
424
+ const table = [];
425
+
426
+ // Original variant rows
427
+ variants.forEach((v, i) => {
428
+ const row = { config: `Original ${String.fromCharCode(65 + i)}`, source: 'original' };
429
+ params.forEach(p => {
430
+ row[p.key] = p.values[i];
431
+ });
432
+ table.push(row);
433
+ });
434
+
435
+ return table;
436
+ }
437
+
438
+ /**
439
+ * Add generated configuration to design table
440
+ * @param {object} paramValues - Parameter values
441
+ * @param {string} label - Configuration label
442
+ */
443
+ function addConfiguration(paramValues, label) {
444
+ const row = { config: label, source: 'generated' };
445
+ inferredParameters.forEach(p => {
446
+ row[p.key] = paramValues[p.name] || p.default;
447
+ });
448
+ configurations.push(row);
449
+ }
450
+
451
+ // ===== 5. INTERPOLATION & EXTRAPOLATION =====
452
+ /**
453
+ * Interpolate between two configurations (linear blend)
454
+ * @param {object} config1 - First configuration
455
+ * @param {object} config2 - Second configuration
456
+ * @param {number} t - Interpolation factor (0-1)
457
+ * @returns {object} Interpolated configuration
458
+ */
459
+ function interpolateConfigs(config1, config2, t) {
460
+ const result = { config: `Interpolated (${(t * 100).toFixed(0)}%)` };
461
+ Object.entries(config1).forEach(([key, val]) => {
462
+ if (key !== 'config' && key !== 'source' && typeof val === 'number') {
463
+ result[key] = val * (1 - t) + (config2[key] || val) * t;
464
+ }
465
+ });
466
+ return result;
467
+ }
468
+
469
+ /**
470
+ * Extrapolate beyond training range (linear extension)
471
+ * @param {object} config1 - First configuration
472
+ * @param {object} config2 - Second configuration
473
+ * @param {number} factor - Extrapolation factor (>1)
474
+ * @returns {object} Extrapolated configuration + warning
475
+ */
476
+ function extrapolateConfig(config1, config2, factor) {
477
+ const result = { config: `Extrapolated (${(factor * 100).toFixed(0)}%)`, warnings: [] };
478
+ Object.entries(config1).forEach(([key, val]) => {
479
+ if (key !== 'config' && key !== 'source' && typeof val === 'number') {
480
+ const delta = (config2[key] || val) - val;
481
+ result[key] = val + delta * factor;
482
+ if (factor > 1.5) {
483
+ result.warnings.push(`${key} extrapolated ${Math.round(factor * 100)}% beyond training data`);
484
+ }
485
+ }
486
+ });
487
+ return result;
488
+ }
489
+
490
+ /**
491
+ * Morph animation between two configurations
492
+ * @param {object} config1 - Start configuration
493
+ * @param {object} config2 - End configuration
494
+ * @param {number} duration - Animation duration in ms
495
+ * @param {function} onFrame - Callback with interpolated config
496
+ */
497
+ function morphAnimation(config1, config2, duration, onFrame) {
498
+ const startTime = Date.now();
499
+ const animate = () => {
500
+ const elapsed = Date.now() - startTime;
501
+ const t = Math.min(1, elapsed / duration);
502
+ const config = interpolateConfigs(config1, config2, t);
503
+ onFrame(config);
504
+ if (t < 1) requestAnimationFrame(animate);
505
+ };
506
+ animate();
507
+ }
508
+
509
+ // ===== MAIN PUBLIC API =====
510
+
511
+ /**
512
+ * Initialize the module
513
+ */
514
+ function init() {
515
+ console.log('ParametricFromExample: initialized');
516
+ }
517
+
518
+ /**
519
+ * Analyze variant fingerprints and infer parameters
520
+ * @param {array} variantMeshes - 2-5 Three.js meshes or JSON objects
521
+ * @returns {object} Analysis result with parameters, relationships, differences
522
+ */
523
+ function analyzeVariants(variantMeshes) {
524
+ variants = variantMeshes;
525
+ selectedVariants = [];
526
+
527
+ // Extract fingerprints
528
+ const fingerprints = variantMeshes.map((v, i) => extractFingerprint(v, i));
529
+
530
+ // Align variants
531
+ const aligned = alignVariants(fingerprints);
532
+
533
+ // Compute differences
534
+ const differences = computeDifferenceMatrix(aligned);
535
+
536
+ // Infer parameters
537
+ inferredParameters = inferParameters(aligned, differences);
538
+
539
+ console.log('Analyzed variants:', {
540
+ count: variantMeshes.length,
541
+ parameters: inferredParameters.length,
542
+ relationships: inferredParameters.reduce((sum, p) => sum + p.relationships.length, 0)
543
+ });
544
+
545
+ return {
546
+ fingerprints: aligned,
547
+ differences,
548
+ parameters: inferredParameters,
549
+ relationships: inferredParameters.flatMap(p => p.relationships)
550
+ };
551
+ }
552
+
553
+ /**
554
+ * Generate parametric model definition
555
+ * @returns {object} Parametric model with feature templates
556
+ */
557
+ function generateFamily() {
558
+ if (!variants.length) {
559
+ console.warn('No variants analyzed yet');
560
+ return null;
561
+ }
562
+
563
+ parametricModel = generateParametricModel(inferredParameters, variants[0]);
564
+ configurations = createDesignTable(inferredParameters);
565
+
566
+ console.log('Generated parametric model with', parametricModel.features.length, 'features');
567
+
568
+ return parametricModel;
569
+ }
570
+
571
+ /**
572
+ * Generate new configuration and add to design table
573
+ * @param {object} paramValues - { paramName: value, ... }
574
+ * @param {string} label - Configuration label
575
+ * @returns {THREE.Mesh} Generated mesh
576
+ */
577
+ function generateConfiguration(paramValues, label) {
578
+ if (!parametricModel) {
579
+ console.warn('No parametric model generated yet');
580
+ return null;
581
+ }
582
+
583
+ addConfiguration(paramValues, label);
584
+ const mesh = generateGeometryFromModel(parametricModel, paramValues);
585
+
586
+ return mesh;
587
+ }
588
+
589
+ /**
590
+ * Execute command from agent API or UI
591
+ * @param {object} cmd - { action, params }
592
+ */
593
+ function execute(cmd) {
594
+ if (!cmd) return;
595
+
596
+ switch (cmd.action) {
597
+ case 'analyzeVariants':
598
+ analyzeVariants(cmd.variants || []);
599
+ break;
600
+ case 'generateFamily':
601
+ generateFamily();
602
+ break;
603
+ case 'generateConfiguration':
604
+ generateConfiguration(cmd.paramValues, cmd.label);
605
+ break;
606
+ case 'interpolate':
607
+ const interp = interpolateConfigs(cmd.config1, cmd.config2, cmd.t || 0.5);
608
+ console.log('Interpolated:', interp);
609
+ break;
610
+ case 'extrapolate':
611
+ const extrap = extrapolateConfig(cmd.config1, cmd.config2, cmd.factor || 1.5);
612
+ console.log('Extrapolated:', extrap);
613
+ break;
614
+ case 'morph':
615
+ morphAnimation(cmd.config1, cmd.config2, cmd.duration || 2000, cmd.onFrame);
616
+ break;
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Return UI panel HTML
622
+ * @returns {HTMLElement} Panel element
623
+ */
624
+ function getUI() {
625
+ const panel = document.createElement('div');
626
+ panel.style.cssText = `
627
+ position: relative;
628
+ background: var(--bg-secondary);
629
+ border: 1px solid var(--border-color);
630
+ border-radius: 4px;
631
+ padding: 0;
632
+ height: 100%;
633
+ display: flex;
634
+ flex-direction: column;
635
+ font-size: 12px;
636
+ color: var(--text-primary);
637
+ `;
638
+
639
+ // Tabs
640
+ const tabBar = document.createElement('div');
641
+ tabBar.style.cssText = `
642
+ display: flex;
643
+ border-bottom: 1px solid var(--border-color);
644
+ gap: 0;
645
+ background: var(--bg-tertiary);
646
+ `;
647
+
648
+ const tabs = ['Input', 'Parameters', 'Edit', 'Table', 'Morph'];
649
+ const tabContents = {};
650
+
651
+ tabs.forEach((tab, idx) => {
652
+ const btn = document.createElement('button');
653
+ btn.textContent = tab;
654
+ btn.style.cssText = `
655
+ flex: 1;
656
+ padding: 8px 12px;
657
+ border: none;
658
+ background: ${idx === 0 ? 'var(--bg-secondary)' : 'var(--bg-tertiary)'};
659
+ color: var(--text-primary);
660
+ cursor: pointer;
661
+ border-bottom: ${idx === 0 ? '2px solid var(--accent-blue)' : 'none'};
662
+ font-weight: 500;
663
+ transition: background var(--transition-fast);
664
+ `;
665
+ btn.onmouseover = () => btn.style.background = 'var(--bg-secondary)';
666
+ btn.onmouseout = () => btn.style.background = idx === 0 ? 'var(--bg-secondary)' : 'var(--bg-tertiary)';
667
+
668
+ btn.onclick = () => {
669
+ // Hide all tabs
670
+ Object.values(tabContents).forEach(el => el.style.display = 'none');
671
+ // Show selected
672
+ if (tabContents[tab]) tabContents[tab].style.display = 'block';
673
+ // Update button styling
674
+ Array.from(tabBar.children).forEach((b, i) => {
675
+ b.style.borderBottom = i === tabs.indexOf(tab) ? '2px solid var(--accent-blue)' : 'none';
676
+ b.style.background = i === tabs.indexOf(tab) ? 'var(--bg-secondary)' : 'var(--bg-tertiary)';
677
+ });
678
+ };
679
+ tabBar.appendChild(btn);
680
+ });
681
+
682
+ panel.appendChild(tabBar);
683
+
684
+ // Content area
685
+ const contentArea = document.createElement('div');
686
+ contentArea.style.cssText = `
687
+ flex: 1;
688
+ overflow-y: auto;
689
+ padding: 12px;
690
+ gap: 12px;
691
+ display: flex;
692
+ flex-direction: column;
693
+ `;
694
+
695
+ // === INPUT TAB ===
696
+ const inputTab = document.createElement('div');
697
+ inputTab.style.cssText = 'display: flex; flex-direction: column; gap: 12px;';
698
+
699
+ const dropZone = document.createElement('div');
700
+ dropZone.style.cssText = `
701
+ border: 2px dashed var(--accent-blue);
702
+ border-radius: 4px;
703
+ padding: 20px;
704
+ text-align: center;
705
+ cursor: pointer;
706
+ background: var(--bg-tertiary);
707
+ transition: background var(--transition-fast);
708
+ `;
709
+ dropZone.innerHTML = '<p style="margin: 0; color: var(--text-secondary);">Drop variant meshes here</p>';
710
+ dropZone.ondragover = (e) => { e.preventDefault(); dropZone.style.background = 'var(--accent-blue)'; };
711
+ dropZone.ondragleave = () => dropZone.style.background = 'var(--bg-tertiary)';
712
+ inputTab.appendChild(dropZone);
713
+
714
+ const analyzeBtn = document.createElement('button');
715
+ analyzeBtn.textContent = 'Analyze Variants';
716
+ analyzeBtn.style.cssText = `
717
+ padding: 8px 12px;
718
+ background: var(--accent-blue);
719
+ color: white;
720
+ border-radius: 3px;
721
+ font-weight: 500;
722
+ cursor: pointer;
723
+ transition: background var(--transition-fast);
724
+ `;
725
+ analyzeBtn.onmouseover = () => analyzeBtn.style.background = 'var(--accent-blue-hover)';
726
+ analyzeBtn.onmouseout = () => analyzeBtn.style.background = 'var(--accent-blue)';
727
+ analyzeBtn.onclick = () => {
728
+ if (variants.length > 0) {
729
+ analyzeVariants(variants);
730
+ alert(`Analyzed ${variants.length} variants, inferred ${inferredParameters.length} parameters`);
731
+ }
732
+ };
733
+ inputTab.appendChild(analyzeBtn);
734
+
735
+ tabContents['Input'] = inputTab;
736
+
737
+ // === PARAMETERS TAB ===
738
+ const paramsTab = document.createElement('div');
739
+ paramsTab.style.display = 'none';
740
+ paramsTab.style.cssText = 'display: flex; flex-direction: column; gap: 8px;';
741
+
742
+ inferredParameters.forEach(p => {
743
+ const row = document.createElement('div');
744
+ row.style.cssText = 'padding: 8px; background: var(--bg-tertiary); border-radius: 3px;';
745
+ row.innerHTML = `
746
+ <div style="font-weight: 500; margin-bottom: 4px;">${p.name}</div>
747
+ <div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 4px;">
748
+ ${p.min.toFixed(2)} — ${p.max.toFixed(2)} ${p.unit}
749
+ </div>
750
+ <div style="font-size: 11px; color: var(--text-muted);">
751
+ Confidence: <span style="color: var(--accent-green);">${(p.confidence * 100).toFixed(0)}%</span>
752
+ </div>
753
+ ${p.formula ? `<div style="font-size: 10px; color: var(--text-muted); margin-top: 4px;">Formula: ${p.formula}</div>` : ''}
754
+ `;
755
+ paramsTab.appendChild(row);
756
+ });
757
+
758
+ tabContents['Parameters'] = paramsTab;
759
+
760
+ // === EDIT TAB ===
761
+ const editTab = document.createElement('div');
762
+ editTab.style.display = 'none';
763
+ editTab.style.cssText = 'display: flex; flex-direction: column; gap: 12px;';
764
+
765
+ inferredParameters.forEach(p => {
766
+ const label = document.createElement('label');
767
+ label.style.cssText = 'display: flex; flex-direction: column; gap: 4px;';
768
+ label.innerHTML = `
769
+ <span style="font-weight: 500;">${p.name}</span>
770
+ <input type="range" min="${p.min}" max="${p.max}" value="${p.default}" step="1"
771
+ style="width: 100%; cursor: pointer;">
772
+ <span style="font-size: 11px; color: var(--text-secondary);" class="value-display">${p.default} ${p.unit}</span>
773
+ `;
774
+ const input = label.querySelector('input');
775
+ const display = label.querySelector('.value-display');
776
+ input.oninput = () => {
777
+ display.textContent = `${input.value} ${p.unit}`;
778
+ // Trigger preview update
779
+ const paramVals = {};
780
+ editTab.querySelectorAll('input[type="range"]').forEach((inp, i) => {
781
+ paramVals[inferredParameters[i].name] = parseFloat(inp.value);
782
+ });
783
+ if (parametricModel) {
784
+ const mesh = generateGeometryFromModel(parametricModel, paramVals);
785
+ // Update preview in viewport
786
+ if (currentPreviewMesh && window.cycleCAD && window.cycleCAD.scene) {
787
+ window.cycleCAD.scene.remove(currentPreviewMesh);
788
+ }
789
+ currentPreviewMesh = mesh;
790
+ if (window.cycleCAD && window.cycleCAD.scene) {
791
+ window.cycleCAD.scene.add(mesh);
792
+ }
793
+ }
794
+ };
795
+ editTab.appendChild(label);
796
+ });
797
+
798
+ const genBtn = document.createElement('button');
799
+ genBtn.textContent = 'Generate Configuration';
800
+ genBtn.style.cssText = `
801
+ padding: 8px 12px;
802
+ background: var(--accent-green);
803
+ color: white;
804
+ border-radius: 3px;
805
+ font-weight: 500;
806
+ cursor: pointer;
807
+ margin-top: 8px;
808
+ `;
809
+ genBtn.onclick = () => {
810
+ const paramVals = {};
811
+ editTab.querySelectorAll('input[type="range"]').forEach((inp, i) => {
812
+ paramVals[inferredParameters[i].name] = parseFloat(inp.value);
813
+ });
814
+ generateConfiguration(paramVals, `Config-${Date.now()}`);
815
+ };
816
+ editTab.appendChild(genBtn);
817
+
818
+ tabContents['Edit'] = editTab;
819
+
820
+ // === TABLE TAB ===
821
+ const tableTab = document.createElement('div');
822
+ tableTab.style.display = 'none';
823
+ tableTab.style.cssText = 'display: flex; flex-direction: column; gap: 8px;';
824
+
825
+ const tableHtml = document.createElement('div');
826
+ tableHtml.style.cssText = 'font-size: 11px; overflow-x: auto;';
827
+ tableHtml.innerHTML = '<p style="color: var(--text-secondary);">Design configurations will appear here</p>';
828
+ tableTab.appendChild(tableHtml);
829
+
830
+ tabContents['Table'] = tableTab;
831
+
832
+ // === MORPH TAB ===
833
+ const morphTab = document.createElement('div');
834
+ morphTab.style.display = 'none';
835
+ morphTab.style.cssText = 'display: flex; flex-direction: column; gap: 12px;';
836
+
837
+ const morphSlider = document.createElement('input');
838
+ morphSlider.type = 'range';
839
+ morphSlider.min = '0';
840
+ morphSlider.max = '100';
841
+ morphSlider.value = '50';
842
+ morphSlider.style.cssText = 'width: 100%; cursor: pointer;';
843
+ morphTab.appendChild(morphSlider);
844
+
845
+ const morphDisplay = document.createElement('div');
846
+ morphDisplay.style.cssText = 'font-size: 11px; color: var(--text-secondary); text-align: center;';
847
+ morphDisplay.textContent = 'Interpolation: 50%';
848
+ morphTab.appendChild(morphDisplay);
849
+
850
+ morphSlider.oninput = () => {
851
+ morphDisplay.textContent = `Interpolation: ${morphSlider.value}%`;
852
+ };
853
+
854
+ const playBtn = document.createElement('button');
855
+ playBtn.textContent = 'Play Animation';
856
+ playBtn.style.cssText = `
857
+ padding: 8px 12px;
858
+ background: var(--accent-blue);
859
+ color: white;
860
+ border-radius: 3px;
861
+ font-weight: 500;
862
+ cursor: pointer;
863
+ `;
864
+ morphTab.appendChild(playBtn);
865
+
866
+ tabContents['Morph'] = morphTab;
867
+
868
+ // Assemble
869
+ contentArea.appendChild(inputTab);
870
+ contentArea.appendChild(paramsTab);
871
+ contentArea.appendChild(editTab);
872
+ contentArea.appendChild(tableTab);
873
+ contentArea.appendChild(morphTab);
874
+
875
+ panel.appendChild(contentArea);
876
+
877
+ uiPanel = panel;
878
+ return panel;
879
+ }
880
+
881
+ // Export
882
+ return {
883
+ init,
884
+ getUI,
885
+ execute,
886
+ analyzeVariants,
887
+ inferParameters,
888
+ generateFamily,
889
+ generateConfiguration,
890
+ extractFingerprint,
891
+ alignVariants,
892
+ computeDifferenceMatrix,
893
+ interpolateConfigs,
894
+ extrapolateConfig,
895
+ morphAnimation,
896
+ safeEval
897
+ };
898
+ })();
899
+
900
+ window.CycleCAD.ParametricFromExample = ParametricFromExample;