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