@cluesmith/codev 1.5.1 → 1.5.2
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/dist/agent-farm/cli.d.ts.map +1 -1
- package/dist/agent-farm/cli.js +4 -0
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/consult.d.ts.map +1 -1
- package/dist/agent-farm/commands/consult.js +2 -4
- package/dist/agent-farm/commands/consult.js.map +1 -1
- package/dist/agent-farm/commands/start.d.ts +13 -0
- package/dist/agent-farm/commands/start.d.ts.map +1 -1
- package/dist/agent-farm/commands/start.js +140 -3
- package/dist/agent-farm/commands/start.js.map +1 -1
- package/dist/agent-farm/commands/stop.d.ts.map +1 -1
- package/dist/agent-farm/commands/stop.js +69 -1
- package/dist/agent-farm/commands/stop.js.map +1 -1
- package/dist/agent-farm/servers/dashboard-server.js +77 -0
- package/dist/agent-farm/servers/dashboard-server.js.map +1 -1
- package/dist/agent-farm/servers/open-server.js +51 -10
- package/dist/agent-farm/servers/open-server.js.map +1 -1
- package/dist/agent-farm/types.d.ts +2 -0
- package/dist/agent-farm/types.d.ts.map +1 -1
- package/dist/agent-farm/utils/terminal-ports.d.ts +18 -0
- package/dist/agent-farm/utils/terminal-ports.d.ts.map +1 -0
- package/dist/agent-farm/utils/terminal-ports.js +35 -0
- package/dist/agent-farm/utils/terminal-ports.js.map +1 -0
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +8 -0
- package/dist/commands/update.js.map +1 -1
- package/package.json +3 -1
- package/templates/3d-viewer.html +799 -0
- package/templates/dashboard/js/tabs.js +49 -4
- package/templates/open.html +1 -0
- package/templates/stl-viewer.html +0 -528
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>{{FILE}} - 3D Viewer</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
body {
|
|
15
|
+
background: #1a1a2e;
|
|
16
|
+
color: #e0e0e0;
|
|
17
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
height: 100vh;
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#header {
|
|
25
|
+
background: #16213e;
|
|
26
|
+
padding: 8px 16px;
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: space-between;
|
|
30
|
+
border-bottom: 1px solid #0f3460;
|
|
31
|
+
flex-shrink: 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#filename {
|
|
35
|
+
font-size: 14px;
|
|
36
|
+
color: #94a3b8;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#info {
|
|
40
|
+
font-size: 12px;
|
|
41
|
+
color: #64748b;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#controls {
|
|
45
|
+
display: flex;
|
|
46
|
+
gap: 8px;
|
|
47
|
+
align-items: center;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.control-group {
|
|
51
|
+
display: flex;
|
|
52
|
+
gap: 4px;
|
|
53
|
+
padding: 0 8px;
|
|
54
|
+
border-right: 1px solid #0f3460;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.control-group:last-child {
|
|
58
|
+
border-right: none;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.control-label {
|
|
62
|
+
font-size: 10px;
|
|
63
|
+
color: #64748b;
|
|
64
|
+
margin-right: 4px;
|
|
65
|
+
align-self: center;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
button {
|
|
69
|
+
background: #0f3460;
|
|
70
|
+
color: #e0e0e0;
|
|
71
|
+
border: 1px solid #1e4976;
|
|
72
|
+
padding: 4px 8px;
|
|
73
|
+
border-radius: 4px;
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
font-size: 11px;
|
|
76
|
+
transition: background 0.2s;
|
|
77
|
+
min-width: 28px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
button:hover {
|
|
81
|
+
background: #1e4976;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
button.active {
|
|
85
|
+
background: #2563eb;
|
|
86
|
+
border-color: #3b82f6;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
button.view-btn {
|
|
90
|
+
font-family: monospace;
|
|
91
|
+
font-weight: bold;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
button.view-btn.positive {
|
|
95
|
+
color: #4ade80;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
button.view-btn.negative {
|
|
99
|
+
color: #f87171;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#canvas-container {
|
|
103
|
+
flex: 1;
|
|
104
|
+
position: relative;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#canvas {
|
|
108
|
+
display: block;
|
|
109
|
+
width: 100%;
|
|
110
|
+
height: 100%;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#loading {
|
|
114
|
+
position: absolute;
|
|
115
|
+
top: 50%;
|
|
116
|
+
left: 50%;
|
|
117
|
+
transform: translate(-50%, -50%);
|
|
118
|
+
text-align: center;
|
|
119
|
+
color: #94a3b8;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#loading .spinner {
|
|
123
|
+
width: 40px;
|
|
124
|
+
height: 40px;
|
|
125
|
+
border: 3px solid #0f3460;
|
|
126
|
+
border-top-color: #3b82f6;
|
|
127
|
+
border-radius: 50%;
|
|
128
|
+
animation: spin 1s linear infinite;
|
|
129
|
+
margin: 0 auto 12px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@keyframes spin {
|
|
133
|
+
to { transform: rotate(360deg); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#error {
|
|
137
|
+
position: absolute;
|
|
138
|
+
top: 50%;
|
|
139
|
+
left: 50%;
|
|
140
|
+
transform: translate(-50%, -50%);
|
|
141
|
+
text-align: center;
|
|
142
|
+
color: #ef4444;
|
|
143
|
+
display: none;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.hidden {
|
|
147
|
+
display: none !important;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* Axes legend */
|
|
151
|
+
#axes-legend {
|
|
152
|
+
position: absolute;
|
|
153
|
+
bottom: 16px;
|
|
154
|
+
left: 16px;
|
|
155
|
+
background: rgba(22, 33, 62, 0.9);
|
|
156
|
+
padding: 8px 12px;
|
|
157
|
+
border-radius: 4px;
|
|
158
|
+
font-size: 11px;
|
|
159
|
+
font-family: monospace;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#axes-legend div {
|
|
163
|
+
margin: 2px 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.axis-x { color: #ef4444; }
|
|
167
|
+
.axis-y { color: #22c55e; }
|
|
168
|
+
.axis-z { color: #3b82f6; }
|
|
169
|
+
</style>
|
|
170
|
+
</head>
|
|
171
|
+
<body>
|
|
172
|
+
<div id="header">
|
|
173
|
+
<div>
|
|
174
|
+
<span id="filename">{{FILE}}</span>
|
|
175
|
+
<span id="info"></span>
|
|
176
|
+
</div>
|
|
177
|
+
<div id="controls">
|
|
178
|
+
<div class="control-group">
|
|
179
|
+
<span class="control-label">View:</span>
|
|
180
|
+
<button id="viewTop" class="view-btn positive" title="Top (+Y)">+Y</button>
|
|
181
|
+
<button id="viewBottom" class="view-btn negative" title="Bottom (-Y)">-Y</button>
|
|
182
|
+
<button id="viewFront" class="view-btn positive" title="Front (+Z)">+Z</button>
|
|
183
|
+
<button id="viewBack" class="view-btn negative" title="Back (-Z)">-Z</button>
|
|
184
|
+
<button id="viewRight" class="view-btn positive" title="Right (+X)">+X</button>
|
|
185
|
+
<button id="viewLeft" class="view-btn negative" title="Left (-X)">-X</button>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="control-group">
|
|
188
|
+
<button id="viewIso" title="Isometric view">Iso</button>
|
|
189
|
+
<button id="resetBtn" title="Fit model to view">Fit</button>
|
|
190
|
+
</div>
|
|
191
|
+
<div class="control-group">
|
|
192
|
+
<button id="wireframeBtn" title="Toggle wireframe mode">Wire</button>
|
|
193
|
+
<button id="axesBtn" class="active" title="Toggle axes">Axes</button>
|
|
194
|
+
<button id="gridBtn" class="active" title="Toggle grid">Grid</button>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<div id="canvas-container">
|
|
200
|
+
<canvas id="canvas"></canvas>
|
|
201
|
+
<div id="loading">
|
|
202
|
+
<div class="spinner"></div>
|
|
203
|
+
<div id="loading-text">Loading 3D model...</div>
|
|
204
|
+
</div>
|
|
205
|
+
<div id="error">
|
|
206
|
+
<div style="font-size: 48px; margin-bottom: 12px;">⚠</div>
|
|
207
|
+
<div id="error-message">Failed to load 3D model</div>
|
|
208
|
+
</div>
|
|
209
|
+
<div id="axes-legend">
|
|
210
|
+
<div class="axis-x">X → Right</div>
|
|
211
|
+
<div class="axis-y">Y → Up</div>
|
|
212
|
+
<div class="axis-z">Z → Front</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<!-- Three.js ES Modules via import map -->
|
|
217
|
+
<script type="importmap">
|
|
218
|
+
{
|
|
219
|
+
"imports": {
|
|
220
|
+
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
|
221
|
+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
</script>
|
|
225
|
+
|
|
226
|
+
<script type="module">
|
|
227
|
+
import * as THREE from 'three';
|
|
228
|
+
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
|
229
|
+
import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
|
|
230
|
+
import { TrackballControls } from 'three/addons/controls/TrackballControls.js';
|
|
231
|
+
|
|
232
|
+
// Configuration (injected by server with proper escaping)
|
|
233
|
+
const FILE_PATH = {{FILE_PATH_JSON}}; // JSON-encoded by server
|
|
234
|
+
const FILE_NAME = '{{FILE}}';
|
|
235
|
+
const FORMAT = '{{FORMAT}}'; // 'stl' or '3mf'
|
|
236
|
+
|
|
237
|
+
// Three.js setup
|
|
238
|
+
let scene, camera, renderer, controls;
|
|
239
|
+
let model = null; // Can be mesh (STL) or group (3MF)
|
|
240
|
+
let wireframeMesh = null; // Wireframe overlay mesh
|
|
241
|
+
let axesHelper, gridHelper;
|
|
242
|
+
let viewMode = 'solid'; // 'solid', 'wireframe', or 'both'
|
|
243
|
+
let showAxes = true;
|
|
244
|
+
let showGrid = true;
|
|
245
|
+
let modelCenter = new THREE.Vector3();
|
|
246
|
+
let cameraDistance = 100;
|
|
247
|
+
|
|
248
|
+
// DOM elements
|
|
249
|
+
const canvas = document.getElementById('canvas');
|
|
250
|
+
const container = document.getElementById('canvas-container');
|
|
251
|
+
const loading = document.getElementById('loading');
|
|
252
|
+
const loadingText = document.getElementById('loading-text');
|
|
253
|
+
const error = document.getElementById('error');
|
|
254
|
+
const errorMessage = document.getElementById('error-message');
|
|
255
|
+
const info = document.getElementById('info');
|
|
256
|
+
|
|
257
|
+
// Buttons
|
|
258
|
+
const resetBtn = document.getElementById('resetBtn');
|
|
259
|
+
const wireframeBtn = document.getElementById('wireframeBtn');
|
|
260
|
+
const axesBtn = document.getElementById('axesBtn');
|
|
261
|
+
const gridBtn = document.getElementById('gridBtn');
|
|
262
|
+
const viewTop = document.getElementById('viewTop');
|
|
263
|
+
const viewBottom = document.getElementById('viewBottom');
|
|
264
|
+
const viewFront = document.getElementById('viewFront');
|
|
265
|
+
const viewBack = document.getElementById('viewBack');
|
|
266
|
+
const viewRight = document.getElementById('viewRight');
|
|
267
|
+
const viewLeft = document.getElementById('viewLeft');
|
|
268
|
+
const viewIso = document.getElementById('viewIso');
|
|
269
|
+
|
|
270
|
+
function init() {
|
|
271
|
+
// Scene
|
|
272
|
+
scene = new THREE.Scene();
|
|
273
|
+
scene.background = new THREE.Color(0x1a1a2e);
|
|
274
|
+
|
|
275
|
+
// Camera
|
|
276
|
+
camera = new THREE.PerspectiveCamera(
|
|
277
|
+
45,
|
|
278
|
+
container.clientWidth / container.clientHeight,
|
|
279
|
+
0.1,
|
|
280
|
+
10000
|
|
281
|
+
);
|
|
282
|
+
camera.position.set(100, 100, 100);
|
|
283
|
+
|
|
284
|
+
// Renderer
|
|
285
|
+
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
286
|
+
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
287
|
+
renderer.setPixelRatio(window.devicePixelRatio);
|
|
288
|
+
|
|
289
|
+
// Controls - TrackballControls uses quaternions, eliminating gimbal lock
|
|
290
|
+
controls = new TrackballControls(camera, renderer.domElement);
|
|
291
|
+
controls.rotateSpeed = 2.0;
|
|
292
|
+
controls.zoomSpeed = 1.2;
|
|
293
|
+
controls.panSpeed = 0.8;
|
|
294
|
+
controls.staticMoving = true; // No inertia for precise control
|
|
295
|
+
controls.dynamicDampingFactor = 0.3;
|
|
296
|
+
|
|
297
|
+
// Lighting
|
|
298
|
+
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
|
|
299
|
+
scene.add(ambientLight);
|
|
300
|
+
|
|
301
|
+
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
302
|
+
directionalLight1.position.set(1, 1, 1);
|
|
303
|
+
scene.add(directionalLight1);
|
|
304
|
+
|
|
305
|
+
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
|
|
306
|
+
directionalLight2.position.set(-1, -1, -1);
|
|
307
|
+
scene.add(directionalLight2);
|
|
308
|
+
|
|
309
|
+
// Grid
|
|
310
|
+
gridHelper = new THREE.GridHelper(200, 20, 0x0f3460, 0x0f3460);
|
|
311
|
+
scene.add(gridHelper);
|
|
312
|
+
|
|
313
|
+
// Axes helper (X=red, Y=green, Z=blue)
|
|
314
|
+
axesHelper = new THREE.AxesHelper(50);
|
|
315
|
+
scene.add(axesHelper);
|
|
316
|
+
|
|
317
|
+
// Load model based on format
|
|
318
|
+
loadModel();
|
|
319
|
+
|
|
320
|
+
// Event listeners
|
|
321
|
+
window.addEventListener('resize', onResize);
|
|
322
|
+
|
|
323
|
+
// Control buttons
|
|
324
|
+
resetBtn.addEventListener('click', fitToView);
|
|
325
|
+
wireframeBtn.addEventListener('click', toggleWireframe);
|
|
326
|
+
axesBtn.addEventListener('click', toggleAxes);
|
|
327
|
+
gridBtn.addEventListener('click', toggleGrid);
|
|
328
|
+
|
|
329
|
+
// View buttons
|
|
330
|
+
viewTop.addEventListener('click', () => setView(0, 1, 0));
|
|
331
|
+
viewBottom.addEventListener('click', () => setView(0, -1, 0));
|
|
332
|
+
viewFront.addEventListener('click', () => setView(0, 0, 1));
|
|
333
|
+
viewBack.addEventListener('click', () => setView(0, 0, -1));
|
|
334
|
+
viewRight.addEventListener('click', () => setView(1, 0, 0));
|
|
335
|
+
viewLeft.addEventListener('click', () => setView(-1, 0, 0));
|
|
336
|
+
viewIso.addEventListener('click', () => setView(1, 1, 1));
|
|
337
|
+
|
|
338
|
+
// Animation loop
|
|
339
|
+
animate();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function loadModel() {
|
|
343
|
+
loadingText.textContent = `Loading ${FORMAT.toUpperCase()} model...`;
|
|
344
|
+
|
|
345
|
+
if (FORMAT === '3mf') {
|
|
346
|
+
load3MF();
|
|
347
|
+
} else {
|
|
348
|
+
loadSTL();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function loadSTL() {
|
|
353
|
+
const loader = new STLLoader();
|
|
354
|
+
|
|
355
|
+
loader.load(
|
|
356
|
+
'/api/model',
|
|
357
|
+
(geometry) => {
|
|
358
|
+
// Center geometry
|
|
359
|
+
geometry.computeBoundingBox();
|
|
360
|
+
const center = new THREE.Vector3();
|
|
361
|
+
geometry.boundingBox.getCenter(center);
|
|
362
|
+
geometry.translate(-center.x, -center.y, -center.z);
|
|
363
|
+
|
|
364
|
+
// Move to sit on grid
|
|
365
|
+
const minY = geometry.boundingBox.min.y - center.y;
|
|
366
|
+
geometry.translate(0, -minY, 0);
|
|
367
|
+
|
|
368
|
+
// Recalculate bounding box after translation
|
|
369
|
+
geometry.computeBoundingBox();
|
|
370
|
+
|
|
371
|
+
// Material
|
|
372
|
+
const material = new THREE.MeshPhongMaterial({
|
|
373
|
+
color: 0x3b82f6,
|
|
374
|
+
specular: 0x111111,
|
|
375
|
+
shininess: 30,
|
|
376
|
+
flatShading: false
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
model = new THREE.Mesh(geometry, material);
|
|
380
|
+
scene.add(model);
|
|
381
|
+
|
|
382
|
+
setupAfterLoad(model);
|
|
383
|
+
|
|
384
|
+
// Update info
|
|
385
|
+
const triangles = geometry.attributes.position.count / 3;
|
|
386
|
+
info.textContent = ` - ${triangles.toLocaleString()} triangles`;
|
|
387
|
+
},
|
|
388
|
+
undefined,
|
|
389
|
+
(err) => {
|
|
390
|
+
showError('Failed to load STL file: ' + (err.message || 'Unknown error'));
|
|
391
|
+
}
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function load3MF() {
|
|
396
|
+
const loader = new ThreeMFLoader();
|
|
397
|
+
|
|
398
|
+
loader.load(
|
|
399
|
+
'/api/model',
|
|
400
|
+
(group) => {
|
|
401
|
+
// 3MFLoader returns a Group with meshes
|
|
402
|
+
// 3MF uses Z-up, Three.js uses Y-up - rotate to match
|
|
403
|
+
group.rotation.set(-Math.PI / 2, 0, 0);
|
|
404
|
+
|
|
405
|
+
// Center the group
|
|
406
|
+
const box = new THREE.Box3().setFromObject(group);
|
|
407
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
408
|
+
group.position.sub(center);
|
|
409
|
+
|
|
410
|
+
// Move to sit on grid (after centering)
|
|
411
|
+
const newBox = new THREE.Box3().setFromObject(group);
|
|
412
|
+
group.position.y -= newBox.min.y;
|
|
413
|
+
|
|
414
|
+
model = group;
|
|
415
|
+
scene.add(model);
|
|
416
|
+
|
|
417
|
+
setupAfterLoad(model);
|
|
418
|
+
|
|
419
|
+
// Count triangles and objects
|
|
420
|
+
let triangleCount = 0;
|
|
421
|
+
let objectCount = 0;
|
|
422
|
+
group.traverse((child) => {
|
|
423
|
+
if (child.isMesh) {
|
|
424
|
+
objectCount++;
|
|
425
|
+
const geom = child.geometry;
|
|
426
|
+
if (geom.index) {
|
|
427
|
+
triangleCount += geom.index.count / 3;
|
|
428
|
+
} else if (geom.attributes.position) {
|
|
429
|
+
triangleCount += geom.attributes.position.count / 3;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
info.textContent = ` - ${triangleCount.toLocaleString()} triangles, ${objectCount} object${objectCount !== 1 ? 's' : ''}`;
|
|
435
|
+
},
|
|
436
|
+
undefined,
|
|
437
|
+
(err) => {
|
|
438
|
+
showError('Failed to load 3MF file: ' + (err.message || 'Unknown error'));
|
|
439
|
+
}
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function setupAfterLoad(object) {
|
|
444
|
+
// Calculate model center and size
|
|
445
|
+
const box = new THREE.Box3().setFromObject(object);
|
|
446
|
+
box.getCenter(modelCenter);
|
|
447
|
+
const size = box.getSize(new THREE.Vector3());
|
|
448
|
+
cameraDistance = Math.max(size.x, size.y, size.z) * 2;
|
|
449
|
+
|
|
450
|
+
// Scale grid and axes to model size
|
|
451
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
452
|
+
const gridSize = Math.ceil(maxDim * 2 / 10) * 10; // Round up to nearest 10
|
|
453
|
+
scene.remove(gridHelper);
|
|
454
|
+
gridHelper = new THREE.GridHelper(gridSize, gridSize / 5, 0x0f3460, 0x0f3460);
|
|
455
|
+
gridHelper.visible = showGrid;
|
|
456
|
+
scene.add(gridHelper);
|
|
457
|
+
|
|
458
|
+
scene.remove(axesHelper);
|
|
459
|
+
axesHelper = new THREE.AxesHelper(gridSize / 2);
|
|
460
|
+
axesHelper.visible = showAxes;
|
|
461
|
+
scene.add(axesHelper);
|
|
462
|
+
|
|
463
|
+
// Fit camera to model
|
|
464
|
+
fitToView();
|
|
465
|
+
|
|
466
|
+
// Hide loading
|
|
467
|
+
loading.classList.add('hidden');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function showError(message) {
|
|
471
|
+
console.error('Error:', message);
|
|
472
|
+
loading.classList.add('hidden');
|
|
473
|
+
error.style.display = 'block';
|
|
474
|
+
errorMessage.textContent = message;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function fitToView() {
|
|
478
|
+
if (!model) return;
|
|
479
|
+
|
|
480
|
+
const box = new THREE.Box3().setFromObject(model);
|
|
481
|
+
const size = box.getSize(new THREE.Vector3());
|
|
482
|
+
|
|
483
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
484
|
+
const fov = camera.fov * (Math.PI / 180);
|
|
485
|
+
cameraDistance = maxDim / (2 * Math.tan(fov / 2)) * 1.5;
|
|
486
|
+
|
|
487
|
+
// Isometric view
|
|
488
|
+
setView(1, 1, 1);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function setView(x, y, z) {
|
|
492
|
+
if (!model) return;
|
|
493
|
+
|
|
494
|
+
const box = new THREE.Box3().setFromObject(model);
|
|
495
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
496
|
+
|
|
497
|
+
// Normalize direction
|
|
498
|
+
const dir = new THREE.Vector3(x, y, z).normalize();
|
|
499
|
+
|
|
500
|
+
// Set camera position
|
|
501
|
+
camera.position.copy(center).add(dir.multiplyScalar(cameraDistance));
|
|
502
|
+
|
|
503
|
+
// Set up vector (handle top/bottom views)
|
|
504
|
+
if (Math.abs(y) > 0.9) {
|
|
505
|
+
camera.up.set(0, 0, y > 0 ? -1 : 1);
|
|
506
|
+
} else {
|
|
507
|
+
camera.up.set(0, 1, 0);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Look at center
|
|
511
|
+
controls.target.copy(center);
|
|
512
|
+
camera.lookAt(center);
|
|
513
|
+
controls.update();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function toggleWireframe() {
|
|
517
|
+
// Cycle through modes: solid -> wireframe -> both -> solid
|
|
518
|
+
if (viewMode === 'solid') {
|
|
519
|
+
viewMode = 'wireframe';
|
|
520
|
+
} else if (viewMode === 'wireframe') {
|
|
521
|
+
viewMode = 'both';
|
|
522
|
+
} else {
|
|
523
|
+
viewMode = 'solid';
|
|
524
|
+
}
|
|
525
|
+
applyViewMode();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function applyViewMode() {
|
|
529
|
+
if (!model) return;
|
|
530
|
+
|
|
531
|
+
// Remove existing wireframe overlay if any
|
|
532
|
+
if (wireframeMesh) {
|
|
533
|
+
scene.remove(wireframeMesh);
|
|
534
|
+
wireframeMesh.traverse((child) => {
|
|
535
|
+
if (child.isMesh) {
|
|
536
|
+
child.geometry.dispose();
|
|
537
|
+
child.material.dispose();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
wireframeMesh = null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (viewMode === 'solid') {
|
|
544
|
+
// Solid mode: no wireframe
|
|
545
|
+
model.traverse((child) => {
|
|
546
|
+
if (child.isMesh && child.material) {
|
|
547
|
+
if (Array.isArray(child.material)) {
|
|
548
|
+
child.material.forEach(m => m.wireframe = false);
|
|
549
|
+
} else {
|
|
550
|
+
child.material.wireframe = false;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
wireframeBtn.textContent = 'Wire';
|
|
555
|
+
wireframeBtn.classList.remove('active');
|
|
556
|
+
} else if (viewMode === 'wireframe') {
|
|
557
|
+
// Wireframe only mode
|
|
558
|
+
model.traverse((child) => {
|
|
559
|
+
if (child.isMesh && child.material) {
|
|
560
|
+
if (Array.isArray(child.material)) {
|
|
561
|
+
child.material.forEach(m => m.wireframe = true);
|
|
562
|
+
} else {
|
|
563
|
+
child.material.wireframe = true;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
wireframeBtn.textContent = 'Wire';
|
|
568
|
+
wireframeBtn.classList.add('active');
|
|
569
|
+
} else {
|
|
570
|
+
// Both mode: solid + black wireframe overlay
|
|
571
|
+
model.traverse((child) => {
|
|
572
|
+
if (child.isMesh && child.material) {
|
|
573
|
+
if (Array.isArray(child.material)) {
|
|
574
|
+
child.material.forEach(m => m.wireframe = false);
|
|
575
|
+
} else {
|
|
576
|
+
child.material.wireframe = false;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
// Create wireframe overlay
|
|
581
|
+
wireframeMesh = createWireframeOverlay(model);
|
|
582
|
+
if (wireframeMesh) {
|
|
583
|
+
scene.add(wireframeMesh);
|
|
584
|
+
}
|
|
585
|
+
wireframeBtn.textContent = 'Both';
|
|
586
|
+
wireframeBtn.classList.add('active');
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function createWireframeOverlay(sourceModel) {
|
|
591
|
+
// Create a group to hold wireframe meshes
|
|
592
|
+
const wireframeGroup = new THREE.Group();
|
|
593
|
+
wireframeGroup.position.copy(sourceModel.position);
|
|
594
|
+
wireframeGroup.rotation.copy(sourceModel.rotation);
|
|
595
|
+
wireframeGroup.scale.copy(sourceModel.scale);
|
|
596
|
+
|
|
597
|
+
sourceModel.traverse((child) => {
|
|
598
|
+
if (child.isMesh) {
|
|
599
|
+
const wireGeom = child.geometry.clone();
|
|
600
|
+
const wireMat = new THREE.MeshBasicMaterial({
|
|
601
|
+
color: 0x000000,
|
|
602
|
+
wireframe: true,
|
|
603
|
+
transparent: true,
|
|
604
|
+
opacity: 0.3
|
|
605
|
+
});
|
|
606
|
+
const wireMesh = new THREE.Mesh(wireGeom, wireMat);
|
|
607
|
+
wireMesh.position.copy(child.position);
|
|
608
|
+
wireMesh.rotation.copy(child.rotation);
|
|
609
|
+
wireMesh.scale.copy(child.scale);
|
|
610
|
+
// Slight offset to prevent z-fighting
|
|
611
|
+
wireMesh.renderOrder = 1;
|
|
612
|
+
wireframeGroup.add(wireMesh);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
return wireframeGroup;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function toggleAxes() {
|
|
620
|
+
showAxes = !showAxes;
|
|
621
|
+
axesHelper.visible = showAxes;
|
|
622
|
+
axesBtn.classList.toggle('active', showAxes);
|
|
623
|
+
document.getElementById('axes-legend').style.display = showAxes ? 'block' : 'none';
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function toggleGrid() {
|
|
627
|
+
showGrid = !showGrid;
|
|
628
|
+
gridHelper.visible = showGrid;
|
|
629
|
+
gridBtn.classList.toggle('active', showGrid);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function onResize() {
|
|
633
|
+
camera.aspect = container.clientWidth / container.clientHeight;
|
|
634
|
+
camera.updateProjectionMatrix();
|
|
635
|
+
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
636
|
+
controls.handleResize(); // TrackballControls needs this to update screen dimensions
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function animate() {
|
|
640
|
+
requestAnimationFrame(animate);
|
|
641
|
+
controls.update();
|
|
642
|
+
renderer.render(scene, camera);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Auto-reload: poll file mtime and reload model when changed
|
|
646
|
+
let lastMtime = null;
|
|
647
|
+
const POLL_INTERVAL = 1000; // 1 second
|
|
648
|
+
|
|
649
|
+
async function checkForChanges() {
|
|
650
|
+
try {
|
|
651
|
+
const res = await fetch('/api/mtime');
|
|
652
|
+
if (res.ok) {
|
|
653
|
+
const data = await res.json();
|
|
654
|
+
if (lastMtime === null) {
|
|
655
|
+
lastMtime = data.mtime;
|
|
656
|
+
} else if (data.mtime !== lastMtime) {
|
|
657
|
+
lastMtime = data.mtime;
|
|
658
|
+
reloadModel();
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
} catch (err) {
|
|
662
|
+
// Ignore fetch errors (server may be restarting)
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function reloadModel() {
|
|
667
|
+
// Remove old wireframe overlay
|
|
668
|
+
if (wireframeMesh) {
|
|
669
|
+
scene.remove(wireframeMesh);
|
|
670
|
+
wireframeMesh.traverse((child) => {
|
|
671
|
+
if (child.isMesh) {
|
|
672
|
+
child.geometry.dispose();
|
|
673
|
+
child.material.dispose();
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
wireframeMesh = null;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Remove old model
|
|
680
|
+
if (model) {
|
|
681
|
+
scene.remove(model);
|
|
682
|
+
model.traverse((child) => {
|
|
683
|
+
if (child.isMesh) {
|
|
684
|
+
child.geometry.dispose();
|
|
685
|
+
if (Array.isArray(child.material)) {
|
|
686
|
+
child.material.forEach(m => m.dispose());
|
|
687
|
+
} else if (child.material) {
|
|
688
|
+
child.material.dispose();
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
model = null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Reload based on format
|
|
696
|
+
if (FORMAT === '3mf') {
|
|
697
|
+
reload3MF();
|
|
698
|
+
} else {
|
|
699
|
+
reloadSTL();
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function reloadSTL() {
|
|
704
|
+
const loader = new STLLoader();
|
|
705
|
+
loader.load('/api/model?t=' + Date.now(), (geometry) => {
|
|
706
|
+
// Center geometry
|
|
707
|
+
geometry.computeBoundingBox();
|
|
708
|
+
const center = new THREE.Vector3();
|
|
709
|
+
geometry.boundingBox.getCenter(center);
|
|
710
|
+
geometry.translate(-center.x, -center.y, -center.z);
|
|
711
|
+
|
|
712
|
+
// Move to sit on grid
|
|
713
|
+
const minY = geometry.boundingBox.min.y - center.y;
|
|
714
|
+
geometry.translate(0, -minY, 0);
|
|
715
|
+
|
|
716
|
+
// Recalculate bounding box after translation
|
|
717
|
+
geometry.computeBoundingBox();
|
|
718
|
+
|
|
719
|
+
// Create new material
|
|
720
|
+
const material = new THREE.MeshPhongMaterial({
|
|
721
|
+
color: 0x3b82f6,
|
|
722
|
+
specular: 0x111111,
|
|
723
|
+
shininess: 30,
|
|
724
|
+
flatShading: false
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
model = new THREE.Mesh(geometry, material);
|
|
728
|
+
scene.add(model);
|
|
729
|
+
|
|
730
|
+
// Re-apply current view mode
|
|
731
|
+
applyViewMode();
|
|
732
|
+
|
|
733
|
+
// Update info
|
|
734
|
+
const triangles = geometry.attributes.position.count / 3;
|
|
735
|
+
info.textContent = ` - ${triangles.toLocaleString()} triangles (reloaded)`;
|
|
736
|
+
|
|
737
|
+
// Brief flash to indicate reload
|
|
738
|
+
setTimeout(() => {
|
|
739
|
+
info.textContent = ` - ${triangles.toLocaleString()} triangles`;
|
|
740
|
+
}, 1000);
|
|
741
|
+
}, undefined, (err) => {
|
|
742
|
+
console.error('Error reloading STL:', err);
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function reload3MF() {
|
|
747
|
+
const loader = new ThreeMFLoader();
|
|
748
|
+
loader.load('/api/model?t=' + Date.now(), (group) => {
|
|
749
|
+
// Z-up to Y-up rotation
|
|
750
|
+
group.rotation.set(-Math.PI / 2, 0, 0);
|
|
751
|
+
|
|
752
|
+
// Center the group
|
|
753
|
+
const box = new THREE.Box3().setFromObject(group);
|
|
754
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
755
|
+
group.position.sub(center);
|
|
756
|
+
|
|
757
|
+
// Move to sit on grid
|
|
758
|
+
const newBox = new THREE.Box3().setFromObject(group);
|
|
759
|
+
group.position.y -= newBox.min.y;
|
|
760
|
+
|
|
761
|
+
model = group;
|
|
762
|
+
scene.add(model);
|
|
763
|
+
|
|
764
|
+
// Re-apply current view mode
|
|
765
|
+
applyViewMode();
|
|
766
|
+
|
|
767
|
+
// Count triangles and objects
|
|
768
|
+
let triangleCount = 0;
|
|
769
|
+
let objectCount = 0;
|
|
770
|
+
group.traverse((child) => {
|
|
771
|
+
if (child.isMesh) {
|
|
772
|
+
objectCount++;
|
|
773
|
+
const geom = child.geometry;
|
|
774
|
+
if (geom.index) {
|
|
775
|
+
triangleCount += geom.index.count / 3;
|
|
776
|
+
} else if (geom.attributes.position) {
|
|
777
|
+
triangleCount += geom.attributes.position.count / 3;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
info.textContent = ` - ${triangleCount.toLocaleString()} triangles, ${objectCount} object${objectCount !== 1 ? 's' : ''} (reloaded)`;
|
|
783
|
+
|
|
784
|
+
setTimeout(() => {
|
|
785
|
+
info.textContent = ` - ${triangleCount.toLocaleString()} triangles, ${objectCount} object${objectCount !== 1 ? 's' : ''}`;
|
|
786
|
+
}, 1000);
|
|
787
|
+
}, undefined, (err) => {
|
|
788
|
+
console.error('Error reloading 3MF:', err);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Start polling for changes
|
|
793
|
+
setInterval(checkForChanges, POLL_INTERVAL);
|
|
794
|
+
|
|
795
|
+
// Start
|
|
796
|
+
init();
|
|
797
|
+
</script>
|
|
798
|
+
</body>
|
|
799
|
+
</html>
|