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.
- package/app/index.html +42 -11
- package/app/js/modules/ai-copilot.js +4 -1
- package/app/js/modules/ai-engineer-rag.js +689 -0
- package/app/js/modules/ai-engineer.js +855 -60
- package/app/js/modules/text-to-cad.js +561 -33
- package/cyclecad.html +1081 -0
- package/explodeview.html +1102 -0
- package/index.html +1683 -1240
- package/package.json +1 -1
- package/pentacad.html +1097 -0
- package/server/converter.py +528 -0
|
@@ -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="
|
|
1096
|
-
rows="
|
|
1112
|
+
placeholder="Try: 'M8 nut', '4040 t-slot extrusion 500mm', '6200 bearing', 'spur gear 20 teeth module 2' 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
|
|
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
|
-
//
|
|
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
|
-
|
|
1479
|
-
updateLivePreview(e.target.value);
|
|
1480
|
-
}
|
|
2011
|
+
onPromptTyped(e.target.value);
|
|
1481
2012
|
});
|
|
1482
2013
|
|
|
1483
2014
|
input.addEventListener('keydown', (e) => {
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
}
|
|
2034
|
+
clearPreview();
|
|
2035
|
+
setStatus('Start typing to see a live preview', 'idle');
|
|
2036
|
+
setInsertEnabled(false);
|
|
1513
2037
|
});
|
|
1514
2038
|
}
|
|
1515
2039
|
|
|
1516
|
-
//
|
|
1517
|
-
if (
|
|
1518
|
-
|
|
2040
|
+
// Insert into main scene.
|
|
2041
|
+
if (insertBtn) {
|
|
2042
|
+
insertBtn.addEventListener('click', insertPreviewIntoMainScene);
|
|
1519
2043
|
}
|
|
1520
|
-
|
|
1521
|
-
|
|
2044
|
+
|
|
2045
|
+
// Download STL.
|
|
2046
|
+
if (downloadBtn) {
|
|
2047
|
+
downloadBtn.addEventListener('click', downloadPreviewSTL);
|
|
1522
2048
|
}
|
|
1523
2049
|
|
|
1524
|
-
//
|
|
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
|
-
//
|
|
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
|
/**
|