cyclecad 0.1.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.
@@ -0,0 +1,643 @@
1
+ /**
2
+ * viewport.js - Three.js 3D viewport module for cycleCAD
3
+ *
4
+ * A production-quality ES module that provides:
5
+ * - Three.js scene, camera, renderer initialization
6
+ * - OrbitControls with damping
7
+ * - Lighting (ambient, directional, fill)
8
+ * - Grid + origin reference geometry
9
+ * - Preset camera views with smooth transitions
10
+ * - Object management and viewport utilities
11
+ * - Proper cleanup and resize handling
12
+ */
13
+
14
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
15
+ import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js';
16
+
17
+ // ============================================================================
18
+ // Module State
19
+ // ============================================================================
20
+
21
+ let scene = null;
22
+ let camera = null;
23
+ let renderer = null;
24
+ let controls = null;
25
+ let animationFrameId = null;
26
+ let isAnimating = false;
27
+
28
+ // Grid and reference objects
29
+ let gridHelper = null;
30
+ let axisLines = null;
31
+ let referencePlanes = {};
32
+
33
+ // Camera animation state
34
+ let cameraAnimationState = {
35
+ isTransitioning: false,
36
+ startPos: new THREE.Vector3(),
37
+ endPos: new THREE.Vector3(),
38
+ startTarget: new THREE.Vector3(),
39
+ endTarget: new THREE.Vector3(),
40
+ startTime: 0,
41
+ duration: 800, // ms
42
+ };
43
+
44
+ // Preset camera views (distance varies by scene size)
45
+ const PRESET_VIEWS = {
46
+ front: { pos: { x: 0, y: 0, z: 1 }, target: { x: 0, y: 0, z: 0 } },
47
+ back: { pos: { x: 0, y: 0, z: -1 }, target: { x: 0, y: 0, z: 0 } },
48
+ top: { pos: { x: 0, y: 1, z: 0 }, target: { x: 0, y: 0, z: 0 } },
49
+ bottom: { pos: { x: 0, y: -1, z: 0 }, target: { x: 0, y: 0, z: 0 } },
50
+ left: { pos: { x: -1, y: 0, z: 0 }, target: { x: 0, y: 0, z: 0 } },
51
+ right: { pos: { x: 1, y: 0, z: 0 }, target: { x: 0, y: 0, z: 0 } },
52
+ iso: { pos: { x: 1, y: 1, z: 1 }, target: { x: 0, y: 0, z: 0 } },
53
+ };
54
+
55
+ const GRID_SIZE = 200;
56
+ const GRID_DIVISIONS = GRID_SIZE; // 1mm spacing
57
+ const AXIS_LENGTH = 60;
58
+ const COLORS = {
59
+ bg: 0x0a0e14,
60
+ ambient: 0xffffff,
61
+ directional: 0xffffff,
62
+ fill: 0xaabbff,
63
+ axisX: 0xff0000,
64
+ axisY: 0x00ff00,
65
+ axisZ: 0x0088ff,
66
+ gridMain: 0x3a4a6a,
67
+ gridSub: 0x1a2a4a,
68
+ refPlane: 0x444466,
69
+ };
70
+
71
+ // ============================================================================
72
+ // Initialization
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Initialize the Three.js viewport with scene, camera, renderer, and controls
77
+ * @param {string} containerId - ID of the DOM element to attach the renderer
78
+ * @returns {Object} Viewport object with accessor methods
79
+ */
80
+ export function initViewport(containerId) {
81
+ const container = document.getElementById(containerId);
82
+ if (!container) {
83
+ throw new Error(`Container with id "${containerId}" not found`);
84
+ }
85
+
86
+ // Get dimensions
87
+ const width = container.clientWidth;
88
+ const height = container.clientHeight;
89
+
90
+ // Create scene
91
+ scene = new THREE.Scene();
92
+ scene.background = new THREE.Color(COLORS.bg);
93
+ scene.fog = new THREE.Fog(COLORS.bg, 10000, 50000);
94
+
95
+ // Create camera
96
+ const fov = 45;
97
+ const aspect = width / height;
98
+ const near = 0.1;
99
+ const far = 50000;
100
+ camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
101
+ camera.position.set(150, 100, 150);
102
+ camera.lookAt(0, 0, 0);
103
+
104
+ // Create renderer
105
+ renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
106
+ renderer.setSize(width, height);
107
+ renderer.setPixelRatio(window.devicePixelRatio);
108
+ renderer.shadowMap.enabled = true;
109
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
110
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
111
+ renderer.toneMappingExposure = 1.0;
112
+ container.appendChild(renderer.domElement);
113
+
114
+ // Create OrbitControls
115
+ controls = new OrbitControls(camera, renderer.domElement);
116
+ controls.enableDamping = true;
117
+ controls.dampingFactor = 0.05;
118
+ controls.autoRotate = false;
119
+ controls.autoRotateSpeed = 5;
120
+ controls.enableZoom = true;
121
+ controls.enablePan = true;
122
+ controls.minDistance = 10;
123
+ controls.maxDistance = 10000;
124
+
125
+ // Setup lighting
126
+ setupLighting();
127
+
128
+ // Setup grid and reference geometry
129
+ setupGridAndOrigin();
130
+
131
+ // Setup event handlers
132
+ setupEventHandlers(container);
133
+
134
+ // Start animation loop
135
+ startAnimationLoop();
136
+
137
+ return {
138
+ getScene,
139
+ getCamera,
140
+ getRenderer,
141
+ getControls,
142
+ setView,
143
+ fitToObject,
144
+ addToScene,
145
+ removeFromScene,
146
+ toggleGrid,
147
+ toggleAxisLines,
148
+ toggleReferencePlanes,
149
+ dispose,
150
+ };
151
+ }
152
+
153
+ // ============================================================================
154
+ // Lighting Setup
155
+ // ============================================================================
156
+
157
+ /**
158
+ * Setup scene lighting: ambient, directional (main), and fill light
159
+ */
160
+ function setupLighting() {
161
+ // Ambient light - soft overall illumination
162
+ const ambientLight = new THREE.AmbientLight(COLORS.ambient, 0.4);
163
+ scene.add(ambientLight);
164
+
165
+ // Main directional light with shadows
166
+ const directionalLight = new THREE.DirectionalLight(COLORS.directional, 0.8);
167
+ directionalLight.position.set(100, 150, 100);
168
+ directionalLight.castShadow = true;
169
+ directionalLight.shadow.mapSize.width = 2048;
170
+ directionalLight.shadow.mapSize.height = 2048;
171
+ directionalLight.shadow.camera.left = -500;
172
+ directionalLight.shadow.camera.right = 500;
173
+ directionalLight.shadow.camera.top = 500;
174
+ directionalLight.shadow.camera.bottom = -500;
175
+ directionalLight.shadow.camera.near = 0.1;
176
+ directionalLight.shadow.camera.far = 2000;
177
+ directionalLight.shadow.bias = -0.0001;
178
+ scene.add(directionalLight);
179
+
180
+ // Fill light from opposite side with bluish tint
181
+ const fillLight = new THREE.DirectionalLight(COLORS.fill, 0.3);
182
+ fillLight.position.set(-100, 50, -100);
183
+ scene.add(fillLight);
184
+ }
185
+
186
+ // ============================================================================
187
+ // Grid and Reference Geometry
188
+ // ============================================================================
189
+
190
+ /**
191
+ * Setup grid helper and origin reference geometry
192
+ */
193
+ function setupGridAndOrigin() {
194
+ // Create grid helper
195
+ gridHelper = new THREE.GridHelper(
196
+ GRID_SIZE,
197
+ GRID_DIVISIONS,
198
+ COLORS.gridMain,
199
+ COLORS.gridSub
200
+ );
201
+ gridHelper.position.y = 0;
202
+ scene.add(gridHelper);
203
+
204
+ // Create axis lines (X=red, Y=green, Z=blue)
205
+ const axisLineMaterial = new THREE.LineBasicMaterial({ linewidth: 2 });
206
+ const axisGeometry = new THREE.BufferGeometry();
207
+
208
+ const positions = [];
209
+ // X axis (red)
210
+ positions.push(-AXIS_LENGTH, 0, 0);
211
+ positions.push(AXIS_LENGTH, 0, 0);
212
+ // Y axis (green)
213
+ positions.push(0, -AXIS_LENGTH, 0);
214
+ positions.push(0, AXIS_LENGTH, 0);
215
+ // Z axis (blue)
216
+ positions.push(0, 0, -AXIS_LENGTH);
217
+ positions.push(0, 0, AXIS_LENGTH);
218
+
219
+ const colors = [];
220
+ // X axis
221
+ colors.push(1, 0, 0); // Red start
222
+ colors.push(1, 0, 0); // Red end
223
+ // Y axis
224
+ colors.push(0, 1, 0); // Green start
225
+ colors.push(0, 1, 0); // Green end
226
+ // Z axis
227
+ colors.push(0, 0.533, 1); // Blue start
228
+ colors.push(0, 0.533, 1); // Blue end
229
+
230
+ axisGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
231
+ axisGeometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colors), 3));
232
+
233
+ const axisMaterial = new THREE.LineBasicMaterial({
234
+ vertexColors: true,
235
+ linewidth: 2,
236
+ });
237
+
238
+ axisLines = new THREE.LineSegments(axisGeometry, axisMaterial);
239
+ scene.add(axisLines);
240
+
241
+ // Create reference planes (semi-transparent quads)
242
+ createReferencePlanes();
243
+ }
244
+
245
+ /**
246
+ * Create semi-transparent reference planes (XY, XZ, YZ)
247
+ */
248
+ function createReferencePlanes() {
249
+ const planeSize = 200;
250
+ const planeAlpha = 0.05;
251
+
252
+ // XY plane (Z = 0)
253
+ const xyGeom = new THREE.PlaneGeometry(planeSize, planeSize);
254
+ const xyMat = new THREE.MeshBasicMaterial({
255
+ color: 0x0088ff,
256
+ transparent: true,
257
+ opacity: planeAlpha,
258
+ side: THREE.DoubleSide,
259
+ });
260
+ referencePlanes.xy = new THREE.Mesh(xyGeom, xyMat);
261
+ referencePlanes.xy.position.z = 0;
262
+ referencePlanes.xy.userData.refPlane = true;
263
+
264
+ // XZ plane (Y = 0)
265
+ const xzGeom = new THREE.PlaneGeometry(planeSize, planeSize);
266
+ const xzMat = new THREE.MeshBasicMaterial({
267
+ color: 0x00ff88,
268
+ transparent: true,
269
+ opacity: planeAlpha,
270
+ side: THREE.DoubleSide,
271
+ });
272
+ referencePlanes.xz = new THREE.Mesh(xzGeom, xzMat);
273
+ referencePlanes.xz.rotation.x = Math.PI / 2;
274
+ referencePlanes.xz.position.y = 0;
275
+ referencePlanes.xz.userData.refPlane = true;
276
+
277
+ // YZ plane (X = 0)
278
+ const yzGeom = new THREE.PlaneGeometry(planeSize, planeSize);
279
+ const yzMat = new THREE.MeshBasicMaterial({
280
+ color: 0xff8800,
281
+ transparent: true,
282
+ opacity: planeAlpha,
283
+ side: THREE.DoubleSide,
284
+ });
285
+ referencePlanes.yz = new THREE.Mesh(yzGeom, yzMat);
286
+ referencePlanes.yz.rotation.y = Math.PI / 2;
287
+ referencePlanes.yz.position.x = 0;
288
+ referencePlanes.yz.userData.refPlane = true;
289
+
290
+ // Planes are created but not added to scene by default
291
+ }
292
+
293
+ // ============================================================================
294
+ // Camera View Management
295
+ // ============================================================================
296
+
297
+ /**
298
+ * Set preset camera view with smooth animated transition
299
+ * @param {string} viewName - View name: 'front', 'back', 'top', 'bottom', 'left', 'right', 'iso'
300
+ * @param {number} duration - Animation duration in ms (default: 800)
301
+ */
302
+ export function setView(viewName, duration = 800) {
303
+ const viewDef = PRESET_VIEWS[viewName];
304
+ if (!viewDef) {
305
+ console.warn(`Unknown view: ${viewName}. Available: ${Object.keys(PRESET_VIEWS).join(', ')}`);
306
+ return;
307
+ }
308
+
309
+ // Calculate distance based on scene bounds
310
+ const distance = calculateOptimalDistance();
311
+
312
+ const startPos = camera.position.clone();
313
+ const startTarget = new THREE.Vector3();
314
+ controls.getTarget(startTarget);
315
+
316
+ const endPos = new THREE.Vector3(
317
+ viewDef.pos.x * distance,
318
+ viewDef.pos.y * distance,
319
+ viewDef.pos.z * distance
320
+ );
321
+ const endTarget = new THREE.Vector3(
322
+ viewDef.target.x,
323
+ viewDef.target.y,
324
+ viewDef.target.z
325
+ );
326
+
327
+ animateCamera(startPos, endPos, startTarget, endTarget, duration);
328
+ }
329
+
330
+ /**
331
+ * Fit camera to view an object with proper framing
332
+ * @param {THREE.Object3D} object - Object to fit to
333
+ * @param {number} padding - Padding factor (default: 1.2)
334
+ */
335
+ export function fitToObject(object, padding = 1.2) {
336
+ const box = new THREE.Box3().setFromObject(object);
337
+ const size = box.getSize(new THREE.Vector3());
338
+ const maxDim = Math.max(size.x, size.y, size.z);
339
+ const fov = camera.fov * (Math.PI / 180); // Convert to radians
340
+ let cameraDistance = maxDim / 2 / Math.tan(fov / 2);
341
+ cameraDistance *= padding;
342
+
343
+ const center = box.getCenter(new THREE.Vector3());
344
+
345
+ const direction = camera.position.clone().sub(center).normalize();
346
+ const newPos = center.clone().addScaledVector(direction, cameraDistance);
347
+
348
+ animateCamera(camera.position.clone(), newPos, center.clone(), center.clone(), 600);
349
+ }
350
+
351
+ /**
352
+ * Animate camera from one position/target to another
353
+ * @private
354
+ */
355
+ function animateCamera(startPos, endPos, startTarget, endTarget, duration) {
356
+ cameraAnimationState.isTransitioning = true;
357
+ cameraAnimationState.startPos = startPos.clone();
358
+ cameraAnimationState.endPos = endPos.clone();
359
+ cameraAnimationState.startTarget = startTarget.clone();
360
+ cameraAnimationState.endTarget = endTarget.clone();
361
+ cameraAnimationState.startTime = Date.now();
362
+ cameraAnimationState.duration = duration;
363
+ }
364
+
365
+ /**
366
+ * Update camera animation (called from animation loop)
367
+ * @private
368
+ */
369
+ function updateCameraAnimation() {
370
+ if (!cameraAnimationState.isTransitioning) return;
371
+
372
+ const elapsed = Date.now() - cameraAnimationState.startTime;
373
+ let t = Math.min(elapsed / cameraAnimationState.duration, 1);
374
+
375
+ // Ease-in-out cubic
376
+ t = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
377
+
378
+ // Interpolate position and target
379
+ camera.position.lerpVectors(
380
+ cameraAnimationState.startPos,
381
+ cameraAnimationState.endPos,
382
+ t
383
+ );
384
+
385
+ const currentTarget = new THREE.Vector3();
386
+ currentTarget.lerpVectors(
387
+ cameraAnimationState.startTarget,
388
+ cameraAnimationState.endTarget,
389
+ t
390
+ );
391
+
392
+ controls.target.copy(currentTarget);
393
+
394
+ if (t >= 1) {
395
+ cameraAnimationState.isTransitioning = false;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Calculate optimal camera distance based on scene bounds
401
+ * @private
402
+ */
403
+ function calculateOptimalDistance() {
404
+ const box = new THREE.Box3();
405
+ scene.traverse((obj) => {
406
+ if (obj.geometry) {
407
+ box.expandByObject(obj);
408
+ }
409
+ });
410
+
411
+ if (!box.isEmpty()) {
412
+ const size = box.getSize(new THREE.Vector3());
413
+ return Math.max(size.x, size.y, size.z) * 1.5;
414
+ }
415
+
416
+ return 200; // Default fallback
417
+ }
418
+
419
+ // ============================================================================
420
+ // Scene Management
421
+ // ============================================================================
422
+
423
+ /**
424
+ * Add an object to the scene
425
+ * @param {THREE.Object3D} object - Object to add
426
+ */
427
+ export function addToScene(object) {
428
+ if (scene) {
429
+ scene.add(object);
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Remove an object from the scene
435
+ * @param {THREE.Object3D} object - Object to remove
436
+ */
437
+ export function removeFromScene(object) {
438
+ if (scene) {
439
+ scene.remove(object);
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Toggle grid visibility
445
+ * @param {boolean} visible - Show/hide grid
446
+ */
447
+ export function toggleGrid(visible) {
448
+ if (gridHelper) {
449
+ gridHelper.visible = visible !== false;
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Toggle axis lines visibility
455
+ * @param {boolean} visible - Show/hide axes
456
+ */
457
+ export function toggleAxisLines(visible) {
458
+ if (axisLines) {
459
+ axisLines.visible = visible !== false;
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Toggle reference planes visibility
465
+ * @param {boolean} visible - Show/hide reference planes
466
+ * @param {string} plane - Specific plane: 'xy', 'xz', 'yz', or null for all
467
+ */
468
+ export function toggleReferencePlanes(visible, plane = null) {
469
+ if (plane) {
470
+ if (referencePlanes[plane]) {
471
+ if (visible) {
472
+ if (!referencePlanes[plane].parent) {
473
+ scene.add(referencePlanes[plane]);
474
+ }
475
+ referencePlanes[plane].visible = true;
476
+ } else {
477
+ referencePlanes[plane].visible = false;
478
+ }
479
+ }
480
+ } else {
481
+ // Toggle all planes
482
+ ['xy', 'xz', 'yz'].forEach((p) => {
483
+ if (visible) {
484
+ if (!referencePlanes[p].parent) {
485
+ scene.add(referencePlanes[p]);
486
+ }
487
+ referencePlanes[p].visible = true;
488
+ } else {
489
+ referencePlanes[p].visible = false;
490
+ }
491
+ });
492
+ }
493
+ }
494
+
495
+ // ============================================================================
496
+ // Accessors
497
+ // ============================================================================
498
+
499
+ /**
500
+ * Get the Three.js scene
501
+ */
502
+ export function getScene() {
503
+ return scene;
504
+ }
505
+
506
+ /**
507
+ * Get the PerspectiveCamera
508
+ */
509
+ export function getCamera() {
510
+ return camera;
511
+ }
512
+
513
+ /**
514
+ * Get the WebGLRenderer
515
+ */
516
+ export function getRenderer() {
517
+ return renderer;
518
+ }
519
+
520
+ /**
521
+ * Get OrbitControls instance
522
+ */
523
+ export function getControls() {
524
+ return controls;
525
+ }
526
+
527
+ // ============================================================================
528
+ // Event Handlers
529
+ // ============================================================================
530
+
531
+ /**
532
+ * Setup window resize and other event handlers
533
+ * @private
534
+ */
535
+ function setupEventHandlers(container) {
536
+ // Window resize
537
+ const handleResize = () => {
538
+ const width = container.clientWidth;
539
+ const height = container.clientHeight;
540
+
541
+ camera.aspect = width / height;
542
+ camera.updateProjectionMatrix();
543
+ renderer.setSize(width, height);
544
+ };
545
+
546
+ window.addEventListener('resize', handleResize);
547
+
548
+ // Store cleanup reference
549
+ renderer.dispose._resizeHandler = handleResize;
550
+ }
551
+
552
+ // ============================================================================
553
+ // Animation Loop
554
+ // ============================================================================
555
+
556
+ /**
557
+ * Start the main animation loop
558
+ * @private
559
+ */
560
+ function startAnimationLoop() {
561
+ isAnimating = true;
562
+
563
+ const animate = () => {
564
+ animationFrameId = requestAnimationFrame(animate);
565
+
566
+ // Update camera animation
567
+ updateCameraAnimation();
568
+
569
+ // Update controls
570
+ if (controls) {
571
+ controls.update();
572
+ }
573
+
574
+ // Render
575
+ if (renderer && scene && camera) {
576
+ renderer.render(scene, camera);
577
+ }
578
+ };
579
+
580
+ animate();
581
+ }
582
+
583
+ /**
584
+ * Stop the animation loop
585
+ * @private
586
+ */
587
+ function stopAnimationLoop() {
588
+ if (animationFrameId) {
589
+ cancelAnimationFrame(animationFrameId);
590
+ animationFrameId = null;
591
+ }
592
+ isAnimating = false;
593
+ }
594
+
595
+ // ============================================================================
596
+ // Cleanup
597
+ // ============================================================================
598
+
599
+ /**
600
+ * Dispose of all Three.js resources and cleanup event listeners
601
+ */
602
+ export function dispose() {
603
+ stopAnimationLoop();
604
+
605
+ // Cleanup geometries and materials
606
+ scene.traverse((obj) => {
607
+ if (obj.geometry) {
608
+ obj.geometry.dispose();
609
+ }
610
+ if (obj.material) {
611
+ if (Array.isArray(obj.material)) {
612
+ obj.material.forEach((m) => m.dispose());
613
+ } else {
614
+ obj.material.dispose();
615
+ }
616
+ }
617
+ });
618
+
619
+ // Cleanup renderer
620
+ if (renderer) {
621
+ renderer.dispose();
622
+ if (renderer.domElement && renderer.domElement.parentNode) {
623
+ renderer.domElement.parentNode.removeChild(renderer.domElement);
624
+ }
625
+ }
626
+
627
+ // Cleanup controls
628
+ if (controls) {
629
+ controls.dispose();
630
+ }
631
+
632
+ // Remove event listeners
633
+ window.removeEventListener('resize', renderer.dispose._resizeHandler);
634
+
635
+ // Clear references
636
+ scene = null;
637
+ camera = null;
638
+ renderer = null;
639
+ controls = null;
640
+ gridHelper = null;
641
+ axisLines = null;
642
+ referencePlanes = {};
643
+ }
Binary file
Binary file