cyclecad 3.11.0 → 3.13.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.
@@ -41,6 +41,23 @@
41
41
  lastAction: null
42
42
  };
43
43
 
44
+ // Live preview state (v3.12.0) — isolated mini Three.js scene embedded in dialog.
45
+ // Template-matched prompts render instantly. Novel prompts require explicit Enter/Generate.
46
+ const preview = {
47
+ scene: null,
48
+ camera: null,
49
+ renderer: null,
50
+ controls: null,
51
+ group: null, // THREE.Group holding current preview meshes
52
+ sketchState: null, // { shape, width, height, radius, points, origin }
53
+ bodyMesh: null, // first solid — target for ops.hole subtraction (visual)
54
+ lastMesh: null, // most recent mesh — used by ops.pattern
55
+ raf: null, // requestAnimationFrame handle
56
+ debounceTimer: null,
57
+ lastPrompt: '',
58
+ pendingLLM: false
59
+ };
60
+
44
61
  // ========== TYPEDEFS ==========
45
62
  /**
46
63
  * @typedef {Object} ParseResult
@@ -1092,13 +1109,22 @@
1092
1109
  <textarea
1093
1110
  id="ttc-input"
1094
1111
  class="ttc-input"
1095
- placeholder="e.g., 'a flanged cylinder 50mm diameter, 80mm tall with 4 bolt holes on a 70mm PCD'&#10;or 'gear with 24 teeth, module 2'"
1096
- rows="4"
1112
+ placeholder="Try: 'M8 nut', '4040 t-slot extrusion 500mm', '6200 bearing', 'spur gear 20 teeth module 2'&#10;or describe freely press Enter to generate with AI."
1113
+ rows="3"
1097
1114
  ></textarea>
1098
1115
  <div class="ttc-input-controls">
1099
- <button id="ttc-generate" class="ttc-btn ttc-btn-primary">Generate (Ctrl+Enter)</button>
1116
+ <button id="ttc-generate" class="ttc-btn ttc-btn-primary">Generate with AI</button>
1100
1117
  <button id="ttc-clear" class="ttc-btn ttc-btn-secondary">Clear</button>
1101
1118
  </div>
1119
+ <div id="ttc-status" class="ttc-status ttc-status-idle">Start typing to see a live preview</div>
1120
+ </div>
1121
+
1122
+ <div class="ttc-livepreview-section">
1123
+ <div id="ttc-preview-mount" class="ttc-preview-mount"></div>
1124
+ <div class="ttc-preview-actions">
1125
+ <button id="ttc-insert" class="ttc-btn ttc-btn-primary" disabled>Insert into scene</button>
1126
+ <button id="ttc-download-stl" class="ttc-btn ttc-btn-secondary" disabled>Download STL</button>
1127
+ </div>
1102
1128
  </div>
1103
1129
 
1104
1130
  <div class="ttc-preview-section">
@@ -1437,9 +1463,508 @@
1437
1463
  background: var(--border-color);
1438
1464
  border-color: var(--accent-blue);
1439
1465
  }
1466
+
1467
+ .ttc-status {
1468
+ padding: 6px 8px;
1469
+ border-radius: 3px;
1470
+ font-size: 11px;
1471
+ border-left: 3px solid transparent;
1472
+ transition: all var(--transition-fast);
1473
+ }
1474
+ .ttc-status-idle { background: #0f172a; color: #94a3b8; border-left-color: #334155; }
1475
+ .ttc-status-template{ background: #052e26; color: #6ee7b7; border-left-color: #10b981; }
1476
+ .ttc-status-pending { background: #1e1b4b; color: #c4b5fd; border-left-color: #8b5cf6; }
1477
+ .ttc-status-error { background: #2a0f0f; color: #fca5a5; border-left-color: #ef4444; }
1478
+ .ttc-status-working { background: #1e293b; color: #7dd3fc; border-left-color: #38bdf8; }
1479
+
1480
+ .ttc-livepreview-section {
1481
+ display: flex;
1482
+ flex-direction: column;
1483
+ gap: 6px;
1484
+ padding: 8px;
1485
+ background: #0f172a;
1486
+ border: 1px solid var(--border-color);
1487
+ border-radius: 3px;
1488
+ }
1489
+ .ttc-preview-mount {
1490
+ width: 100%;
1491
+ height: 280px;
1492
+ background: #0b1220;
1493
+ border-radius: 3px;
1494
+ overflow: hidden;
1495
+ position: relative;
1496
+ }
1497
+ .ttc-preview-mount canvas {
1498
+ display: block;
1499
+ width: 100% !important;
1500
+ height: 100% !important;
1501
+ }
1502
+ .ttc-preview-actions {
1503
+ display: flex;
1504
+ gap: 6px;
1505
+ }
1440
1506
  `;
1441
1507
  }
1442
1508
 
1509
+ // ========== LIVE PREVIEW ENGINE (template-matched + debounced) ==========
1510
+
1511
+ /**
1512
+ * Set up a dedicated Three.js preview scene inside the dialog.
1513
+ * Called once when the UI is first mounted.
1514
+ * @param {HTMLElement} mount - Container element for the preview canvas
1515
+ */
1516
+ function setupPreviewScene(mount) {
1517
+ if (!window.THREE || !mount) return;
1518
+ const THREE = window.THREE;
1519
+ const w = mount.clientWidth || 360;
1520
+ const h = mount.clientHeight || 280;
1521
+
1522
+ preview.scene = new THREE.Scene();
1523
+ preview.scene.background = new THREE.Color(0x0b1220);
1524
+
1525
+ preview.camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 5000);
1526
+ preview.camera.position.set(180, 180, 180);
1527
+ preview.camera.lookAt(0, 0, 0);
1528
+
1529
+ preview.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
1530
+ preview.renderer.setPixelRatio(window.devicePixelRatio || 1);
1531
+ preview.renderer.setSize(w, h, false);
1532
+ mount.appendChild(preview.renderer.domElement);
1533
+
1534
+ const hemi = new THREE.HemisphereLight(0xffffff, 0x444466, 0.8);
1535
+ preview.scene.add(hemi);
1536
+ const dir = new THREE.DirectionalLight(0xffffff, 0.6);
1537
+ dir.position.set(150, 250, 100);
1538
+ preview.scene.add(dir);
1539
+
1540
+ const grid = new THREE.GridHelper(200, 20, 0x1f2937, 0x1f2937);
1541
+ grid.position.y = -0.01;
1542
+ preview.scene.add(grid);
1543
+
1544
+ // Simple orbit: mouse drag rotates around target.
1545
+ let dragging = false, lastX = 0, lastY = 0, yaw = Math.PI / 4, pitch = Math.PI / 5, dist = 260;
1546
+ const canvas = preview.renderer.domElement;
1547
+ const applyCam = () => {
1548
+ const x = Math.cos(pitch) * Math.cos(yaw) * dist;
1549
+ const y = Math.sin(pitch) * dist;
1550
+ const z = Math.cos(pitch) * Math.sin(yaw) * dist;
1551
+ preview.camera.position.set(x, y, z);
1552
+ preview.camera.lookAt(0, 0, 0);
1553
+ };
1554
+ applyCam();
1555
+ canvas.addEventListener('pointerdown', (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; canvas.setPointerCapture(e.pointerId); });
1556
+ canvas.addEventListener('pointerup', (e) => { dragging = false; try { canvas.releasePointerCapture(e.pointerId); } catch(_){} });
1557
+ canvas.addEventListener('pointermove', (e) => {
1558
+ if (!dragging) return;
1559
+ yaw -= (e.clientX - lastX) * 0.01;
1560
+ pitch = Math.max(-1.3, Math.min(1.3, pitch + (e.clientY - lastY) * 0.01));
1561
+ lastX = e.clientX; lastY = e.clientY;
1562
+ applyCam();
1563
+ });
1564
+ canvas.addEventListener('wheel', (e) => {
1565
+ e.preventDefault();
1566
+ dist = Math.max(40, Math.min(2000, dist * (1 + Math.sign(e.deltaY) * 0.1)));
1567
+ applyCam();
1568
+ }, { passive: false });
1569
+ preview._applyCam = applyCam;
1570
+ preview._getDist = () => dist;
1571
+ preview._setDist = (d) => { dist = d; applyCam(); };
1572
+
1573
+ const tick = () => {
1574
+ if (!preview.renderer) return;
1575
+ preview.renderer.render(preview.scene, preview.camera);
1576
+ preview.raf = requestAnimationFrame(tick);
1577
+ };
1578
+ tick();
1579
+
1580
+ // Handle container resizing.
1581
+ if (window.ResizeObserver) {
1582
+ const ro = new ResizeObserver(() => {
1583
+ if (!preview.renderer) return;
1584
+ const nw = mount.clientWidth || 360, nh = mount.clientHeight || 280;
1585
+ preview.renderer.setSize(nw, nh, false);
1586
+ preview.camera.aspect = nw / nh;
1587
+ preview.camera.updateProjectionMatrix();
1588
+ });
1589
+ ro.observe(mount);
1590
+ }
1591
+ }
1592
+
1593
+ /**
1594
+ * Clear the preview group (remove all meshes, reset state).
1595
+ */
1596
+ function clearPreview() {
1597
+ if (!preview.scene) return;
1598
+ if (preview.group) {
1599
+ preview.scene.remove(preview.group);
1600
+ preview.group.traverse(obj => {
1601
+ if (obj.geometry) obj.geometry.dispose();
1602
+ if (obj.material) {
1603
+ if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose());
1604
+ else obj.material.dispose();
1605
+ }
1606
+ });
1607
+ }
1608
+ preview.group = new window.THREE.Group();
1609
+ preview.group.name = 'TextToCADPreview';
1610
+ preview.scene.add(preview.group);
1611
+ preview.sketchState = null;
1612
+ preview.bodyMesh = null;
1613
+ preview.lastMesh = null;
1614
+ }
1615
+
1616
+ /**
1617
+ * Helper: extract [x,y,z] position from step params.
1618
+ */
1619
+ function previewGetPos(p) {
1620
+ if (Array.isArray(p.position)) return p.position;
1621
+ if (Array.isArray(p.center)) return p.center;
1622
+ if (Array.isArray(p.at)) return p.at;
1623
+ return [+p.x || 0, +p.y || 0, +p.z || 0];
1624
+ }
1625
+
1626
+ /**
1627
+ * Mini executor that runs a single plan step against the preview scene.
1628
+ * Mirrors the methods AICopilot.miniExecute supports, but writes into preview.group
1629
+ * instead of window._scene. Subtraction is visual-only (no CSG) to keep it fast.
1630
+ * @param {{method:string, params:Object}} step
1631
+ */
1632
+ function previewExecutor(step) {
1633
+ if (!preview.scene || !window.THREE) return;
1634
+ const THREE = window.THREE;
1635
+ const method = step.method;
1636
+ const params = step.params || {};
1637
+
1638
+ if (method === 'sketch.start') {
1639
+ preview.sketchState = { plane: params.plane || 'XY', origin: previewGetPos(params) };
1640
+ return;
1641
+ }
1642
+ if (method === 'sketch.rect') {
1643
+ preview.sketchState = Object.assign(preview.sketchState || {}, {
1644
+ shape: 'rect',
1645
+ width: params.width || params.w || 50,
1646
+ height: params.height || params.h || 30,
1647
+ origin: previewGetPos(params)
1648
+ });
1649
+ return;
1650
+ }
1651
+ if (method === 'sketch.circle') {
1652
+ preview.sketchState = Object.assign(preview.sketchState || {}, {
1653
+ shape: 'circle',
1654
+ radius: params.radius || params.r || (params.diameter ? params.diameter / 2 : 25),
1655
+ origin: previewGetPos(params)
1656
+ });
1657
+ return;
1658
+ }
1659
+ if (method === 'sketch.polyline' || method === 'sketch.polygon') {
1660
+ const raw = Array.isArray(params.points) ? params.points : [];
1661
+ const pts = raw.filter(p => Array.isArray(p) && p.length >= 2).map(p => [Number(p[0]) || 0, Number(p[1]) || 0]);
1662
+ if (pts.length < 3) return;
1663
+ preview.sketchState = Object.assign(preview.sketchState || {}, {
1664
+ shape: 'polyline',
1665
+ points: pts,
1666
+ origin: previewGetPos(params)
1667
+ });
1668
+ return;
1669
+ }
1670
+ if (method === 'sketch.line' || method === 'sketch.end') return;
1671
+
1672
+ if (method === 'ops.extrude') {
1673
+ const d = params.depth || params.height || params.distance || 20;
1674
+ const sk = preview.sketchState || {};
1675
+ const explicit = (Array.isArray(params.position) || params.x !== undefined) ? previewGetPos(params) : null;
1676
+ const pos = explicit || sk.origin || [0, 0, 0];
1677
+ let g;
1678
+ if (sk.shape === 'rect') g = new THREE.BoxGeometry(sk.width, d, sk.height);
1679
+ else if (sk.shape === 'circle') g = new THREE.CylinderGeometry(sk.radius, sk.radius, d, 48);
1680
+ else if (sk.shape === 'polyline' && Array.isArray(sk.points) && sk.points.length >= 3) {
1681
+ const shape = new THREE.Shape();
1682
+ const pts = sk.points;
1683
+ shape.moveTo(pts[0][0], -pts[0][1]);
1684
+ for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i][0], -pts[i][1]);
1685
+ shape.closePath();
1686
+ g = new THREE.ExtrudeGeometry(shape, { depth: d, bevelEnabled: false, curveSegments: 24 });
1687
+ g.translate(0, 0, -d / 2);
1688
+ g.rotateX(-Math.PI / 2);
1689
+ } else {
1690
+ g = new THREE.BoxGeometry(50, d, 30);
1691
+ }
1692
+ const isSub = params.subtract === true || params.operation === 'cut' || params.operation === 'subtract';
1693
+ const mat = new THREE.MeshStandardMaterial({
1694
+ color: isSub ? 0x1a1a1a : 0x38bdf8,
1695
+ metalness: 0.3,
1696
+ roughness: 0.5
1697
+ });
1698
+ const mesh = new THREE.Mesh(g, mat);
1699
+ mesh.position.set(pos[0] || 0, (pos[1] || 0) + d / 2, pos[2] || 0);
1700
+ preview.group.add(mesh);
1701
+ preview.lastMesh = mesh;
1702
+ if (!preview.bodyMesh && !isSub) preview.bodyMesh = mesh;
1703
+ preview.sketchState = null;
1704
+ return;
1705
+ }
1706
+
1707
+ if (method === 'ops.hole' || method === 'ops.subtract' || method === 'ops.cut') {
1708
+ // Visual-only: mark the cut region with a dark cylinder/box (no CSG to keep preview fast).
1709
+ const pos = previewGetPos(params);
1710
+ const d = params.depth || 25;
1711
+ let g;
1712
+ if (params.width && params.height) {
1713
+ g = new THREE.BoxGeometry(params.width, d, params.height);
1714
+ } else {
1715
+ const r = params.radius || (params.diameter ? params.diameter / 2 : 3);
1716
+ g = new THREE.CylinderGeometry(r, r, d, 32);
1717
+ }
1718
+ const mat = new THREE.MeshStandardMaterial({ color: 0x111827, metalness: 0.1, roughness: 0.9 });
1719
+ const mesh = new THREE.Mesh(g, mat);
1720
+ mesh.position.set(pos[0] || 0, (pos[1] || 0) + d / 2 - 0.5, pos[2] || 0);
1721
+ preview.group.add(mesh);
1722
+ return;
1723
+ }
1724
+
1725
+ if (method === 'ops.pattern') {
1726
+ if (!preview.lastMesh) return;
1727
+ const count = Math.max(2, Math.min(20, params.count || 4));
1728
+ const sx = params.spacingX || (params.direction === 'x' ? params.spacing : 0) || 0;
1729
+ const sz = params.spacingZ || (params.direction === 'z' ? params.spacing : 0) || 0;
1730
+ const sy = params.spacingY || 0;
1731
+ for (let i = 1; i < count; i++) {
1732
+ const c = preview.lastMesh.clone();
1733
+ c.position.x += sx * i;
1734
+ c.position.z += sz * i;
1735
+ c.position.y += sy * i;
1736
+ preview.group.add(c);
1737
+ }
1738
+ return;
1739
+ }
1740
+
1741
+ if (method === 'view.fit') {
1742
+ if (!preview.group || !preview.camera) return;
1743
+ const box = new THREE.Box3().setFromObject(preview.group);
1744
+ if (box.isEmpty()) return;
1745
+ const size = box.getSize(new THREE.Vector3());
1746
+ const maxDim = Math.max(size.x, size.y, size.z) || 100;
1747
+ const fov = (preview.camera.fov || 45) * Math.PI / 180;
1748
+ const dist = maxDim / (2 * Math.tan(fov / 2)) * 2.3;
1749
+ if (preview._setDist) preview._setDist(dist);
1750
+ return;
1751
+ }
1752
+ if (method === 'view.set') return; // honour iso default
1753
+ // Unknown/stub methods: ignore silently.
1754
+ }
1755
+
1756
+ /**
1757
+ * Run a plan (array of steps) against the preview scene.
1758
+ * @param {Array} plan
1759
+ */
1760
+ function runPlanInPreview(plan) {
1761
+ if (!Array.isArray(plan)) return;
1762
+ clearPreview();
1763
+ for (const step of plan) {
1764
+ try { previewExecutor(step); } catch (_) { /* continue */ }
1765
+ }
1766
+ // Ensure a fit pass ran.
1767
+ try { previewExecutor({ method: 'view.fit', params: {} }); } catch (_) {}
1768
+ }
1769
+
1770
+ /**
1771
+ * Debounced handler: called on every keystroke.
1772
+ * Fast path: if matchTemplate returns a plan, render it immediately and show success badge.
1773
+ * Slow path: show "Press Enter" hint — no LLM call until user confirms.
1774
+ * @param {string} promptText
1775
+ */
1776
+ function onPromptTyped(promptText) {
1777
+ if (preview.debounceTimer) clearTimeout(preview.debounceTimer);
1778
+ const trimmed = (promptText || '').trim();
1779
+ if (trimmed.length === 0) {
1780
+ setStatus('Start typing to see a live preview', 'idle');
1781
+ clearPreview();
1782
+ setInsertEnabled(false);
1783
+ return;
1784
+ }
1785
+ preview.debounceTimer = setTimeout(() => {
1786
+ preview.lastPrompt = trimmed;
1787
+ let plan = null;
1788
+ try {
1789
+ if (window.CycleCAD && window.CycleCAD.AICopilot && typeof window.CycleCAD.AICopilot.matchTemplate === 'function') {
1790
+ plan = window.CycleCAD.AICopilot.matchTemplate(trimmed);
1791
+ }
1792
+ } catch (_) { plan = null; }
1793
+ if (Array.isArray(plan) && plan.length > 0) {
1794
+ runPlanInPreview(plan);
1795
+ setStatus('Template matched (' + plan.length + ' step' + (plan.length === 1 ? '' : 's') + ') — adjust or Insert into scene', 'template');
1796
+ setInsertEnabled(true);
1797
+ } else {
1798
+ // No template match. Try the built-in NLP parser as a secondary fast path.
1799
+ try {
1800
+ const spec = parseDescription(trimmed);
1801
+ const geom = spec ? generateGeometry(spec) : null;
1802
+ if (geom) {
1803
+ clearPreview();
1804
+ geom.traverse(m => {
1805
+ if (m.material) { m.material.opacity = 1; m.material.transparent = false; }
1806
+ });
1807
+ preview.group.add(geom);
1808
+ // Auto-fit.
1809
+ previewExecutor({ method: 'view.fit', params: {} });
1810
+ setStatus('Parsed locally (confidence ' + Math.round(spec.confidence * 100) + '%) — press Enter for AI refinement', 'template');
1811
+ setInsertEnabled(true);
1812
+ return;
1813
+ }
1814
+ } catch (_) { /* fall through */ }
1815
+ setStatus('No template match — press Enter to generate with AI', 'pending');
1816
+ setInsertEnabled(false);
1817
+ }
1818
+ }, 400);
1819
+ }
1820
+
1821
+ /**
1822
+ * Update the status badge.
1823
+ * @param {string} text
1824
+ * @param {'idle'|'template'|'pending'|'error'|'working'} kind
1825
+ */
1826
+ function setStatus(text, kind) {
1827
+ const el = document.querySelector('#ttc-status');
1828
+ if (!el) return;
1829
+ el.textContent = text;
1830
+ el.className = 'ttc-status ttc-status-' + (kind || 'idle');
1831
+ }
1832
+
1833
+ /**
1834
+ * Enable/disable the Insert and Download buttons.
1835
+ */
1836
+ function setInsertEnabled(enabled) {
1837
+ const ins = document.querySelector('#ttc-insert');
1838
+ const dl = document.querySelector('#ttc-download-stl');
1839
+ if (ins) ins.disabled = !enabled;
1840
+ if (dl) dl.disabled = !enabled;
1841
+ }
1842
+
1843
+ /**
1844
+ * Copy the current preview geometry into the main cycleCAD scene.
1845
+ */
1846
+ function insertPreviewIntoMainScene() {
1847
+ if (!preview.group || preview.group.children.length === 0) return;
1848
+ const mainScene = (state.scene) || window._scene;
1849
+ if (!mainScene || !window.THREE) {
1850
+ setStatus('Main scene not available', 'error');
1851
+ return;
1852
+ }
1853
+ const clone = new window.THREE.Group();
1854
+ clone.name = 'TextToCAD_' + Date.now();
1855
+ preview.group.children.forEach(child => {
1856
+ const c = child.clone();
1857
+ if (c.material && c.material.clone) c.material = c.material.clone();
1858
+ clone.add(c);
1859
+ });
1860
+ mainScene.add(clone);
1861
+ state.currentGeometry = clone;
1862
+ addStep({ input: preview.lastPrompt || 'Text-to-CAD', geometry: clone, timestamp: Date.now() });
1863
+ setStatus('Inserted into main scene', 'template');
1864
+ }
1865
+
1866
+ /**
1867
+ * Generate an ASCII STL string from the current preview group.
1868
+ * @returns {string}
1869
+ */
1870
+ function previewToSTL() {
1871
+ if (!preview.group || !window.THREE) return '';
1872
+ const THREE = window.THREE;
1873
+ const lines = ['solid TextToCAD'];
1874
+ const v = new THREE.Vector3();
1875
+ const m = new THREE.Matrix4();
1876
+ preview.group.traverse(obj => {
1877
+ if (!obj.isMesh || !obj.geometry) return;
1878
+ const geom = obj.geometry.index ? obj.geometry.toNonIndexed() : obj.geometry;
1879
+ const pos = geom.getAttribute('position');
1880
+ if (!pos) return;
1881
+ obj.updateMatrixWorld(true);
1882
+ m.copy(obj.matrixWorld);
1883
+ for (let i = 0; i < pos.count; i += 3) {
1884
+ const a = new THREE.Vector3().fromBufferAttribute(pos, i).applyMatrix4(m);
1885
+ const b = new THREE.Vector3().fromBufferAttribute(pos, i + 1).applyMatrix4(m);
1886
+ const c = new THREE.Vector3().fromBufferAttribute(pos, i + 2).applyMatrix4(m);
1887
+ const n = new THREE.Vector3().subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a)).normalize();
1888
+ lines.push(' facet normal ' + n.x.toFixed(6) + ' ' + n.y.toFixed(6) + ' ' + n.z.toFixed(6));
1889
+ lines.push(' outer loop');
1890
+ lines.push(' vertex ' + a.x.toFixed(6) + ' ' + a.y.toFixed(6) + ' ' + a.z.toFixed(6));
1891
+ lines.push(' vertex ' + b.x.toFixed(6) + ' ' + b.y.toFixed(6) + ' ' + b.z.toFixed(6));
1892
+ lines.push(' vertex ' + c.x.toFixed(6) + ' ' + c.y.toFixed(6) + ' ' + c.z.toFixed(6));
1893
+ lines.push(' endloop');
1894
+ lines.push(' endfacet');
1895
+ }
1896
+ });
1897
+ lines.push('endsolid TextToCAD');
1898
+ return lines.join('\n');
1899
+ }
1900
+
1901
+ /**
1902
+ * Trigger a download of the current preview as an ASCII STL file.
1903
+ */
1904
+ function downloadPreviewSTL() {
1905
+ const stl = previewToSTL();
1906
+ if (!stl) return;
1907
+ const blob = new Blob([stl], { type: 'model/stl' });
1908
+ const url = URL.createObjectURL(blob);
1909
+ const a = document.createElement('a');
1910
+ a.href = url;
1911
+ a.download = 'text-to-cad-' + Date.now() + '.stl';
1912
+ document.body.appendChild(a);
1913
+ a.click();
1914
+ document.body.removeChild(a);
1915
+ setTimeout(() => URL.revokeObjectURL(url), 500);
1916
+ }
1917
+
1918
+ /**
1919
+ * Confirm-generate: called when user presses Enter or clicks Generate.
1920
+ * If no template matched, this is where an LLM call would go. For now we:
1921
+ * 1. Re-attempt matchTemplate (in case user added more text)
1922
+ * 2. Fall back to the built-in NLP parseDescription/generateGeometry
1923
+ * 3. Trigger AICopilot.execute('generate', ...) if available (hand off to LLM UI)
1924
+ */
1925
+ function confirmGenerate(promptText) {
1926
+ const trimmed = (promptText || '').trim();
1927
+ if (!trimmed) return;
1928
+ preview.lastPrompt = trimmed;
1929
+ setStatus('Generating...', 'working');
1930
+
1931
+ let plan = null;
1932
+ try {
1933
+ if (window.CycleCAD && window.CycleCAD.AICopilot && typeof window.CycleCAD.AICopilot.matchTemplate === 'function') {
1934
+ plan = window.CycleCAD.AICopilot.matchTemplate(trimmed);
1935
+ }
1936
+ } catch (_) { plan = null; }
1937
+ if (Array.isArray(plan) && plan.length > 0) {
1938
+ runPlanInPreview(plan);
1939
+ setStatus('Template matched (' + plan.length + ' steps)', 'template');
1940
+ setInsertEnabled(true);
1941
+ return;
1942
+ }
1943
+ // Try local NLP parser.
1944
+ try {
1945
+ const spec = parseDescription(trimmed);
1946
+ const geom = spec ? generateGeometry(spec) : null;
1947
+ if (geom) {
1948
+ clearPreview();
1949
+ preview.group.add(geom);
1950
+ previewExecutor({ method: 'view.fit', params: {} });
1951
+ setStatus('Parsed locally (confidence ' + Math.round(spec.confidence * 100) + '%)', 'template');
1952
+ setInsertEnabled(true);
1953
+ return;
1954
+ }
1955
+ } catch (_) {}
1956
+
1957
+ // Hand off to AI Copilot LLM pipeline if available.
1958
+ if (window.CycleCAD && window.CycleCAD.AICopilot && typeof window.CycleCAD.AICopilot.execute === 'function') {
1959
+ try {
1960
+ window.CycleCAD.AICopilot.execute('generate', { prompt: trimmed });
1961
+ setStatus('Sent to AI Copilot — switch to Copilot panel to view progress', 'working');
1962
+ return;
1963
+ } catch (_) {}
1964
+ }
1965
+ setStatus('Unable to parse — try a simpler prompt like "M8 nut"', 'error');
1966
+ }
1967
+
1443
1968
  /**
1444
1969
  * Initialize module
1445
1970
  * @param {THREE.Scene} scene
@@ -1470,58 +1995,63 @@
1470
1995
  const livePreviewCheckbox = container.querySelector('#ttc-live-preview');
1471
1996
  const undoBtn = container.querySelector('#ttc-undo');
1472
1997
  const redoBtn = container.querySelector('#ttc-redo');
1998
+ const insertBtn = container.querySelector('#ttc-insert');
1999
+ const downloadBtn = container.querySelector('#ttc-download-stl');
2000
+ const previewMount = container.querySelector('#ttc-preview-mount');
1473
2001
  const examples = container.querySelectorAll('.ttc-example');
1474
2002
 
1475
- // Input handling
2003
+ // Initialize the live preview scene (once per mount).
2004
+ if (previewMount && !preview.scene) {
2005
+ try { setupPreviewScene(previewMount); clearPreview(); } catch (_) {}
2006
+ }
2007
+
2008
+ // Input handling — debounced live preview via template matcher.
1476
2009
  if (input) {
1477
2010
  input.addEventListener('input', (e) => {
1478
- if (livePreviewCheckbox && livePreviewCheckbox.checked) {
1479
- updateLivePreview(e.target.value);
1480
- }
2011
+ onPromptTyped(e.target.value);
1481
2012
  });
1482
2013
 
1483
2014
  input.addEventListener('keydown', (e) => {
1484
- if (e.ctrlKey && e.key === 'Enter') {
1485
- generateBtn.click();
2015
+ // Enter (no shift) or Ctrl/Cmd+Enter → confirm generation.
2016
+ if ((e.key === 'Enter' && !e.shiftKey) || ((e.ctrlKey || e.metaKey) && e.key === 'Enter')) {
2017
+ e.preventDefault();
2018
+ confirmGenerate(input.value);
1486
2019
  }
1487
2020
  });
1488
2021
  }
1489
2022
 
1490
- // Generate button
2023
+ // Generate button → same as Enter.
1491
2024
  if (generateBtn) {
1492
2025
  generateBtn.addEventListener('click', () => {
1493
- if (input) {
1494
- const spec = parseDescription(input.value);
1495
- if (spec) {
1496
- const geometry = generateGeometry(spec);
1497
- if (geometry) {
1498
- commitPreview();
1499
- }
1500
- }
1501
- }
2026
+ if (input) confirmGenerate(input.value);
1502
2027
  });
1503
2028
  }
1504
2029
 
1505
- // Clear button
2030
+ // Clear button.
1506
2031
  if (clearBtn) {
1507
2032
  clearBtn.addEventListener('click', () => {
1508
2033
  if (input) input.value = '';
1509
- if (state.previewGeometry && state.scene) {
1510
- state.scene.remove(state.previewGeometry);
1511
- state.previewGeometry = null;
1512
- }
2034
+ clearPreview();
2035
+ setStatus('Start typing to see a live preview', 'idle');
2036
+ setInsertEnabled(false);
1513
2037
  });
1514
2038
  }
1515
2039
 
1516
- // Undo/Redo
1517
- if (undoBtn) {
1518
- undoBtn.addEventListener('click', undoStep);
2040
+ // Insert into main scene.
2041
+ if (insertBtn) {
2042
+ insertBtn.addEventListener('click', insertPreviewIntoMainScene);
1519
2043
  }
1520
- if (redoBtn) {
1521
- redoBtn.addEventListener('click', redoStep);
2044
+
2045
+ // Download STL.
2046
+ if (downloadBtn) {
2047
+ downloadBtn.addEventListener('click', downloadPreviewSTL);
1522
2048
  }
1523
2049
 
1524
- // Example prompts
2050
+ // Undo/Redo (legacy).
2051
+ if (undoBtn) undoBtn.addEventListener('click', undoStep);
2052
+ if (redoBtn) redoBtn.addEventListener('click', redoStep);
2053
+
2054
+ // Example prompts.
1525
2055
  examples.forEach(example => {
1526
2056
  example.addEventListener('click', () => {
1527
2057
  if (input) {
@@ -1531,7 +2061,7 @@
1531
2061
  });
1532
2062
  });
1533
2063
 
1534
- // Live preview toggle
2064
+ // Legacy live-preview checkbox (still supported).
1535
2065
  if (livePreviewCheckbox) {
1536
2066
  livePreviewCheckbox.addEventListener('change', (e) => {
1537
2067
  if (!e.target.checked && state.previewGeometry && state.scene) {
@@ -1540,8 +2070,6 @@
1540
2070
  }
1541
2071
  });
1542
2072
  }
1543
-
1544
- console.log('TextToCAD module initialized');
1545
2073
  }
1546
2074
 
1547
2075
  /**