cyclecad 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT_API_IMPLEMENTATION_SUMMARY.md +399 -0
- package/AGENT_API_MANIFEST.md +343 -0
- package/AGENT_API_QUICKSTART.md +316 -0
- package/AGENT_API_WIRING.md +495 -0
- package/CLAUDE.md +120 -8
- package/DELIVERABLES.txt +471 -0
- package/app/agent-demo.html +1990 -1294
- package/app/agent-test.html +486 -0
- package/app/index.html +236 -5
- package/app/js/agent-api.js +953 -98
- package/app/js/viewer-mode.js +899 -0
- package/architecture.html +372 -0
- package/docs/EXPLODEVIEW-FEATURE-MAPPING.md +602 -0
- package/docs/README-VIEWER-MODE-MERGE.md +364 -0
- package/docs/VIEWER-MODE-IMPLEMENTATION-GUIDE.md +412 -0
- package/docs/explodeview-merge-plan.md +476 -0
- package/docs/opencascade-integration.md +1102 -0
- package/linkedin-post.md +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* viewer-mode.js - ExplodeView integration for cycleCAD
|
|
3
|
+
*
|
|
4
|
+
* This module enables Viewer Mode: a presentation/inspection mode for loaded assemblies.
|
|
5
|
+
* Shares the Three.js scene with Edit Mode and provides:
|
|
6
|
+
*
|
|
7
|
+
* - Mode switching (Edit ↔ Viewer)
|
|
8
|
+
* - File loading (STL, OBJ, manifest-based assemblies)
|
|
9
|
+
* - Assembly tree navigation
|
|
10
|
+
* - Part selection and highlighting
|
|
11
|
+
* - Explode/collapse animation
|
|
12
|
+
* - Section cut (clipping planes)
|
|
13
|
+
* - Context menu (select, hide, isolate, export)
|
|
14
|
+
* - Part info panel
|
|
15
|
+
* - State management for viewer-specific data
|
|
16
|
+
*
|
|
17
|
+
* Part of the ExplodeView→cycleCAD merge strategy.
|
|
18
|
+
* Imports THREE.js loaders and viewport scene from existing modules.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
|
|
22
|
+
import { STLLoader } from 'https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/STLLoader.js';
|
|
23
|
+
import { OBJLoader } from 'https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/OBJLoader.js';
|
|
24
|
+
import { GLTFLoader } from 'https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/GLTFLoader.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* ============================================================================
|
|
28
|
+
* MODULE STATE
|
|
29
|
+
* ============================================================================
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
let isViewerMode = false;
|
|
33
|
+
let scene = null;
|
|
34
|
+
let camera = null;
|
|
35
|
+
let renderer = null;
|
|
36
|
+
let controls = null;
|
|
37
|
+
|
|
38
|
+
// Root group for all viewer objects (toggled when switching modes)
|
|
39
|
+
let viewerGroup = null;
|
|
40
|
+
|
|
41
|
+
// Viewer state
|
|
42
|
+
const viewerState = {
|
|
43
|
+
isLoading: false,
|
|
44
|
+
allParts: [], // Array of { mesh, name, index, bbox, center }
|
|
45
|
+
assemblies: [], // Array of assembly definitions { name, indices, color }
|
|
46
|
+
manifest: [], // Array of part metadata
|
|
47
|
+
selectedPartIndex: null,
|
|
48
|
+
selectedMesh: null,
|
|
49
|
+
explodeAmount: 0, // 0-1, how far apart to move parts
|
|
50
|
+
explodedPositions: {}, // Cache of original positions
|
|
51
|
+
hoveredMesh: null,
|
|
52
|
+
sectionCutActive: false,
|
|
53
|
+
sectionCutPlane: null,
|
|
54
|
+
clippingPlanes: [],
|
|
55
|
+
annotationPins: [],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Part highlight state
|
|
59
|
+
const partHighlightState = {
|
|
60
|
+
originalColor: {},
|
|
61
|
+
originalOpacity: {},
|
|
62
|
+
highlighted: null,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Configuration
|
|
66
|
+
const config = {
|
|
67
|
+
highlightColor: 0x00ff00,
|
|
68
|
+
selectionColor: 0xffaa00,
|
|
69
|
+
assemblySeparation: 80, // mm to move parts when exploded
|
|
70
|
+
sectionCutThickness: 2, // mm clipping plane thickness
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* ============================================================================
|
|
75
|
+
* INITIALIZATION
|
|
76
|
+
* ============================================================================
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Initialize the Viewer Mode system
|
|
81
|
+
* @param {Object} viewportExports - { getScene, getCamera, getRenderer, getControls }
|
|
82
|
+
* @returns {Object} Public API
|
|
83
|
+
*/
|
|
84
|
+
export function initViewerMode(viewportExports) {
|
|
85
|
+
scene = viewportExports.getScene();
|
|
86
|
+
camera = viewportExports.getCamera();
|
|
87
|
+
renderer = viewportExports.getRenderer();
|
|
88
|
+
controls = viewportExports.getControls();
|
|
89
|
+
|
|
90
|
+
if (!scene || !camera || !renderer || !controls) {
|
|
91
|
+
throw new Error('initViewerMode: Missing viewport exports');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Create the viewer group to toggle visibility
|
|
95
|
+
viewerGroup = new THREE.Group();
|
|
96
|
+
viewerGroup.name = 'ViewerGroup';
|
|
97
|
+
scene.add(viewerGroup);
|
|
98
|
+
|
|
99
|
+
setupEventListeners();
|
|
100
|
+
setupContextMenu();
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
toggleViewerMode,
|
|
104
|
+
loadFile,
|
|
105
|
+
getViewerState,
|
|
106
|
+
selectPart,
|
|
107
|
+
explodeParts,
|
|
108
|
+
setSectionCut,
|
|
109
|
+
exportBOM,
|
|
110
|
+
addAnnotationPin,
|
|
111
|
+
isInViewerMode,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* ============================================================================
|
|
117
|
+
* MODE SWITCHING
|
|
118
|
+
* ============================================================================
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
function toggleViewerMode(enable) {
|
|
122
|
+
isViewerMode = enable;
|
|
123
|
+
viewerGroup.visible = enable;
|
|
124
|
+
|
|
125
|
+
const modeBtn = document.getElementById('btn-viewer-mode-toggle');
|
|
126
|
+
if (modeBtn) {
|
|
127
|
+
modeBtn.textContent = isViewerMode ? 'Edit Mode' : 'Viewer Mode';
|
|
128
|
+
modeBtn.style.backgroundColor = isViewerMode ? '#ff6600' : '#333';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const rightPanel = document.getElementById('right-panel');
|
|
132
|
+
if (rightPanel) {
|
|
133
|
+
// In viewer mode, show viewer tabs; hide edit tabs
|
|
134
|
+
const viewerTabs = rightPanel.querySelectorAll('[data-viewer-only]');
|
|
135
|
+
const editTabs = rightPanel.querySelectorAll('[data-edit-only]');
|
|
136
|
+
|
|
137
|
+
viewerTabs.forEach(t => t.style.display = isViewerMode ? 'block' : 'none');
|
|
138
|
+
editTabs.forEach(t => t.style.display = !isViewerMode ? 'block' : 'none');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (isViewerMode) {
|
|
142
|
+
// Auto-fit to loaded model
|
|
143
|
+
if (viewerState.allParts.length > 0) {
|
|
144
|
+
fitAllParts();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isInViewerMode() {
|
|
150
|
+
return isViewerMode;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* ============================================================================
|
|
155
|
+
* FILE LOADING
|
|
156
|
+
* ============================================================================
|
|
157
|
+
*/
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Load a file (STL, OBJ, glTF, or manifest-based assembly)
|
|
161
|
+
* @param {File|Blob} file - The file to load
|
|
162
|
+
* @param {Object} options - { manifest, assemblies }
|
|
163
|
+
*/
|
|
164
|
+
export async function loadFile(file, options = {}) {
|
|
165
|
+
viewerState.isLoading = true;
|
|
166
|
+
clearViewer();
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const fileName = file.name.toLowerCase();
|
|
170
|
+
let mesh = null;
|
|
171
|
+
|
|
172
|
+
if (fileName.endsWith('.stl')) {
|
|
173
|
+
mesh = await loadSTL(file);
|
|
174
|
+
} else if (fileName.endsWith('.obj')) {
|
|
175
|
+
mesh = await loadOBJ(file);
|
|
176
|
+
} else if (fileName.endsWith('.gltf') || fileName.endsWith('.glb')) {
|
|
177
|
+
mesh = await loadGLTF(file);
|
|
178
|
+
} else {
|
|
179
|
+
throw new Error(`Unsupported file type: ${fileName}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!mesh) throw new Error('Failed to load model');
|
|
183
|
+
|
|
184
|
+
// If manifest provided, use it to split mesh into parts
|
|
185
|
+
if (options.manifest) {
|
|
186
|
+
processManifestFile(mesh, options.manifest, options.assemblies);
|
|
187
|
+
} else {
|
|
188
|
+
// Single mesh as single part
|
|
189
|
+
addPartToScene(mesh, 'Loaded Model', 0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Enable viewer mode
|
|
193
|
+
toggleViewerMode(true);
|
|
194
|
+
|
|
195
|
+
updateStatus(`Loaded ${file.name} — ${viewerState.allParts.length} parts`);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
updateStatus(`Error loading file: ${err.message}`, 'error');
|
|
198
|
+
console.error(err);
|
|
199
|
+
} finally {
|
|
200
|
+
viewerState.isLoading = false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function loadSTL(file) {
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
const reader = new FileReader();
|
|
207
|
+
reader.onload = (e) => {
|
|
208
|
+
try {
|
|
209
|
+
const loader = new STLLoader();
|
|
210
|
+
const geometry = loader.parse(e.target.result);
|
|
211
|
+
const material = new THREE.MeshStandardMaterial({
|
|
212
|
+
color: 0xcc9955,
|
|
213
|
+
roughness: 0.7,
|
|
214
|
+
metalness: 0.2,
|
|
215
|
+
});
|
|
216
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
217
|
+
centerGeometry(mesh);
|
|
218
|
+
resolve(mesh);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
reject(err);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
224
|
+
reader.readAsArrayBuffer(file);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function loadOBJ(file) {
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
const reader = new FileReader();
|
|
231
|
+
reader.onload = (e) => {
|
|
232
|
+
try {
|
|
233
|
+
const loader = new OBJLoader();
|
|
234
|
+
const object = loader.parse(e.target.result);
|
|
235
|
+
const material = new THREE.MeshStandardMaterial({
|
|
236
|
+
color: 0xcc9955,
|
|
237
|
+
roughness: 0.7,
|
|
238
|
+
metalness: 0.2,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Convert all geometries in object
|
|
242
|
+
object.traverse((child) => {
|
|
243
|
+
if (child.isGeometry || child.geometry) {
|
|
244
|
+
if (!(child.material instanceof THREE.Material)) {
|
|
245
|
+
child.material = material;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
centerGeometry(object);
|
|
251
|
+
resolve(object);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
reject(err);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
257
|
+
reader.readAsText(file);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function loadGLTF(file) {
|
|
262
|
+
return new Promise((resolve, reject) => {
|
|
263
|
+
const reader = new FileReader();
|
|
264
|
+
reader.onload = (e) => {
|
|
265
|
+
try {
|
|
266
|
+
const loader = new GLTFLoader();
|
|
267
|
+
loader.parse(e.target.result, '', (gltf) => {
|
|
268
|
+
const scene = gltf.scene;
|
|
269
|
+
centerGeometry(scene);
|
|
270
|
+
resolve(scene);
|
|
271
|
+
}, reject);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
reject(err);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
277
|
+
|
|
278
|
+
if (file.name.endsWith('.glb')) {
|
|
279
|
+
reader.readAsArrayBuffer(file);
|
|
280
|
+
} else {
|
|
281
|
+
reader.readAsText(file);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* If manifest is provided, build parts array from metadata
|
|
288
|
+
* This matches ExplodeView's structure: array of parts with metadata
|
|
289
|
+
*/
|
|
290
|
+
function processManifestFile(mesh, manifest, assemblies) {
|
|
291
|
+
// TODO: Parse manifest.json to extract:
|
|
292
|
+
// - Part names, centers, bounding boxes
|
|
293
|
+
// - Assembly groupings
|
|
294
|
+
// - Load individual part files if URLs provided
|
|
295
|
+
//
|
|
296
|
+
// For now, treat entire mesh as single part
|
|
297
|
+
addPartToScene(mesh, manifest[0]?.name || 'Part', 0);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* ============================================================================
|
|
302
|
+
* SCENE MANAGEMENT
|
|
303
|
+
* ============================================================================
|
|
304
|
+
*/
|
|
305
|
+
|
|
306
|
+
function addPartToScene(object, partName, index) {
|
|
307
|
+
const bbox = new THREE.Box3().setFromObject(object);
|
|
308
|
+
const center = bbox.getCenter(new THREE.Vector3());
|
|
309
|
+
|
|
310
|
+
const partData = {
|
|
311
|
+
mesh: object,
|
|
312
|
+
name: partName || `Part ${index}`,
|
|
313
|
+
index: index || viewerState.allParts.length,
|
|
314
|
+
bbox: bbox,
|
|
315
|
+
center: center,
|
|
316
|
+
originalCenter: center.clone(),
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
viewerState.allParts.push(partData);
|
|
320
|
+
viewerGroup.add(object);
|
|
321
|
+
|
|
322
|
+
// Cache original position for explode animation
|
|
323
|
+
if (object.isGroup) {
|
|
324
|
+
object.children.forEach((child) => {
|
|
325
|
+
if (child.position) {
|
|
326
|
+
viewerState.explodedPositions[child.uuid] = child.position.clone();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
} else {
|
|
330
|
+
viewerState.explodedPositions[object.uuid] = object.position.clone();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function clearViewer() {
|
|
335
|
+
// Remove all meshes from viewer group
|
|
336
|
+
while (viewerGroup.children.length > 0) {
|
|
337
|
+
viewerGroup.remove(viewerGroup.children[0]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
viewerState.allParts = [];
|
|
341
|
+
viewerState.assemblies = [];
|
|
342
|
+
viewerState.selectedPartIndex = null;
|
|
343
|
+
viewerState.selectedMesh = null;
|
|
344
|
+
viewerState.explodeAmount = 0;
|
|
345
|
+
partHighlightState.highlighted = null;
|
|
346
|
+
viewerState.annotationPins = [];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function centerGeometry(object) {
|
|
350
|
+
const bbox = new THREE.Box3().setFromObject(object);
|
|
351
|
+
const center = bbox.getCenter(new THREE.Vector3());
|
|
352
|
+
|
|
353
|
+
object.traverse((child) => {
|
|
354
|
+
if (child.position) {
|
|
355
|
+
child.position.sub(center);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* ============================================================================
|
|
362
|
+
* PART SELECTION & HIGHLIGHTING
|
|
363
|
+
* ============================================================================
|
|
364
|
+
*/
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Select a part by index
|
|
368
|
+
*/
|
|
369
|
+
export function selectPart(partIndex) {
|
|
370
|
+
if (partIndex < 0 || partIndex >= viewerState.allParts.length) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Clear previous selection
|
|
375
|
+
if (viewerState.selectedMesh) {
|
|
376
|
+
restorePartColor(viewerState.selectedMesh);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const part = viewerState.allParts[partIndex];
|
|
380
|
+
viewerState.selectedPartIndex = partIndex;
|
|
381
|
+
viewerState.selectedMesh = part.mesh;
|
|
382
|
+
|
|
383
|
+
// Highlight selected mesh
|
|
384
|
+
highlightMesh(part.mesh, config.selectionColor, 0.8);
|
|
385
|
+
|
|
386
|
+
// Show part info panel
|
|
387
|
+
showPartInfo(part);
|
|
388
|
+
|
|
389
|
+
updateStatus(`Selected: ${part.name}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function highlightMesh(mesh, color, opacity) {
|
|
393
|
+
if (!mesh) return;
|
|
394
|
+
|
|
395
|
+
if (mesh.isGroup) {
|
|
396
|
+
mesh.children.forEach((child) => {
|
|
397
|
+
if (child.material) {
|
|
398
|
+
partHighlightState.originalColor[child.uuid] = child.material.color.clone();
|
|
399
|
+
partHighlightState.originalOpacity[child.uuid] = child.material.opacity;
|
|
400
|
+
|
|
401
|
+
child.material.color.setHex(color);
|
|
402
|
+
child.material.opacity = opacity;
|
|
403
|
+
child.material.transparent = true;
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
} else if (mesh.material) {
|
|
407
|
+
partHighlightState.originalColor[mesh.uuid] = mesh.material.color.clone();
|
|
408
|
+
partHighlightState.originalOpacity[mesh.uuid] = mesh.material.opacity;
|
|
409
|
+
|
|
410
|
+
mesh.material.color.setHex(color);
|
|
411
|
+
mesh.material.opacity = opacity;
|
|
412
|
+
mesh.material.transparent = true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function restorePartColor(mesh) {
|
|
417
|
+
if (!mesh) return;
|
|
418
|
+
|
|
419
|
+
if (mesh.isGroup) {
|
|
420
|
+
mesh.children.forEach((child) => {
|
|
421
|
+
if (child.material && partHighlightState.originalColor[child.uuid]) {
|
|
422
|
+
child.material.color.copy(partHighlightState.originalColor[child.uuid]);
|
|
423
|
+
child.material.opacity = partHighlightState.originalOpacity[child.uuid];
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
} else if (mesh.material && partHighlightState.originalColor[mesh.uuid]) {
|
|
427
|
+
mesh.material.color.copy(partHighlightState.originalColor[mesh.uuid]);
|
|
428
|
+
mesh.material.opacity = partHighlightState.originalOpacity[mesh.uuid];
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* ============================================================================
|
|
434
|
+
* EXPLODE ANIMATION
|
|
435
|
+
* ============================================================================
|
|
436
|
+
*/
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Animate explode/collapse
|
|
440
|
+
* @param {number} amount - 0 = collapsed, 1 = fully exploded
|
|
441
|
+
*/
|
|
442
|
+
export function explodeParts(amount) {
|
|
443
|
+
viewerState.explodeAmount = Math.max(0, Math.min(1, amount));
|
|
444
|
+
|
|
445
|
+
viewerState.allParts.forEach((partData, index) => {
|
|
446
|
+
const mesh = partData.mesh;
|
|
447
|
+
const originalPos = viewerState.explodedPositions[mesh.uuid];
|
|
448
|
+
|
|
449
|
+
if (!originalPos) return;
|
|
450
|
+
|
|
451
|
+
// Calculate displacement direction from center
|
|
452
|
+
const direction = partData.center.clone().normalize();
|
|
453
|
+
|
|
454
|
+
// Interpolate position
|
|
455
|
+
const displacementDistance = config.assemblySeparation * viewerState.explodeAmount;
|
|
456
|
+
const newPos = originalPos.clone().add(direction.multiplyScalar(displacementDistance));
|
|
457
|
+
|
|
458
|
+
mesh.position.copy(newPos);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
updateStatus(`Explode: ${Math.round(viewerState.explodeAmount * 100)}%`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* ============================================================================
|
|
466
|
+
* SECTION CUT (CLIPPING PLANE)
|
|
467
|
+
* ============================================================================
|
|
468
|
+
*/
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Enable/disable section cut with clipping plane
|
|
472
|
+
* @param {boolean} enabled
|
|
473
|
+
* @param {string} axis - 'x', 'y', 'z'
|
|
474
|
+
* @param {number} position - position of clipping plane
|
|
475
|
+
*/
|
|
476
|
+
export function setSectionCut(enabled, axis = 'z', position = 0) {
|
|
477
|
+
viewerState.sectionCutActive = enabled;
|
|
478
|
+
|
|
479
|
+
if (!enabled) {
|
|
480
|
+
// Disable clipping on all materials
|
|
481
|
+
viewerState.allParts.forEach((partData) => {
|
|
482
|
+
const traverse = (obj) => {
|
|
483
|
+
if (obj.material) {
|
|
484
|
+
obj.material.clippingPlanes = [];
|
|
485
|
+
obj.material.clipIntersection = false;
|
|
486
|
+
}
|
|
487
|
+
if (obj.children) {
|
|
488
|
+
obj.children.forEach(traverse);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
traverse(partData.mesh);
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Create clipping plane
|
|
497
|
+
const normal = new THREE.Vector3();
|
|
498
|
+
normal[axis] = 1;
|
|
499
|
+
|
|
500
|
+
const clippingPlane = new THREE.Plane(normal, position);
|
|
501
|
+
renderer.localClippingEnabled = true;
|
|
502
|
+
|
|
503
|
+
// Apply to all materials
|
|
504
|
+
viewerState.allParts.forEach((partData) => {
|
|
505
|
+
const traverse = (obj) => {
|
|
506
|
+
if (obj.material) {
|
|
507
|
+
obj.material.clippingPlanes = [clippingPlane];
|
|
508
|
+
obj.material.clipIntersection = false;
|
|
509
|
+
obj.material.side = THREE.DoubleSide;
|
|
510
|
+
}
|
|
511
|
+
if (obj.children) {
|
|
512
|
+
obj.children.forEach(traverse);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
traverse(partData.mesh);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
updateStatus(`Section cut active — ${axis.toUpperCase()} axis`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* ============================================================================
|
|
523
|
+
* PART INFO PANEL
|
|
524
|
+
* ============================================================================
|
|
525
|
+
*/
|
|
526
|
+
|
|
527
|
+
function showPartInfo(part) {
|
|
528
|
+
// Calculate bounding box dimensions
|
|
529
|
+
const size = part.bbox.getSize(new THREE.Vector3());
|
|
530
|
+
const volume = size.x * size.y * size.z;
|
|
531
|
+
|
|
532
|
+
// Create info panel if doesn't exist
|
|
533
|
+
let infoPanel = document.getElementById('viewer-part-info-panel');
|
|
534
|
+
if (!infoPanel) {
|
|
535
|
+
infoPanel = document.createElement('div');
|
|
536
|
+
infoPanel.id = 'viewer-part-info-panel';
|
|
537
|
+
infoPanel.style.cssText = `
|
|
538
|
+
position: fixed;
|
|
539
|
+
bottom: 20px;
|
|
540
|
+
right: 20px;
|
|
541
|
+
background: #252526;
|
|
542
|
+
border: 1px solid #3e3e42;
|
|
543
|
+
border-radius: 4px;
|
|
544
|
+
padding: 16px;
|
|
545
|
+
width: 250px;
|
|
546
|
+
max-height: 300px;
|
|
547
|
+
overflow-y: auto;
|
|
548
|
+
color: #e0e0e0;
|
|
549
|
+
font-family: monospace;
|
|
550
|
+
font-size: 12px;
|
|
551
|
+
z-index: 50;
|
|
552
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
|
553
|
+
`;
|
|
554
|
+
document.body.appendChild(infoPanel);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
infoPanel.innerHTML = `
|
|
558
|
+
<div style="margin-bottom: 8px; font-weight: bold; color: #58a6ff;">${part.name}</div>
|
|
559
|
+
<div style="margin-bottom: 4px;">Index: ${part.index}</div>
|
|
560
|
+
<div style="margin-bottom: 4px;">Dimensions (mm):</div>
|
|
561
|
+
<div style="margin-bottom: 4px; margin-left: 8px;">X: ${size.x.toFixed(2)}</div>
|
|
562
|
+
<div style="margin-bottom: 4px; margin-left: 8px;">Y: ${size.y.toFixed(2)}</div>
|
|
563
|
+
<div style="margin-bottom: 4px; margin-left: 8px;">Z: ${size.z.toFixed(2)}</div>
|
|
564
|
+
<div style="margin-bottom: 4px;">Volume: ${volume.toFixed(0)} mm³</div>
|
|
565
|
+
<button id="info-close-btn" style="
|
|
566
|
+
margin-top: 8px;
|
|
567
|
+
padding: 4px 8px;
|
|
568
|
+
background: #3e3e42;
|
|
569
|
+
border: 1px solid #58a6ff;
|
|
570
|
+
color: #58a6ff;
|
|
571
|
+
cursor: pointer;
|
|
572
|
+
border-radius: 2px;
|
|
573
|
+
">Close</button>
|
|
574
|
+
`;
|
|
575
|
+
|
|
576
|
+
document.getElementById('info-close-btn').addEventListener('click', () => {
|
|
577
|
+
infoPanel.style.display = 'none';
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* ============================================================================
|
|
583
|
+
* BOM EXPORT
|
|
584
|
+
* ============================================================================
|
|
585
|
+
*/
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Export BOM as CSV
|
|
589
|
+
*/
|
|
590
|
+
export function exportBOM() {
|
|
591
|
+
if (viewerState.allParts.length === 0) {
|
|
592
|
+
updateStatus('No parts loaded', 'warning');
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Build CSV
|
|
597
|
+
let csv = 'Index,Name,Dimensions (X,Y,Z),Volume (mm³)\n';
|
|
598
|
+
|
|
599
|
+
viewerState.allParts.forEach((part) => {
|
|
600
|
+
const size = part.bbox.getSize(new THREE.Vector3());
|
|
601
|
+
const volume = size.x * size.y * size.z;
|
|
602
|
+
csv += `${part.index},"${part.name}","${size.x.toFixed(2)}, ${size.y.toFixed(2)}, ${size.z.toFixed(2)}",${volume.toFixed(0)}\n`;
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// Download
|
|
606
|
+
const blob = new Blob([csv], { type: 'text/csv' });
|
|
607
|
+
const url = URL.createObjectURL(blob);
|
|
608
|
+
const a = document.createElement('a');
|
|
609
|
+
a.href = url;
|
|
610
|
+
a.download = 'bom.csv';
|
|
611
|
+
a.click();
|
|
612
|
+
URL.revokeObjectURL(url);
|
|
613
|
+
|
|
614
|
+
updateStatus('BOM exported to bom.csv');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* ============================================================================
|
|
619
|
+
* ANNOTATIONS
|
|
620
|
+
* ============================================================================
|
|
621
|
+
*/
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Add an annotation pin at world position
|
|
625
|
+
* @param {THREE.Vector3} position - World position for pin
|
|
626
|
+
* @param {string} text - Annotation text
|
|
627
|
+
*/
|
|
628
|
+
export function addAnnotationPin(position, text) {
|
|
629
|
+
// Create simple sphere pin
|
|
630
|
+
const geometry = new THREE.SphereGeometry(5, 8, 8);
|
|
631
|
+
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });
|
|
632
|
+
const pin = new THREE.Mesh(geometry, material);
|
|
633
|
+
|
|
634
|
+
pin.position.copy(position);
|
|
635
|
+
pin.userData = { text, isAnnotationPin: true };
|
|
636
|
+
|
|
637
|
+
viewerGroup.add(pin);
|
|
638
|
+
viewerState.annotationPins.push(pin);
|
|
639
|
+
|
|
640
|
+
updateStatus(`Added annotation: ${text}`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* ============================================================================
|
|
645
|
+
* CONTEXT MENU
|
|
646
|
+
* ============================================================================
|
|
647
|
+
*/
|
|
648
|
+
|
|
649
|
+
function setupContextMenu() {
|
|
650
|
+
// Create context menu HTML
|
|
651
|
+
let contextMenu = document.getElementById('viewer-context-menu');
|
|
652
|
+
if (!contextMenu) {
|
|
653
|
+
contextMenu = document.createElement('div');
|
|
654
|
+
contextMenu.id = 'viewer-context-menu';
|
|
655
|
+
contextMenu.style.cssText = `
|
|
656
|
+
position: fixed;
|
|
657
|
+
background: #2d2d30;
|
|
658
|
+
border: 1px solid #3e3e42;
|
|
659
|
+
border-radius: 4px;
|
|
660
|
+
min-width: 150px;
|
|
661
|
+
z-index: 1000;
|
|
662
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
|
663
|
+
display: none;
|
|
664
|
+
`;
|
|
665
|
+
document.body.appendChild(contextMenu);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Right-click handler on renderer
|
|
669
|
+
renderer.domElement.addEventListener('contextmenu', (e) => {
|
|
670
|
+
if (!isViewerMode) return;
|
|
671
|
+
|
|
672
|
+
e.preventDefault();
|
|
673
|
+
|
|
674
|
+
// Raycast to find part under cursor
|
|
675
|
+
const raycaster = new THREE.Raycaster();
|
|
676
|
+
const mouse = new THREE.Vector2();
|
|
677
|
+
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
|
678
|
+
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
|
679
|
+
|
|
680
|
+
raycaster.setFromCamera(mouse, camera);
|
|
681
|
+
const intersects = raycaster.intersectObjects(viewerGroup.children, true);
|
|
682
|
+
|
|
683
|
+
if (intersects.length === 0) return;
|
|
684
|
+
|
|
685
|
+
const clickedObject = intersects[0].object;
|
|
686
|
+
const partIndex = findPartIndex(clickedObject);
|
|
687
|
+
|
|
688
|
+
if (partIndex === -1) return;
|
|
689
|
+
|
|
690
|
+
// Show context menu
|
|
691
|
+
contextMenu.innerHTML = `
|
|
692
|
+
<div style="padding: 0;">
|
|
693
|
+
<button class="context-menu-item" data-action="select">Select</button>
|
|
694
|
+
<button class="context-menu-item" data-action="hide">Hide</button>
|
|
695
|
+
<button class="context-menu-item" data-action="isolate">Isolate</button>
|
|
696
|
+
<button class="context-menu-item" data-action="export">Export STL</button>
|
|
697
|
+
<button class="context-menu-item" data-action="info">Part Info</button>
|
|
698
|
+
</div>
|
|
699
|
+
`;
|
|
700
|
+
|
|
701
|
+
// Style items
|
|
702
|
+
contextMenu.querySelectorAll('.context-menu-item').forEach((btn) => {
|
|
703
|
+
btn.style.cssText = `
|
|
704
|
+
display: block;
|
|
705
|
+
width: 100%;
|
|
706
|
+
padding: 8px 12px;
|
|
707
|
+
border: none;
|
|
708
|
+
background: transparent;
|
|
709
|
+
color: #e0e0e0;
|
|
710
|
+
cursor: pointer;
|
|
711
|
+
text-align: left;
|
|
712
|
+
font-size: 12px;
|
|
713
|
+
`;
|
|
714
|
+
btn.addEventListener('mouseover', () => {
|
|
715
|
+
btn.style.background = '#3e3e42';
|
|
716
|
+
});
|
|
717
|
+
btn.addEventListener('mouseout', () => {
|
|
718
|
+
btn.style.background = 'transparent';
|
|
719
|
+
});
|
|
720
|
+
btn.addEventListener('click', () => {
|
|
721
|
+
handleContextMenuAction(btn.dataset.action, partIndex);
|
|
722
|
+
contextMenu.style.display = 'none';
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
contextMenu.style.display = 'block';
|
|
727
|
+
contextMenu.style.left = e.clientX + 'px';
|
|
728
|
+
contextMenu.style.top = e.clientY + 'px';
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// Hide menu on click elsewhere
|
|
732
|
+
document.addEventListener('click', () => {
|
|
733
|
+
contextMenu.style.display = 'none';
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function handleContextMenuAction(action, partIndex) {
|
|
738
|
+
const part = viewerState.allParts[partIndex];
|
|
739
|
+
if (!part) return;
|
|
740
|
+
|
|
741
|
+
switch (action) {
|
|
742
|
+
case 'select':
|
|
743
|
+
selectPart(partIndex);
|
|
744
|
+
break;
|
|
745
|
+
case 'hide':
|
|
746
|
+
part.mesh.visible = false;
|
|
747
|
+
updateStatus(`Hidden: ${part.name}`);
|
|
748
|
+
break;
|
|
749
|
+
case 'isolate':
|
|
750
|
+
viewerState.allParts.forEach((p, i) => {
|
|
751
|
+
p.mesh.visible = (i === partIndex);
|
|
752
|
+
});
|
|
753
|
+
updateStatus(`Isolated: ${part.name}`);
|
|
754
|
+
break;
|
|
755
|
+
case 'export':
|
|
756
|
+
exportPartSTL(part);
|
|
757
|
+
break;
|
|
758
|
+
case 'info':
|
|
759
|
+
showPartInfo(part);
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function findPartIndex(object) {
|
|
765
|
+
for (let i = 0; i < viewerState.allParts.length; i++) {
|
|
766
|
+
const part = viewerState.allParts[i];
|
|
767
|
+
if (part.mesh === object || part.mesh.children.includes(object)) {
|
|
768
|
+
return i;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return -1;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function exportPartSTL(part) {
|
|
775
|
+
// Stub: Would export mesh to STL using Three.js STL exporter
|
|
776
|
+
updateStatus(`Exporting ${part.name} to STL... (stub)`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* ============================================================================
|
|
781
|
+
* EVENT LISTENERS
|
|
782
|
+
* ============================================================================
|
|
783
|
+
*/
|
|
784
|
+
|
|
785
|
+
function setupEventListeners() {
|
|
786
|
+
// Explode slider
|
|
787
|
+
const explodeSlider = document.getElementById('viewer-explode-slider');
|
|
788
|
+
if (explodeSlider) {
|
|
789
|
+
explodeSlider.addEventListener('input', (e) => {
|
|
790
|
+
const amount = parseFloat(e.target.value);
|
|
791
|
+
explodeParts(amount);
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Section cut toggle
|
|
796
|
+
const sectionToggle = document.getElementById('viewer-section-cut-toggle');
|
|
797
|
+
if (sectionToggle) {
|
|
798
|
+
sectionToggle.addEventListener('change', (e) => {
|
|
799
|
+
setSectionCut(e.target.checked);
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// BOM export button
|
|
804
|
+
const bomBtn = document.getElementById('viewer-bom-export-btn');
|
|
805
|
+
if (bomBtn) {
|
|
806
|
+
bomBtn.addEventListener('click', () => {
|
|
807
|
+
exportBOM();
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Mode toggle button
|
|
812
|
+
const modeBtn = document.getElementById('btn-viewer-mode-toggle');
|
|
813
|
+
if (modeBtn) {
|
|
814
|
+
modeBtn.addEventListener('click', () => {
|
|
815
|
+
toggleViewerMode(!isViewerMode);
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* ============================================================================
|
|
822
|
+
* UTILITIES
|
|
823
|
+
* ============================================================================
|
|
824
|
+
*/
|
|
825
|
+
|
|
826
|
+
function fitAllParts() {
|
|
827
|
+
if (viewerState.allParts.length === 0) return;
|
|
828
|
+
|
|
829
|
+
// Calculate overall bounding box
|
|
830
|
+
const bbox = new THREE.Box3();
|
|
831
|
+
viewerState.allParts.forEach((part) => {
|
|
832
|
+
bbox.expandByObject(part.mesh);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
const center = bbox.getCenter(new THREE.Vector3());
|
|
836
|
+
const size = bbox.getSize(new THREE.Vector3());
|
|
837
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
838
|
+
|
|
839
|
+
// Set camera to frame all parts
|
|
840
|
+
const distance = maxDim / (2 * Math.tan((camera.fov * Math.PI / 180) / 2));
|
|
841
|
+
|
|
842
|
+
camera.position.set(
|
|
843
|
+
center.x + distance * 0.5,
|
|
844
|
+
center.y + distance * 0.3,
|
|
845
|
+
center.z + distance * 0.7,
|
|
846
|
+
);
|
|
847
|
+
camera.lookAt(center);
|
|
848
|
+
controls.target.copy(center);
|
|
849
|
+
controls.update();
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function updateStatus(message, type = 'info') {
|
|
853
|
+
let statusBar = document.getElementById('status-bar');
|
|
854
|
+
if (!statusBar) {
|
|
855
|
+
statusBar = document.createElement('div');
|
|
856
|
+
statusBar.id = 'status-bar';
|
|
857
|
+
statusBar.style.cssText = `
|
|
858
|
+
position: fixed;
|
|
859
|
+
bottom: 0;
|
|
860
|
+
left: 0;
|
|
861
|
+
right: 0;
|
|
862
|
+
height: 36px;
|
|
863
|
+
background: #1e1e1e;
|
|
864
|
+
border-top: 1px solid #3e3e42;
|
|
865
|
+
display: flex;
|
|
866
|
+
align-items: center;
|
|
867
|
+
padding: 0 16px;
|
|
868
|
+
color: #a0a0a0;
|
|
869
|
+
font-size: 12px;
|
|
870
|
+
z-index: 40;
|
|
871
|
+
`;
|
|
872
|
+
document.body.appendChild(statusBar);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
statusBar.textContent = message;
|
|
876
|
+
statusBar.style.color = type === 'error' ? '#f85149' : type === 'warning' ? '#d29922' : '#a0a0a0';
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* ============================================================================
|
|
881
|
+
* PUBLIC API GETTERS
|
|
882
|
+
* ============================================================================
|
|
883
|
+
*/
|
|
884
|
+
|
|
885
|
+
export function getViewerState() {
|
|
886
|
+
return viewerState;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Expose key functions globally for debugging
|
|
890
|
+
window.ViewerMode = {
|
|
891
|
+
loadFile,
|
|
892
|
+
selectPart,
|
|
893
|
+
explodeParts,
|
|
894
|
+
setSectionCut,
|
|
895
|
+
exportBOM,
|
|
896
|
+
addAnnotationPin,
|
|
897
|
+
toggleViewerMode,
|
|
898
|
+
getViewerState,
|
|
899
|
+
};
|