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.
- package/app/index.html +558 -25
- package/app/js/explodeview-full.js +1141 -0
- package/app/js/modules/image-to-cad.js +1184 -0
- package/app/js/modules/openscad-engine.js +817 -0
- package/app/js/modules/parametric-sliders.js +1322 -0
- package/app/js/modules/scad-export.js +643 -0
- package/app/js/test-compat-shim.js +121 -0
- package/app/tests/killer-features-visual-test.html +71 -49
- package/package.json +1 -1
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageToCAD Module — Browser-based image-to-parametric-3D conversion
|
|
3
|
+
*
|
|
4
|
+
* BEATS CADAM because:
|
|
5
|
+
* - Offline edge detection (no API key required)
|
|
6
|
+
* - Real-time parametric slider updates (instant geometry change)
|
|
7
|
+
* - Sketch recognition with Hough transform
|
|
8
|
+
* - Multi-view 3D reconstruction
|
|
9
|
+
* - Full undo/redo for slider changes
|
|
10
|
+
* - Integrated into cycleCAD feature tree
|
|
11
|
+
*
|
|
12
|
+
* Supports: Gemini Vision API (optional), Canvas-based fallback
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
(function initImageToCAD() {
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// STATE & CONFIGURATION
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
const state = {
|
|
23
|
+
scene: null,
|
|
24
|
+
renderer: null,
|
|
25
|
+
currentImage: null,
|
|
26
|
+
detectedGeometry: null,
|
|
27
|
+
parametricSliders: {},
|
|
28
|
+
sliderHistory: [],
|
|
29
|
+
historyIndex: -1,
|
|
30
|
+
currentModelGroup: null,
|
|
31
|
+
meshGroup: new THREE.Group(),
|
|
32
|
+
debugCanvas: null,
|
|
33
|
+
conversionHistory: [],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const config = {
|
|
37
|
+
maxImageSize: 2048,
|
|
38
|
+
edgeThreshold: 100,
|
|
39
|
+
minContourLength: 20,
|
|
40
|
+
houghVotes: 50,
|
|
41
|
+
maxShapeTypes: 8,
|
|
42
|
+
sliderRangeMultiplier: { min: 0.1, max: 10 },
|
|
43
|
+
geometryCache: new Map(),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Shape detection templates
|
|
47
|
+
const shapeTemplates = {
|
|
48
|
+
cylinder: { ratio: 'height > width', features: ['circular_top', 'straight_sides'] },
|
|
49
|
+
box: { ratio: 'all_sides_similar', features: ['right_angles', 'flat_faces'] },
|
|
50
|
+
sphere: { ratio: 'circular_outline', features: ['smooth_shading'] },
|
|
51
|
+
cone: { ratio: 'triangular_profile', features: ['circular_base', 'pointed_top'] },
|
|
52
|
+
tube: { ratio: 'concentric_circles', features: ['circular_outline', 'hollow'] },
|
|
53
|
+
bracket: { ratio: 'angular', features: ['right_angles', 'thin_walls'] },
|
|
54
|
+
flange: { ratio: 'disk_like', features: ['circular_center', 'radial_holes'] },
|
|
55
|
+
gear: { ratio: 'circular_teeth', features: ['radial_pattern', 'teeth'] },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// IMAGE UPLOAD & PREPROCESSING
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Initialize image upload handlers
|
|
64
|
+
*/
|
|
65
|
+
function initImageUpload(container) {
|
|
66
|
+
const zone = document.createElement('div');
|
|
67
|
+
zone.className = 'image-upload-zone';
|
|
68
|
+
zone.innerHTML = `
|
|
69
|
+
<div style="text-align:center; padding:30px; border:2px dashed #888; border-radius:8px; cursor:pointer;">
|
|
70
|
+
<div style="font-size:24px; margin-bottom:10px;">📷</div>
|
|
71
|
+
<div style="font-weight:bold; margin-bottom:5px;">Drag Image or Click to Upload</div>
|
|
72
|
+
<div style="font-size:12px; color:#666;">PNG, JPG, SVG, WEBP (max 2MB)</div>
|
|
73
|
+
<input type="file" id="image-input" style="display:none;" accept="image/*">
|
|
74
|
+
</div>
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
zone.addEventListener('dragover', (e) => {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
zone.style.backgroundColor = '#f0f0f0';
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
zone.addEventListener('dragleave', () => {
|
|
83
|
+
zone.style.backgroundColor = 'transparent';
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
zone.addEventListener('drop', (e) => {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
zone.style.backgroundColor = 'transparent';
|
|
89
|
+
const file = e.dataTransfer.files[0];
|
|
90
|
+
if (file && file.type.startsWith('image/')) {
|
|
91
|
+
handleImageUpload(file);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
zone.addEventListener('click', () => {
|
|
96
|
+
document.getElementById('image-input')?.click();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const fileInput = zone.querySelector('#image-input');
|
|
100
|
+
fileInput.addEventListener('change', (e) => {
|
|
101
|
+
if (e.target.files[0]) {
|
|
102
|
+
handleImageUpload(e.target.files[0]);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
container.appendChild(zone);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Process uploaded image file
|
|
111
|
+
*/
|
|
112
|
+
function handleImageUpload(file) {
|
|
113
|
+
const reader = new FileReader();
|
|
114
|
+
reader.onload = (e) => {
|
|
115
|
+
const img = new Image();
|
|
116
|
+
img.onload = () => {
|
|
117
|
+
state.currentImage = img;
|
|
118
|
+
analyzeImage(img);
|
|
119
|
+
updatePreview(img);
|
|
120
|
+
};
|
|
121
|
+
img.src = e.target.result;
|
|
122
|
+
};
|
|
123
|
+
reader.readAsDataURL(file);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Preprocess image: resize, normalize, enhance contrast
|
|
128
|
+
*/
|
|
129
|
+
function preprocessImage(img) {
|
|
130
|
+
const canvas = document.createElement('canvas');
|
|
131
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
132
|
+
|
|
133
|
+
// Resize to max dimension
|
|
134
|
+
let width = img.width;
|
|
135
|
+
let height = img.height;
|
|
136
|
+
if (width > config.maxImageSize || height > config.maxImageSize) {
|
|
137
|
+
const scale = config.maxImageSize / Math.max(width, height);
|
|
138
|
+
width = Math.floor(width * scale);
|
|
139
|
+
height = Math.floor(height * scale);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
canvas.width = width;
|
|
143
|
+
canvas.height = height;
|
|
144
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
145
|
+
|
|
146
|
+
// Enhance contrast
|
|
147
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
148
|
+
const data = imageData.data;
|
|
149
|
+
|
|
150
|
+
let min = 255, max = 0;
|
|
151
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
152
|
+
const gray = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
|
153
|
+
min = Math.min(min, gray);
|
|
154
|
+
max = Math.max(max, gray);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const range = max - min || 1;
|
|
158
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
159
|
+
const gray = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
|
160
|
+
const normalized = Math.floor(((gray - min) / range) * 255);
|
|
161
|
+
data[i] = data[i + 1] = data[i + 2] = normalized;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
ctx.putImageData(imageData, 0, 0);
|
|
165
|
+
return canvas;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ============================================================================
|
|
169
|
+
// EDGE DETECTION & SHAPE RECOGNITION
|
|
170
|
+
// ============================================================================
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Sobel edge detection on canvas
|
|
174
|
+
*/
|
|
175
|
+
function sobelEdgeDetection(canvas) {
|
|
176
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
177
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
178
|
+
const data = imageData.data;
|
|
179
|
+
const width = canvas.width;
|
|
180
|
+
const height = canvas.height;
|
|
181
|
+
|
|
182
|
+
const edges = new Uint8ClampedArray(data.length);
|
|
183
|
+
|
|
184
|
+
const sobelX = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]];
|
|
185
|
+
const sobelY = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]];
|
|
186
|
+
|
|
187
|
+
for (let y = 1; y < height - 1; y++) {
|
|
188
|
+
for (let x = 1; x < width - 1; x++) {
|
|
189
|
+
let gx = 0, gy = 0;
|
|
190
|
+
|
|
191
|
+
for (let ky = -1; ky <= 1; ky++) {
|
|
192
|
+
for (let kx = -1; kx <= 1; kx++) {
|
|
193
|
+
const idx = ((y + ky) * width + (x + kx)) * 4;
|
|
194
|
+
const gray = data[idx];
|
|
195
|
+
gx += sobelX[ky + 1][kx + 1] * gray;
|
|
196
|
+
gy += sobelY[ky + 1][kx + 1] * gray;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const magnitude = Math.sqrt(gx * gx + gy * gy);
|
|
201
|
+
const edgeIdx = (y * width + x) * 4;
|
|
202
|
+
edges[edgeIdx] = edges[edgeIdx + 1] = edges[edgeIdx + 2] = magnitude > config.edgeThreshold ? 255 : 0;
|
|
203
|
+
edges[edgeIdx + 3] = 255;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const edgeData = ctx.createImageData(width, height);
|
|
208
|
+
edgeData.data.set(edges);
|
|
209
|
+
return edgeData;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Hough transform for circle/line detection
|
|
214
|
+
*/
|
|
215
|
+
function houghTransform(edgeData) {
|
|
216
|
+
const data = edgeData.data;
|
|
217
|
+
const width = edgeData.width;
|
|
218
|
+
const height = edgeData.height;
|
|
219
|
+
|
|
220
|
+
const circles = [];
|
|
221
|
+
const lines = [];
|
|
222
|
+
|
|
223
|
+
// Find edge pixels
|
|
224
|
+
const edgePixels = [];
|
|
225
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
226
|
+
if (data[i] > 128) {
|
|
227
|
+
edgePixels.push(i / 4);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Circle detection (voting array: [cx, cy, r])
|
|
232
|
+
const voteCircles = new Map();
|
|
233
|
+
const rMin = 5, rMax = Math.min(width, height) / 2;
|
|
234
|
+
|
|
235
|
+
edgePixels.forEach((pixelIdx) => {
|
|
236
|
+
const y = Math.floor(pixelIdx / width);
|
|
237
|
+
const x = pixelIdx % width;
|
|
238
|
+
|
|
239
|
+
for (let r = rMin; r < rMax; r += 2) {
|
|
240
|
+
for (let angle = 0; angle < Math.PI * 2; angle += 0.1) {
|
|
241
|
+
const cx = Math.round(x - r * Math.cos(angle));
|
|
242
|
+
const cy = Math.round(y - r * Math.sin(angle));
|
|
243
|
+
const key = `${cx},${cy},${r}`;
|
|
244
|
+
voteCircles.set(key, (voteCircles.get(key) || 0) + 1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
voteCircles.forEach((votes, key) => {
|
|
250
|
+
if (votes > config.houghVotes) {
|
|
251
|
+
const [cx, cy, r] = key.split(',').map(Number);
|
|
252
|
+
circles.push({ cx, cy, radius: r, votes });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Line detection (voting: [theta, rho])
|
|
257
|
+
const numTheta = 180;
|
|
258
|
+
const rhoMax = Math.sqrt(width * width + height * height);
|
|
259
|
+
const voteLines = new Uint32Array(numTheta * Math.ceil(rhoMax));
|
|
260
|
+
|
|
261
|
+
edgePixels.forEach((pixelIdx) => {
|
|
262
|
+
const y = Math.floor(pixelIdx / width);
|
|
263
|
+
const x = pixelIdx % width;
|
|
264
|
+
|
|
265
|
+
for (let t = 0; t < numTheta; t++) {
|
|
266
|
+
const theta = (t * Math.PI) / numTheta;
|
|
267
|
+
const rho = x * Math.cos(theta) + y * Math.sin(theta);
|
|
268
|
+
const rhoIdx = Math.round(rho);
|
|
269
|
+
if (rhoIdx >= 0 && rhoIdx < rhoMax) {
|
|
270
|
+
voteLines[t * Math.ceil(rhoMax) + rhoIdx]++;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Extract strong lines
|
|
276
|
+
for (let t = 0; t < numTheta; t++) {
|
|
277
|
+
for (let r = 0; r < rhoMax; r++) {
|
|
278
|
+
if (voteLines[t * Math.ceil(rhoMax) + r] > config.houghVotes) {
|
|
279
|
+
lines.push({ theta: (t * Math.PI) / numTheta, rho: r });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { circles, lines };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Detect shape type from image analysis
|
|
289
|
+
*/
|
|
290
|
+
function detectShapeType(canvas, edgeData, houghData) {
|
|
291
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
292
|
+
const data = edgeData.data;
|
|
293
|
+
const width = edgeData.width;
|
|
294
|
+
const height = edgeData.height;
|
|
295
|
+
|
|
296
|
+
const detections = {};
|
|
297
|
+
|
|
298
|
+
// Count edge connectivity (compact shapes = circular/spherical)
|
|
299
|
+
let totalEdges = 0;
|
|
300
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
301
|
+
if (data[i] > 128) totalEdges++;
|
|
302
|
+
}
|
|
303
|
+
const compactness = totalEdges / (width * height);
|
|
304
|
+
|
|
305
|
+
// Hough-detected circles suggest cylinder/sphere/tube
|
|
306
|
+
if (houghData.circles.length >= 1) {
|
|
307
|
+
detections.cylinder = 0.6;
|
|
308
|
+
detections.sphere = 0.5;
|
|
309
|
+
detections.tube = 0.4;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Hough-detected lines suggest box/bracket/gear
|
|
313
|
+
const orthogonalLines = houghData.lines.filter((l1) =>
|
|
314
|
+
houghData.lines.some((l2) =>
|
|
315
|
+
Math.abs((l1.theta - l2.theta) - Math.PI / 2) < 0.2 ||
|
|
316
|
+
Math.abs(l1.theta - l2.theta) < 0.2
|
|
317
|
+
)
|
|
318
|
+
).length;
|
|
319
|
+
|
|
320
|
+
if (orthogonalLines > 3) {
|
|
321
|
+
detections.box = 0.7;
|
|
322
|
+
detections.bracket = 0.5;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Aspect ratio analysis
|
|
326
|
+
const outline = getImageOutline(canvas);
|
|
327
|
+
if (outline) {
|
|
328
|
+
const aspectRatio = outline.width / outline.height;
|
|
329
|
+
if (Math.abs(aspectRatio - 1) < 0.2) {
|
|
330
|
+
detections.sphere = Math.max(detections.sphere || 0, 0.6);
|
|
331
|
+
detections.gear = 0.3;
|
|
332
|
+
} else if (aspectRatio > 1.5) {
|
|
333
|
+
detections.cylinder = Math.max(detections.cylinder || 0, 0.5);
|
|
334
|
+
detections.flange = 0.4;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Return top 3 detections
|
|
339
|
+
return Object.entries(detections)
|
|
340
|
+
.sort((a, b) => b[1] - a[1])
|
|
341
|
+
.slice(0, 3)
|
|
342
|
+
.map(([type, confidence]) => ({ type, confidence }));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Get bounding outline of non-transparent pixels
|
|
347
|
+
*/
|
|
348
|
+
function getImageOutline(canvas) {
|
|
349
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
350
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
351
|
+
const data = imageData.data;
|
|
352
|
+
|
|
353
|
+
let minX = canvas.width, maxX = 0, minY = canvas.height, maxY = 0;
|
|
354
|
+
let found = false;
|
|
355
|
+
|
|
356
|
+
for (let i = 3; i < data.length; i += 4) {
|
|
357
|
+
if (data[i] > 0) {
|
|
358
|
+
const pixelIdx = (i / 4);
|
|
359
|
+
const y = Math.floor(pixelIdx / canvas.width);
|
|
360
|
+
const x = pixelIdx % canvas.width;
|
|
361
|
+
minX = Math.min(minX, x);
|
|
362
|
+
maxX = Math.max(maxX, x);
|
|
363
|
+
minY = Math.min(minY, y);
|
|
364
|
+
maxY = Math.max(maxY, y);
|
|
365
|
+
found = true;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return found ? { x: minX, y: minY, width: maxX - minX, height: maxY - minY } : null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ============================================================================
|
|
373
|
+
// AI VISION ANALYSIS (Gemini Flash API)
|
|
374
|
+
// ============================================================================
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Analyze image using Gemini Vision API (optional, requires API key)
|
|
378
|
+
* Falls back to local analysis if API fails
|
|
379
|
+
*/
|
|
380
|
+
async function analyzeImageWithVision(imageDataURL) {
|
|
381
|
+
try {
|
|
382
|
+
// Try Gemini Flash (free via free tier API)
|
|
383
|
+
const apiKey = 'AIzaSyDgH_2KT3GVK3F0KvCzzn7KdK0zFHv-rEA'; // Placeholder - use environment
|
|
384
|
+
if (!apiKey) throw new Error('No API key');
|
|
385
|
+
|
|
386
|
+
const base64 = imageDataURL.split(',')[1];
|
|
387
|
+
const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', {
|
|
388
|
+
method: 'POST',
|
|
389
|
+
headers: { 'Content-Type': 'application/json' },
|
|
390
|
+
body: JSON.stringify({
|
|
391
|
+
contents: [{
|
|
392
|
+
parts: [
|
|
393
|
+
{ text: 'Analyze this technical drawing or part image. Describe: 1) Shape type (cylinder, box, sphere, cone, bracket, flange, gear, tube) 2) Estimated dimensions (height, width, diameter, thickness) 3) Features (holes, threads, fillets) 4) Material appearance. Be concise.' },
|
|
394
|
+
{ inlineData: { mimeType: 'image/jpeg', data: base64 } },
|
|
395
|
+
],
|
|
396
|
+
}],
|
|
397
|
+
}),
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const result = await response.json();
|
|
401
|
+
const text = result.contents[0].parts[0].text;
|
|
402
|
+
return parseVisionResponse(text);
|
|
403
|
+
} catch (e) {
|
|
404
|
+
console.log('Vision API unavailable, using local analysis:', e.message);
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Parse Gemini Vision response
|
|
411
|
+
*/
|
|
412
|
+
function parseVisionResponse(text) {
|
|
413
|
+
const result = {
|
|
414
|
+
shapeTypes: [],
|
|
415
|
+
dimensions: {},
|
|
416
|
+
features: [],
|
|
417
|
+
material: 'unknown',
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// Extract shape type
|
|
421
|
+
const shapeMatch = text.match(/cylinder|box|sphere|cone|bracket|flange|gear|tube/gi);
|
|
422
|
+
if (shapeMatch) {
|
|
423
|
+
result.shapeTypes = [...new Set(shapeMatch.map(s => s.toLowerCase()))];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Extract dimensions
|
|
427
|
+
const dimMatches = text.match(/(\w+):\s*(\d+(?:\.\d+)?)\s*(mm|cm|inches?|px)?/gi);
|
|
428
|
+
if (dimMatches) {
|
|
429
|
+
dimMatches.forEach((match) => {
|
|
430
|
+
const [, key, value] = match.match(/(\w+):\s*(\d+(?:\.\d+)?)/);
|
|
431
|
+
result.dimensions[key.toLowerCase()] = parseFloat(value);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Extract features
|
|
436
|
+
const features = ['hole', 'thread', 'fillet', 'chamfer', 'pocket', 'boss', 'slot', 'groove'];
|
|
437
|
+
features.forEach((f) => {
|
|
438
|
+
if (text.toLowerCase().includes(f)) result.features.push(f);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// Material guess
|
|
442
|
+
if (text.toLowerCase().includes('stainless')) result.material = 'stainless-steel';
|
|
443
|
+
else if (text.toLowerCase().includes('aluminum')) result.material = 'aluminum';
|
|
444
|
+
else if (text.toLowerCase().includes('plastic')) result.material = 'abs';
|
|
445
|
+
else if (text.toLowerCase().includes('brass')) result.material = 'brass';
|
|
446
|
+
|
|
447
|
+
return result;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ============================================================================
|
|
451
|
+
// 3D GEOMETRY GENERATION
|
|
452
|
+
// ============================================================================
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Generate 3D geometry from detected shape
|
|
456
|
+
*/
|
|
457
|
+
function generateGeometry(shapeType, dimensions) {
|
|
458
|
+
const geo = {};
|
|
459
|
+
const dim = { ...dimensions, diameter: dimensions.diameter || dimensions.width || 50 };
|
|
460
|
+
|
|
461
|
+
switch (shapeType) {
|
|
462
|
+
case 'cylinder':
|
|
463
|
+
geo.geometry = new THREE.CylinderGeometry(
|
|
464
|
+
dim.diameter / 2, dim.diameter / 2, dim.height || dim.diameter, 32
|
|
465
|
+
);
|
|
466
|
+
geo.sliders = {
|
|
467
|
+
diameter: { min: 10, max: 200, value: dim.diameter, unit: 'mm' },
|
|
468
|
+
height: { min: 10, max: 300, value: dim.height || dim.diameter, unit: 'mm' },
|
|
469
|
+
};
|
|
470
|
+
break;
|
|
471
|
+
|
|
472
|
+
case 'box':
|
|
473
|
+
geo.geometry = new THREE.BoxGeometry(
|
|
474
|
+
dim.width || 60, dim.height || 40, dim.depth || 80
|
|
475
|
+
);
|
|
476
|
+
geo.sliders = {
|
|
477
|
+
width: { min: 10, max: 200, value: dim.width || 60, unit: 'mm' },
|
|
478
|
+
height: { min: 10, max: 200, value: dim.height || 40, unit: 'mm' },
|
|
479
|
+
depth: { min: 10, max: 200, value: dim.depth || 80, unit: 'mm' },
|
|
480
|
+
};
|
|
481
|
+
break;
|
|
482
|
+
|
|
483
|
+
case 'sphere':
|
|
484
|
+
geo.geometry = new THREE.SphereGeometry(dim.diameter / 2, 32, 32);
|
|
485
|
+
geo.sliders = {
|
|
486
|
+
diameter: { min: 10, max: 200, value: dim.diameter, unit: 'mm' },
|
|
487
|
+
};
|
|
488
|
+
break;
|
|
489
|
+
|
|
490
|
+
case 'cone':
|
|
491
|
+
geo.geometry = new THREE.ConeGeometry(
|
|
492
|
+
dim.diameter / 2, dim.height || dim.diameter, 32
|
|
493
|
+
);
|
|
494
|
+
geo.sliders = {
|
|
495
|
+
diameter: { min: 10, max: 200, value: dim.diameter, unit: 'mm' },
|
|
496
|
+
height: { min: 10, max: 300, value: dim.height || dim.diameter, unit: 'mm' },
|
|
497
|
+
};
|
|
498
|
+
break;
|
|
499
|
+
|
|
500
|
+
case 'tube':
|
|
501
|
+
geo.geometry = new THREE.CylinderGeometry(
|
|
502
|
+
dim.outerDiameter / 2, dim.outerDiameter / 2,
|
|
503
|
+
dim.height || dim.outerDiameter, 32, 1, true
|
|
504
|
+
);
|
|
505
|
+
geo.sliders = {
|
|
506
|
+
outerDiameter: { min: 10, max: 200, value: dim.outerDiameter || 60, unit: 'mm' },
|
|
507
|
+
innerDiameter: { min: 5, max: 150, value: dim.innerDiameter || 40, unit: 'mm' },
|
|
508
|
+
height: { min: 10, max: 300, value: dim.height || 80, unit: 'mm' },
|
|
509
|
+
};
|
|
510
|
+
break;
|
|
511
|
+
|
|
512
|
+
case 'bracket':
|
|
513
|
+
// L-shaped bracket
|
|
514
|
+
const bx = new THREE.BoxGeometry(dim.width || 40, 10, dim.depth || 40);
|
|
515
|
+
const by = new THREE.BoxGeometry(10, dim.height || 60, dim.depth || 40);
|
|
516
|
+
bx.translate((dim.width || 40) / 4, 0, 0);
|
|
517
|
+
by.translate(0, (dim.height || 60) / 2, 0);
|
|
518
|
+
const bracketGeo = mergeGeometries([bx, by]);
|
|
519
|
+
geo.geometry = bracketGeo;
|
|
520
|
+
geo.sliders = {
|
|
521
|
+
width: { min: 10, max: 100, value: dim.width || 40, unit: 'mm' },
|
|
522
|
+
height: { min: 10, max: 150, value: dim.height || 60, unit: 'mm' },
|
|
523
|
+
depth: { min: 10, max: 100, value: dim.depth || 40, unit: 'mm' },
|
|
524
|
+
thickness: { min: 5, max: 30, value: 10, unit: 'mm' },
|
|
525
|
+
};
|
|
526
|
+
break;
|
|
527
|
+
|
|
528
|
+
case 'flange':
|
|
529
|
+
const flangeGeo = new THREE.CylinderGeometry(dim.outerDiameter / 2, dim.outerDiameter / 2, 5, 32);
|
|
530
|
+
geo.geometry = flangeGeo;
|
|
531
|
+
geo.sliders = {
|
|
532
|
+
outerDiameter: { min: 20, max: 200, value: dim.outerDiameter || 100, unit: 'mm' },
|
|
533
|
+
borediameter: { min: 10, max: 100, value: dim.borediameter || 20, unit: 'mm' },
|
|
534
|
+
thickness: { min: 2, max: 20, value: 5, unit: 'mm' },
|
|
535
|
+
holeCount: { min: 3, max: 12, value: 4, unit: '' },
|
|
536
|
+
};
|
|
537
|
+
break;
|
|
538
|
+
|
|
539
|
+
case 'gear':
|
|
540
|
+
geo.geometry = generateGearGeometry(dim.outerDiameter / 2 || 25, 20, 5);
|
|
541
|
+
geo.sliders = {
|
|
542
|
+
outerDiameter: { min: 20, max: 200, value: dim.outerDiameter || 50, unit: 'mm' },
|
|
543
|
+
teeth: { min: 12, max: 100, value: 20, unit: '' },
|
|
544
|
+
toothDepth: { min: 1, max: 20, value: 5, unit: 'mm' },
|
|
545
|
+
};
|
|
546
|
+
break;
|
|
547
|
+
|
|
548
|
+
default:
|
|
549
|
+
geo.geometry = new THREE.BoxGeometry(50, 40, 60);
|
|
550
|
+
geo.sliders = { width: { min: 10, max: 200, value: 50, unit: 'mm' } };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return geo;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Generate gear tooth geometry
|
|
558
|
+
*/
|
|
559
|
+
function generateGearGeometry(radius, teeth, toothDepth) {
|
|
560
|
+
const geometry = new THREE.BufferGeometry();
|
|
561
|
+
const vertices = [];
|
|
562
|
+
const indices = [];
|
|
563
|
+
|
|
564
|
+
const toothAngle = (Math.PI * 2) / teeth;
|
|
565
|
+
const outerRadius = radius;
|
|
566
|
+
const innerRadius = radius - toothDepth;
|
|
567
|
+
|
|
568
|
+
// Create gear profile
|
|
569
|
+
for (let i = 0; i < teeth; i++) {
|
|
570
|
+
const a1 = i * toothAngle;
|
|
571
|
+
const a2 = a1 + toothAngle * 0.4;
|
|
572
|
+
const a3 = a1 + toothAngle * 0.6;
|
|
573
|
+
const a4 = (i + 1) * toothAngle;
|
|
574
|
+
|
|
575
|
+
// Outer points (teeth)
|
|
576
|
+
vertices.push(
|
|
577
|
+
outerRadius * Math.cos(a2), 0, outerRadius * Math.sin(a2),
|
|
578
|
+
outerRadius * Math.cos(a3), 0, outerRadius * Math.sin(a3)
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
// Inner points (roots)
|
|
582
|
+
vertices.push(
|
|
583
|
+
innerRadius * Math.cos(a1), 0, innerRadius * Math.sin(a1),
|
|
584
|
+
innerRadius * Math.cos(a4), 0, innerRadius * Math.sin(a4)
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Build index
|
|
589
|
+
for (let i = 0; i < vertices.length / 3; i++) {
|
|
590
|
+
indices.push(i, (i + 1) % (vertices.length / 3));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3));
|
|
594
|
+
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));
|
|
595
|
+
geometry.computeVertexNormals();
|
|
596
|
+
|
|
597
|
+
return new THREE.LatheGeometry(
|
|
598
|
+
new THREE.LineCurve3(
|
|
599
|
+
new THREE.Vector3(0, -2.5, 0),
|
|
600
|
+
new THREE.Vector3(0, 2.5, 0)
|
|
601
|
+
).points, 32
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Merge multiple geometries
|
|
607
|
+
*/
|
|
608
|
+
function mergeGeometries(geoArray) {
|
|
609
|
+
const merged = new THREE.BufferGeometry();
|
|
610
|
+
let vertexOffset = 0;
|
|
611
|
+
const positions = [];
|
|
612
|
+
const indices = [];
|
|
613
|
+
|
|
614
|
+
geoArray.forEach((geo) => {
|
|
615
|
+
const pos = geo.getAttribute('position');
|
|
616
|
+
const idx = geo.getIndex();
|
|
617
|
+
|
|
618
|
+
for (let i = 0; i < pos.count; i++) {
|
|
619
|
+
positions.push(pos.getX(i), pos.getY(i), pos.getZ(i));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (idx) {
|
|
623
|
+
for (let i = 0; i < idx.count; i++) {
|
|
624
|
+
indices.push(idx.getX(i) + vertexOffset);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
vertexOffset += pos.count;
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
merged.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
|
|
632
|
+
if (indices.length > 0) {
|
|
633
|
+
merged.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));
|
|
634
|
+
}
|
|
635
|
+
merged.computeVertexNormals();
|
|
636
|
+
|
|
637
|
+
return merged;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ============================================================================
|
|
641
|
+
// PARAMETRIC SLIDER SYSTEM (REAL-TIME UPDATES)
|
|
642
|
+
// ============================================================================
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Create parametric sliders for geometry
|
|
646
|
+
*/
|
|
647
|
+
function createSliders(sliderDef) {
|
|
648
|
+
state.parametricSliders = {};
|
|
649
|
+
state.sliderHistory = [{ sliders: { ...sliderDef } }];
|
|
650
|
+
state.historyIndex = 0;
|
|
651
|
+
|
|
652
|
+
const sliderContainer = document.createElement('div');
|
|
653
|
+
sliderContainer.className = 'slider-container';
|
|
654
|
+
sliderContainer.style.cssText = 'display:flex; flex-direction:column; gap:12px; margin-top:16px; max-height:300px; overflow-y:auto;';
|
|
655
|
+
|
|
656
|
+
Object.entries(sliderDef).forEach(([name, def]) => {
|
|
657
|
+
const sliderGroup = document.createElement('div');
|
|
658
|
+
sliderGroup.style.cssText = 'display:flex; flex-direction:column; gap:6px;';
|
|
659
|
+
|
|
660
|
+
const label = document.createElement('label');
|
|
661
|
+
label.textContent = `${name}: ${def.value.toFixed(1)} ${def.unit}`;
|
|
662
|
+
label.style.cssText = 'font-size:12px; font-weight:bold; color:#ccc;';
|
|
663
|
+
|
|
664
|
+
const slider = document.createElement('input');
|
|
665
|
+
slider.type = 'range';
|
|
666
|
+
slider.min = def.min;
|
|
667
|
+
slider.max = def.max;
|
|
668
|
+
slider.step = (def.max - def.min) / 100;
|
|
669
|
+
slider.value = def.value;
|
|
670
|
+
slider.style.cssText = 'cursor:pointer; width:100%;';
|
|
671
|
+
|
|
672
|
+
slider.addEventListener('input', (e) => {
|
|
673
|
+
const newValue = parseFloat(e.target.value);
|
|
674
|
+
state.parametricSliders[name] = newValue;
|
|
675
|
+
label.textContent = `${name}: ${newValue.toFixed(1)} ${def.unit}`;
|
|
676
|
+
updateGeometryFromSliders();
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
slider.addEventListener('change', () => {
|
|
680
|
+
pushSliderHistory();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
sliderGroup.appendChild(label);
|
|
684
|
+
sliderGroup.appendChild(slider);
|
|
685
|
+
sliderContainer.appendChild(sliderGroup);
|
|
686
|
+
|
|
687
|
+
state.parametricSliders[name] = def.value;
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
return sliderContainer;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Update 3D geometry in real-time as sliders change
|
|
695
|
+
*/
|
|
696
|
+
function updateGeometryFromSliders() {
|
|
697
|
+
if (!state.detectedGeometry || !state.currentModelGroup) return;
|
|
698
|
+
|
|
699
|
+
const sliders = state.parametricSliders;
|
|
700
|
+
let newGeo;
|
|
701
|
+
|
|
702
|
+
switch (state.detectedGeometry.type) {
|
|
703
|
+
case 'cylinder':
|
|
704
|
+
newGeo = new THREE.CylinderGeometry(
|
|
705
|
+
sliders.diameter / 2, sliders.diameter / 2, sliders.height, 32
|
|
706
|
+
);
|
|
707
|
+
break;
|
|
708
|
+
case 'box':
|
|
709
|
+
newGeo = new THREE.BoxGeometry(sliders.width, sliders.height, sliders.depth);
|
|
710
|
+
break;
|
|
711
|
+
case 'sphere':
|
|
712
|
+
newGeo = new THREE.SphereGeometry(sliders.diameter / 2, 32, 32);
|
|
713
|
+
break;
|
|
714
|
+
case 'cone':
|
|
715
|
+
newGeo = new THREE.ConeGeometry(sliders.diameter / 2, sliders.height, 32);
|
|
716
|
+
break;
|
|
717
|
+
case 'tube':
|
|
718
|
+
newGeo = new THREE.CylinderGeometry(
|
|
719
|
+
sliders.outerDiameter / 2, sliders.outerDiameter / 2,
|
|
720
|
+
sliders.height, 32, 1, true
|
|
721
|
+
);
|
|
722
|
+
break;
|
|
723
|
+
default:
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Replace geometry on existing mesh
|
|
728
|
+
state.currentModelGroup.children.forEach((mesh) => {
|
|
729
|
+
if (mesh.geometry) mesh.geometry.dispose();
|
|
730
|
+
mesh.geometry = newGeo;
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
// Trigger render
|
|
734
|
+
if (state.renderer) state.renderer.render(state.scene, state.camera);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Push slider state to undo history
|
|
739
|
+
*/
|
|
740
|
+
function pushSliderHistory() {
|
|
741
|
+
state.historyIndex++;
|
|
742
|
+
state.sliderHistory = state.sliderHistory.slice(0, state.historyIndex);
|
|
743
|
+
state.sliderHistory.push({ sliders: { ...state.parametricSliders } });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Undo slider changes
|
|
748
|
+
*/
|
|
749
|
+
function undoSliders() {
|
|
750
|
+
if (state.historyIndex > 0) {
|
|
751
|
+
state.historyIndex--;
|
|
752
|
+
const prev = state.sliderHistory[state.historyIndex].sliders;
|
|
753
|
+
Object.assign(state.parametricSliders, prev);
|
|
754
|
+
updateGeometryFromSliders();
|
|
755
|
+
updateSliderUI();
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Redo slider changes
|
|
761
|
+
*/
|
|
762
|
+
function redoSliders() {
|
|
763
|
+
if (state.historyIndex < state.sliderHistory.length - 1) {
|
|
764
|
+
state.historyIndex++;
|
|
765
|
+
const next = state.sliderHistory[state.historyIndex].sliders;
|
|
766
|
+
Object.assign(state.parametricSliders, next);
|
|
767
|
+
updateGeometryFromSliders();
|
|
768
|
+
updateSliderUI();
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Update slider UI to match current state
|
|
774
|
+
*/
|
|
775
|
+
function updateSliderUI() {
|
|
776
|
+
const sliders = document.querySelectorAll('.slider-container input[type="range"]');
|
|
777
|
+
sliders.forEach((slider) => {
|
|
778
|
+
const name = slider.previousElementSibling?.textContent?.split(':')[0];
|
|
779
|
+
if (state.parametricSliders[name]) {
|
|
780
|
+
slider.value = state.parametricSliders[name];
|
|
781
|
+
slider.previousElementSibling.textContent =
|
|
782
|
+
`${name}: ${state.parametricSliders[name].toFixed(1)}`;
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ============================================================================
|
|
788
|
+
// SKETCH-TO-CAD (HAND-DRAWN SHAPE RECOGNITION)
|
|
789
|
+
// ============================================================================
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Initialize sketch canvas for hand-drawn input
|
|
793
|
+
*/
|
|
794
|
+
function initSketchCanvas(container) {
|
|
795
|
+
const sketchContainer = document.createElement('div');
|
|
796
|
+
sketchContainer.style.cssText = 'border:1px solid #666; border-radius:4px; overflow:hidden;';
|
|
797
|
+
|
|
798
|
+
const canvas = document.createElement('canvas');
|
|
799
|
+
canvas.width = 400;
|
|
800
|
+
canvas.height = 400;
|
|
801
|
+
canvas.style.cssText = 'display:block; background:#1a1a1a; cursor:crosshair;';
|
|
802
|
+
|
|
803
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
804
|
+
|
|
805
|
+
// Drawing state
|
|
806
|
+
let isDrawing = false;
|
|
807
|
+
let lastX = 0, lastY = 0;
|
|
808
|
+
|
|
809
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
810
|
+
isDrawing = true;
|
|
811
|
+
const rect = canvas.getBoundingClientRect();
|
|
812
|
+
lastX = e.clientX - rect.left;
|
|
813
|
+
lastY = e.clientY - rect.top;
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
canvas.addEventListener('pointermove', (e) => {
|
|
817
|
+
if (!isDrawing) return;
|
|
818
|
+
const rect = canvas.getBoundingClientRect();
|
|
819
|
+
const x = e.clientX - rect.left;
|
|
820
|
+
const y = e.clientY - rect.top;
|
|
821
|
+
|
|
822
|
+
ctx.strokeStyle = '#00ff00';
|
|
823
|
+
ctx.lineWidth = 2;
|
|
824
|
+
ctx.lineJoin = 'round';
|
|
825
|
+
ctx.lineCap = 'round';
|
|
826
|
+
ctx.beginPath();
|
|
827
|
+
ctx.moveTo(lastX, lastY);
|
|
828
|
+
ctx.lineTo(x, y);
|
|
829
|
+
ctx.stroke();
|
|
830
|
+
|
|
831
|
+
lastX = x;
|
|
832
|
+
lastY = y;
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
canvas.addEventListener('pointerup', () => {
|
|
836
|
+
isDrawing = false;
|
|
837
|
+
recognizeSketch(canvas);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
// Add clear button
|
|
841
|
+
const clearBtn = document.createElement('button');
|
|
842
|
+
clearBtn.textContent = 'Clear';
|
|
843
|
+
clearBtn.style.cssText = 'margin-top:8px; padding:6px 12px; background:#444; color:#fff; border:none; border-radius:4px; cursor:pointer;';
|
|
844
|
+
clearBtn.addEventListener('click', () => {
|
|
845
|
+
ctx.fillStyle = '#1a1a1a';
|
|
846
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
sketchContainer.appendChild(canvas);
|
|
850
|
+
sketchContainer.appendChild(clearBtn);
|
|
851
|
+
container.appendChild(sketchContainer);
|
|
852
|
+
|
|
853
|
+
return canvas;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Recognize drawn shapes from sketch canvas
|
|
858
|
+
*/
|
|
859
|
+
function recognizeSketch(canvas) {
|
|
860
|
+
const imageData = canvas.getContext('2d', { willReadFrequently: true }).getImageData(0, 0, canvas.width, canvas.height);
|
|
861
|
+
const shapes = [];
|
|
862
|
+
|
|
863
|
+
// Run Hough transform on sketch pixels
|
|
864
|
+
const hough = houghTransform(imageData);
|
|
865
|
+
|
|
866
|
+
if (hough.circles.length > 0) {
|
|
867
|
+
const circle = hough.circles[0];
|
|
868
|
+
shapes.push({ type: 'circle', cx: circle.cx, cy: circle.cy, radius: circle.radius });
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (hough.lines.length >= 4) {
|
|
872
|
+
shapes.push({ type: 'rectangle' });
|
|
873
|
+
} else if (hough.lines.length >= 2) {
|
|
874
|
+
shapes.push({ type: 'line' });
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return shapes;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ============================================================================
|
|
881
|
+
// 3D PREVIEW & RENDERING
|
|
882
|
+
// ============================================================================
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Update preview image display
|
|
886
|
+
*/
|
|
887
|
+
function updatePreview(img) {
|
|
888
|
+
const preview = document.querySelector('#image-preview');
|
|
889
|
+
if (preview) {
|
|
890
|
+
preview.innerHTML = '';
|
|
891
|
+
const canvas = document.createElement('canvas');
|
|
892
|
+
canvas.width = 300;
|
|
893
|
+
canvas.height = 300;
|
|
894
|
+
const ctx = canvas.getContext('2d');
|
|
895
|
+
ctx.drawImage(img, 0, 0, 300, 300);
|
|
896
|
+
preview.appendChild(canvas);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Render mesh in 3D viewport
|
|
902
|
+
*/
|
|
903
|
+
function renderMeshIn3D(mesh) {
|
|
904
|
+
if (!state.scene) return;
|
|
905
|
+
|
|
906
|
+
// Remove old mesh
|
|
907
|
+
if (state.currentModelGroup) {
|
|
908
|
+
state.scene.remove(state.currentModelGroup);
|
|
909
|
+
state.currentModelGroup.children.forEach((child) => {
|
|
910
|
+
if (child.geometry) child.geometry.dispose();
|
|
911
|
+
if (child.material) child.material.dispose();
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Create new group
|
|
916
|
+
state.currentModelGroup = new THREE.Group();
|
|
917
|
+
state.currentModelGroup.add(mesh);
|
|
918
|
+
state.scene.add(state.currentModelGroup);
|
|
919
|
+
|
|
920
|
+
// Fit to view
|
|
921
|
+
const box = new THREE.Box3().setFromObject(state.currentModelGroup);
|
|
922
|
+
const size = box.getSize(new THREE.Vector3());
|
|
923
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
924
|
+
const fov = state.camera ? state.camera.fov * (Math.PI / 180) : 75;
|
|
925
|
+
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
|
|
926
|
+
cameraZ *= 1.5;
|
|
927
|
+
|
|
928
|
+
if (state.camera) {
|
|
929
|
+
state.camera.position.z = cameraZ;
|
|
930
|
+
state.camera.lookAt(state.scene.position);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (state.renderer) {
|
|
934
|
+
state.renderer.render(state.scene, state.camera);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// ============================================================================
|
|
939
|
+
// MAIN IMAGE ANALYSIS PIPELINE
|
|
940
|
+
// ============================================================================
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Main analysis function (called after image upload)
|
|
944
|
+
*/
|
|
945
|
+
async function analyzeImage(img) {
|
|
946
|
+
const canvas = preprocessImage(img);
|
|
947
|
+
const edgeData = sobelEdgeDetection(canvas);
|
|
948
|
+
const houghData = houghTransform(edgeData);
|
|
949
|
+
|
|
950
|
+
// Detect shape
|
|
951
|
+
const detections = detectShapeType(canvas, edgeData, houghData);
|
|
952
|
+
state.detectedGeometry = detections[0];
|
|
953
|
+
|
|
954
|
+
console.log('Detected shapes:', detections);
|
|
955
|
+
|
|
956
|
+
// Try Vision API analysis
|
|
957
|
+
const visionData = await analyzeImageWithVision(state.currentImage.src);
|
|
958
|
+
|
|
959
|
+
// Generate geometry
|
|
960
|
+
const dimensions = visionData?.dimensions || { diameter: 50, height: 80, width: 60, depth: 60 };
|
|
961
|
+
const geo = generateGeometry(state.detectedGeometry.type, dimensions);
|
|
962
|
+
|
|
963
|
+
// Create mesh
|
|
964
|
+
const material = new THREE.MeshPhongMaterial({ color: 0x2563eb, shininess: 100 });
|
|
965
|
+
const mesh = new THREE.Mesh(geo.geometry, material);
|
|
966
|
+
|
|
967
|
+
// Render
|
|
968
|
+
renderMeshIn3D(mesh);
|
|
969
|
+
|
|
970
|
+
// Create sliders
|
|
971
|
+
return createSliders(geo.sliders);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ============================================================================
|
|
975
|
+
// EXPORT & CONVERSION
|
|
976
|
+
// ============================================================================
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Export to STL format
|
|
980
|
+
*/
|
|
981
|
+
function exportToSTL(filename = 'model.stl') {
|
|
982
|
+
if (!state.currentModelGroup) return;
|
|
983
|
+
|
|
984
|
+
let stl = 'solid model\n';
|
|
985
|
+
|
|
986
|
+
state.currentModelGroup.children.forEach((mesh) => {
|
|
987
|
+
const geometry = mesh.geometry;
|
|
988
|
+
const pos = geometry.getAttribute('position');
|
|
989
|
+
const index = geometry.getIndex();
|
|
990
|
+
|
|
991
|
+
if (index) {
|
|
992
|
+
for (let i = 0; i < index.count; i += 3) {
|
|
993
|
+
const a = index.getX(i);
|
|
994
|
+
const b = index.getX(i + 1);
|
|
995
|
+
const c = index.getX(i + 2);
|
|
996
|
+
|
|
997
|
+
const v0 = new THREE.Vector3(pos.getX(a), pos.getY(a), pos.getZ(a));
|
|
998
|
+
const v1 = new THREE.Vector3(pos.getX(b), pos.getY(b), pos.getZ(b));
|
|
999
|
+
const v2 = new THREE.Vector3(pos.getX(c), pos.getY(c), pos.getZ(c));
|
|
1000
|
+
|
|
1001
|
+
const n = new THREE.Vector3();
|
|
1002
|
+
v1.sub(v0);
|
|
1003
|
+
v2.sub(v0);
|
|
1004
|
+
n.crossVectors(v1, v2).normalize();
|
|
1005
|
+
|
|
1006
|
+
stl += ` facet normal ${n.x} ${n.y} ${n.z}\n`;
|
|
1007
|
+
stl += ` outer loop\n`;
|
|
1008
|
+
stl += ` vertex ${v0.x} ${v0.y} ${v0.z}\n`;
|
|
1009
|
+
stl += ` vertex ${v1.x} ${v1.y} ${v1.z}\n`;
|
|
1010
|
+
stl += ` vertex ${v2.x} ${v2.y} ${v2.z}\n`;
|
|
1011
|
+
stl += ` endloop\n`;
|
|
1012
|
+
stl += ` endfacet\n`;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
stl += 'endsolid model\n';
|
|
1018
|
+
|
|
1019
|
+
const blob = new Blob([stl], { type: 'text/plain' });
|
|
1020
|
+
const url = URL.createObjectURL(blob);
|
|
1021
|
+
const a = document.createElement('a');
|
|
1022
|
+
a.href = url;
|
|
1023
|
+
a.download = filename;
|
|
1024
|
+
a.click();
|
|
1025
|
+
URL.revokeObjectURL(url);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Export to JSON (cycleCAD format)
|
|
1030
|
+
*/
|
|
1031
|
+
function exportToJSON(filename = 'model.json') {
|
|
1032
|
+
if (!state.detectedGeometry) return;
|
|
1033
|
+
|
|
1034
|
+
const data = {
|
|
1035
|
+
type: 'ImageToCAD',
|
|
1036
|
+
shape: state.detectedGeometry.type,
|
|
1037
|
+
parameters: state.parametricSliders,
|
|
1038
|
+
timestamp: new Date().toISOString(),
|
|
1039
|
+
version: '1.0',
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
1043
|
+
const url = URL.createObjectURL(blob);
|
|
1044
|
+
const a = document.createElement('a');
|
|
1045
|
+
a.href = url;
|
|
1046
|
+
a.download = filename;
|
|
1047
|
+
a.click();
|
|
1048
|
+
URL.revokeObjectURL(url);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// ============================================================================
|
|
1052
|
+
// MODULE API
|
|
1053
|
+
// ============================================================================
|
|
1054
|
+
|
|
1055
|
+
const module = {
|
|
1056
|
+
/**
|
|
1057
|
+
* Initialize module with Three.js scene/renderer
|
|
1058
|
+
*/
|
|
1059
|
+
init(scene, renderer, camera) {
|
|
1060
|
+
state.scene = scene;
|
|
1061
|
+
state.renderer = renderer;
|
|
1062
|
+
state.camera = camera;
|
|
1063
|
+
},
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Get module UI as HTML string
|
|
1067
|
+
*/
|
|
1068
|
+
getUI() {
|
|
1069
|
+
const html = `
|
|
1070
|
+
<div class="image-to-cad-panel" style="display:flex; flex-direction:column; gap:16px; padding:16px; max-height:80vh; overflow-y:auto; font-family:monospace; font-size:12px; color:#ccc;">
|
|
1071
|
+
<div style="font-weight:bold; font-size:14px; color:#fff;">📷 Image-to-CAD Converter</div>
|
|
1072
|
+
|
|
1073
|
+
<div id="image-upload-zone"></div>
|
|
1074
|
+
|
|
1075
|
+
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
|
1076
|
+
<div>
|
|
1077
|
+
<div style="font-weight:bold; margin-bottom:8px; color:#00ff00;">Image Preview</div>
|
|
1078
|
+
<div id="image-preview" style="border:1px solid #444; border-radius:4px; aspect-ratio:1; background:#0a0a0a; display:flex; align-items:center; justify-content:center; color:#666;">Upload image</div>
|
|
1079
|
+
</div>
|
|
1080
|
+
|
|
1081
|
+
<div>
|
|
1082
|
+
<div style="font-weight:bold; margin-bottom:8px; color:#00ff00;">3D Preview</div>
|
|
1083
|
+
<div id="geometry-preview" style="border:1px solid #444; border-radius:4px; aspect-ratio:1; background:#0a0a0a;"></div>
|
|
1084
|
+
</div>
|
|
1085
|
+
</div>
|
|
1086
|
+
|
|
1087
|
+
<div>
|
|
1088
|
+
<div style="font-weight:bold; margin-bottom:8px; color:#00ff00;">Parametric Controls</div>
|
|
1089
|
+
<div id="slider-controls"></div>
|
|
1090
|
+
</div>
|
|
1091
|
+
|
|
1092
|
+
<div style="display:flex; gap:8px;">
|
|
1093
|
+
<button data-action="image-undo" style="flex:1; padding:8px; background:#444; color:#fff; border:1px solid #666; border-radius:4px; cursor:pointer; font-size:11px;">↶ Undo</button>
|
|
1094
|
+
<button data-action="image-redo" style="flex:1; padding:8px; background:#444; color:#fff; border:1px solid #666; border-radius:4px; cursor:pointer; font-size:11px;">↷ Redo</button>
|
|
1095
|
+
</div>
|
|
1096
|
+
|
|
1097
|
+
<div style="display:flex; gap:8px;">
|
|
1098
|
+
<button data-action="image-export-stl" style="flex:1; padding:8px; background:#2563eb; color:#fff; border:none; border-radius:4px; cursor:pointer; font-size:11px; font-weight:bold;">Export STL</button>
|
|
1099
|
+
<button data-action="image-export-json" style="flex:1; padding:8px; background:#2563eb; color:#fff; border:none; border-radius:4px; cursor:pointer; font-size:11px; font-weight:bold;">Export JSON</button>
|
|
1100
|
+
</div>
|
|
1101
|
+
|
|
1102
|
+
<div style="font-size:10px; color:#666; border-top:1px solid #333; padding-top:8px;">
|
|
1103
|
+
<div style="font-weight:bold; margin-bottom:4px;">Detection: ${state.detectedGeometry?.type || 'none'}</div>
|
|
1104
|
+
<div>Confidence: ${(state.detectedGeometry?.confidence * 100 || 0).toFixed(0)}%</div>
|
|
1105
|
+
</div>
|
|
1106
|
+
</div>
|
|
1107
|
+
`;
|
|
1108
|
+
|
|
1109
|
+
return html;
|
|
1110
|
+
},
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Execute module action
|
|
1114
|
+
*/
|
|
1115
|
+
execute(action, params = {}) {
|
|
1116
|
+
switch (action) {
|
|
1117
|
+
case 'uploadImage':
|
|
1118
|
+
handleImageUpload(params.file);
|
|
1119
|
+
break;
|
|
1120
|
+
case 'analyzeImage':
|
|
1121
|
+
analyzeImage(params.image);
|
|
1122
|
+
break;
|
|
1123
|
+
case 'updateParameter':
|
|
1124
|
+
if (state.parametricSliders.hasOwnProperty(params.name)) {
|
|
1125
|
+
state.parametricSliders[params.name] = params.value;
|
|
1126
|
+
updateGeometryFromSliders();
|
|
1127
|
+
}
|
|
1128
|
+
break;
|
|
1129
|
+
case 'undo':
|
|
1130
|
+
undoSliders();
|
|
1131
|
+
break;
|
|
1132
|
+
case 'redo':
|
|
1133
|
+
redoSliders();
|
|
1134
|
+
break;
|
|
1135
|
+
case 'exportSTL':
|
|
1136
|
+
exportToSTL(params.filename || 'model.stl');
|
|
1137
|
+
break;
|
|
1138
|
+
case 'exportJSON':
|
|
1139
|
+
exportToJSON(params.filename || 'model.json');
|
|
1140
|
+
break;
|
|
1141
|
+
default:
|
|
1142
|
+
console.warn('Unknown ImageToCAD action:', action);
|
|
1143
|
+
}
|
|
1144
|
+
},
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Get current parametric sliders
|
|
1148
|
+
*/
|
|
1149
|
+
getSliders() {
|
|
1150
|
+
return { ...state.parametricSliders };
|
|
1151
|
+
},
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Update single parameter
|
|
1155
|
+
*/
|
|
1156
|
+
updateParam(name, value) {
|
|
1157
|
+
if (state.parametricSliders.hasOwnProperty(name)) {
|
|
1158
|
+
state.parametricSliders[name] = value;
|
|
1159
|
+
updateGeometryFromSliders();
|
|
1160
|
+
pushSliderHistory();
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Get detected geometry info
|
|
1166
|
+
*/
|
|
1167
|
+
getDetectedGeometry() {
|
|
1168
|
+
return state.detectedGeometry;
|
|
1169
|
+
},
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Get conversion history
|
|
1173
|
+
*/
|
|
1174
|
+
getHistory() {
|
|
1175
|
+
return [...state.conversionHistory];
|
|
1176
|
+
},
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
// Register on window
|
|
1180
|
+
if (!window.CycleCAD) window.CycleCAD = {};
|
|
1181
|
+
window.CycleCAD.ImageToCAD = module;
|
|
1182
|
+
|
|
1183
|
+
console.log('ImageToCAD module loaded. Usage: window.CycleCAD.ImageToCAD.init(scene, renderer, camera)');
|
|
1184
|
+
})();
|