cyclecad 1.3.2 → 2.0.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,530 @@
1
+ /**
2
+ * ViewportModule — Core 3D Rendering Engine
3
+ * Foundational LEGO block for Three.js scene, camera, renderer, and interaction
4
+ *
5
+ * Provides:
6
+ * - Scene setup (lights, grid, fog)
7
+ * - Camera (perspective + orbit controls)
8
+ * - Renderer (WebGL, shadows, MSAA)
9
+ * - Selection (raycaster + click detection)
10
+ * - View controls (preset views, fit-to-bounds)
11
+ *
12
+ * No dependencies — this is the base layer.
13
+ */
14
+
15
+ const ViewportModule = {
16
+ id: 'viewport',
17
+ name: '3D Viewport',
18
+ version: '1.0.0',
19
+ category: 'engine',
20
+ dependencies: [],
21
+ memoryEstimate: 30,
22
+ replaces: [],
23
+
24
+ async load(kernel) {
25
+ console.log('[ViewportModule] Loading...');
26
+
27
+ // Import THREE from global (via import map)
28
+ const THREE = window.THREE;
29
+ if (!THREE) throw new Error('THREE.js not loaded');
30
+
31
+ // Import OrbitControls
32
+ const { OrbitControls } = await import('https://cdn.jsdelivr.net/npm/three@r170/examples/jsm/controls/OrbitControls.js');
33
+
34
+ // === SCENE SETUP ===
35
+ const scene = new THREE.Scene();
36
+ scene.background = new THREE.Color(0x1a1a2e);
37
+ scene.fog = new THREE.Fog(0x1a1a2e, 100, 10000);
38
+
39
+ // === CAMERA ===
40
+ const camera = new THREE.PerspectiveCamera(
41
+ 60,
42
+ window.innerWidth / window.innerHeight,
43
+ 0.1,
44
+ 100000
45
+ );
46
+ camera.position.set(500, 400, 500);
47
+ camera.lookAt(0, 0, 0);
48
+
49
+ // === RENDERER ===
50
+ const renderer = new THREE.WebGLRenderer({
51
+ antialias: true,
52
+ alpha: true,
53
+ preserveDrawingBuffer: true,
54
+ powerPreference: 'high-performance'
55
+ });
56
+ renderer.setSize(window.innerWidth, window.innerHeight);
57
+ renderer.setPixelRatio(window.devicePixelRatio);
58
+ renderer.shadowMap.enabled = true;
59
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
60
+ renderer.localClippingEnabled = true; // For section cut
61
+
62
+ // === LIGHTS ===
63
+ // Ambient light for base illumination
64
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
65
+ scene.add(ambientLight);
66
+
67
+ // Directional lights for shadows
68
+ const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
69
+ dirLight1.position.set(500, 500, 500);
70
+ dirLight1.castShadow = true;
71
+ dirLight1.shadow.mapSize.width = 2048;
72
+ dirLight1.shadow.mapSize.height = 2048;
73
+ dirLight1.shadow.camera.left = -1000;
74
+ dirLight1.shadow.camera.right = 1000;
75
+ dirLight1.shadow.camera.top = 1000;
76
+ dirLight1.shadow.camera.bottom = -1000;
77
+ dirLight1.shadow.camera.far = 3000;
78
+ scene.add(dirLight1);
79
+
80
+ const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
81
+ dirLight2.position.set(-500, 300, -500);
82
+ dirLight2.castShadow = true;
83
+ scene.add(dirLight2);
84
+
85
+ const dirLight3 = new THREE.DirectionalLight(0xffffff, 0.3);
86
+ dirLight3.position.set(0, -500, 0);
87
+ scene.add(dirLight3);
88
+
89
+ // === GRID ===
90
+ const gridSize = 2000;
91
+ const gridDivisions = 20;
92
+ const grid = new THREE.GridHelper(gridSize, gridDivisions, 0x4a4a6a, 0x2a2a4a);
93
+ grid.position.y = 0;
94
+ grid.visible = true;
95
+ scene.add(grid);
96
+
97
+ // === CONTROLS ===
98
+ const controls = new OrbitControls(camera, renderer.domElement);
99
+ controls.enableDamping = true;
100
+ controls.dampingFactor = 0.05;
101
+ controls.autoRotate = false;
102
+ controls.mouseButtons = {
103
+ LEFT: THREE.MOUSE.ROTATE,
104
+ MIDDLE: THREE.MOUSE.DOLLY,
105
+ RIGHT: THREE.MOUSE.PAN
106
+ };
107
+ controls.touches = {
108
+ ONE: THREE.TOUCH.ROTATE,
109
+ TWO: THREE.TOUCH.DOLLY_PAN
110
+ };
111
+
112
+ // === RAYCASTER (Selection) ===
113
+ const raycaster = new THREE.Raycaster();
114
+ const mouse = new THREE.Vector2();
115
+ const selectedObjects = new Set();
116
+
117
+ // === STATE & TRACKING ===
118
+ const state = {
119
+ gridVisible: true,
120
+ wireframeMode: false,
121
+ shadowsEnabled: true,
122
+ animationId: null,
123
+ viewName: 'iso',
124
+ selectedMeshId: null,
125
+ meshes: new Map(), // id -> { mesh, metadata }
126
+ nextMeshId: 0
127
+ };
128
+
129
+ // === ANIMATION LOOP ===
130
+ function animate() {
131
+ state.animationId = requestAnimationFrame(animate);
132
+ controls.update();
133
+ renderer.render(scene, camera);
134
+ }
135
+
136
+ // === RESIZE HANDLER ===
137
+ function onWindowResize() {
138
+ const width = window.innerWidth;
139
+ const height = window.innerHeight;
140
+ camera.aspect = width / height;
141
+ camera.updateProjectionMatrix();
142
+ renderer.setSize(width, height);
143
+ kernel.bus.emit('viewport:resize', { width, height });
144
+ }
145
+ window.addEventListener('resize', onWindowResize);
146
+
147
+ // === CLICK HANDLER (Selection) ===
148
+ function onMouseClick(event) {
149
+ // Skip clicks on UI elements
150
+ if (event.target !== renderer.domElement) return;
151
+
152
+ mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
153
+ mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
154
+
155
+ raycaster.setFromCamera(mouse, camera);
156
+ const intersects = raycaster.intersectObjects(scene.children, true);
157
+
158
+ if (intersects.length > 0) {
159
+ const intersection = intersects[0];
160
+ const meshId = intersection.object.userData?.meshId;
161
+
162
+ if (meshId) {
163
+ state.selectedMeshId = meshId;
164
+
165
+ // Highlight selected object
166
+ selectedObjects.forEach(obj => {
167
+ obj.material.emissive.setHex(0x000000);
168
+ });
169
+ selectedObjects.clear();
170
+
171
+ intersection.object.material.emissive.setHex(0x444444);
172
+ selectedObjects.add(intersection.object);
173
+
174
+ kernel.bus.emit('part:selected', {
175
+ meshId,
176
+ point: intersection.point,
177
+ face: intersection.face,
178
+ object: intersection.object
179
+ });
180
+ }
181
+ } else {
182
+ // Deselect
183
+ selectedObjects.forEach(obj => {
184
+ obj.material.emissive.setHex(0x000000);
185
+ });
186
+ selectedObjects.clear();
187
+ state.selectedMeshId = null;
188
+ kernel.bus.emit('part:deselected', {});
189
+ }
190
+ }
191
+ renderer.domElement.addEventListener('click', onMouseClick);
192
+
193
+ // === PRESET VIEWS ===
194
+ const presetViews = {
195
+ front: { pos: [0, 0, 500], target: [0, 0, 0] },
196
+ back: { pos: [0, 0, -500], target: [0, 0, 0] },
197
+ left: { pos: [-500, 0, 0], target: [0, 0, 0] },
198
+ right: { pos: [500, 0, 0], target: [0, 0, 0] },
199
+ top: { pos: [0, 500, 0], target: [0, 0, 0] },
200
+ bottom: { pos: [0, -500, 0], target: [0, 0, 0] },
201
+ iso: { pos: [500, 400, 500], target: [0, 0, 0] }
202
+ };
203
+
204
+ // === HELPER FUNCTIONS ===
205
+
206
+ /**
207
+ * Calculate bounds and fit camera
208
+ */
209
+ function calculateBounds(object = scene) {
210
+ const box = new THREE.Box3().setFromObject(object);
211
+ if (box.isEmpty()) {
212
+ return { center: new THREE.Vector3(), size: 100 };
213
+ }
214
+ const size = box.getSize(new THREE.Vector3());
215
+ const center = box.getCenter(new THREE.Vector3());
216
+ return { box, center, size };
217
+ }
218
+
219
+ /**
220
+ * Fit camera to object with animation
221
+ */
222
+ function fitToObject(meshOrId, animate = true) {
223
+ let targetObj = meshOrId;
224
+
225
+ if (typeof meshOrId === 'string') {
226
+ const entry = state.meshes.get(meshOrId);
227
+ if (!entry) return console.warn(`[Viewport] Mesh ${meshOrId} not found`);
228
+ targetObj = entry.mesh;
229
+ }
230
+
231
+ const { box, center, size } = calculateBounds(targetObj);
232
+ const maxDim = Math.max(size.x, size.y, size.z);
233
+ const fov = camera.fov * (Math.PI / 180);
234
+ const distance = maxDim / (2 * Math.tan(fov / 2)) * 1.5;
235
+
236
+ const direction = camera.position.clone().sub(center).normalize();
237
+ const newPos = center.clone().add(direction.multiplyScalar(distance));
238
+
239
+ if (animate) {
240
+ // Smooth animation to new position
241
+ const startPos = camera.position.clone();
242
+ const startTime = performance.now();
243
+ const duration = 800; // ms
244
+
245
+ function animateCamera(currentTime) {
246
+ const elapsed = currentTime - startTime;
247
+ const progress = Math.min(elapsed / duration, 1);
248
+
249
+ camera.position.lerpVectors(startPos, newPos, progress);
250
+ controls.target.lerpVectors(controls.target, center, progress);
251
+ controls.update();
252
+
253
+ if (progress < 1) {
254
+ requestAnimationFrame(animateCamera);
255
+ }
256
+ }
257
+ requestAnimationFrame(animateCamera);
258
+ } else {
259
+ camera.position.copy(newPos);
260
+ controls.target.copy(center);
261
+ controls.update();
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Set to preset view
267
+ */
268
+ function setView(viewName) {
269
+ const view = presetViews[viewName];
270
+ if (!view) return console.warn(`[Viewport] Unknown view: ${viewName}`);
271
+
272
+ state.viewName = viewName;
273
+ camera.position.set(...view.pos);
274
+ controls.target.set(...view.target);
275
+ controls.update();
276
+ }
277
+
278
+ /**
279
+ * Toggle grid visibility
280
+ */
281
+ function toggleGrid() {
282
+ state.gridVisible = !state.gridVisible;
283
+ grid.visible = state.gridVisible;
284
+ }
285
+
286
+ /**
287
+ * Toggle wireframe mode
288
+ */
289
+ function toggleWireframe() {
290
+ state.wireframeMode = !state.wireframeMode;
291
+ scene.traverse(obj => {
292
+ if (obj.isMesh && obj.material) {
293
+ if (Array.isArray(obj.material)) {
294
+ obj.material.forEach(m => m.wireframe = state.wireframeMode);
295
+ } else {
296
+ obj.material.wireframe = state.wireframeMode;
297
+ }
298
+ }
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Toggle shadows
304
+ */
305
+ function toggleShadows() {
306
+ state.shadowsEnabled = !state.shadowsEnabled;
307
+ renderer.shadowMap.enabled = state.shadowsEnabled;
308
+ scene.traverse(obj => {
309
+ if (obj.isMesh) {
310
+ obj.castShadow = state.shadowsEnabled;
311
+ obj.receiveShadow = state.shadowsEnabled;
312
+ }
313
+ });
314
+ }
315
+
316
+ /**
317
+ * Add mesh to scene
318
+ */
319
+ function addMesh(geometry, material, name = 'Mesh') {
320
+ const mesh = new THREE.Mesh(geometry, material);
321
+ mesh.castShadow = state.shadowsEnabled;
322
+ mesh.receiveShadow = state.shadowsEnabled;
323
+
324
+ const meshId = `mesh_${state.nextMeshId++}`;
325
+ mesh.userData.meshId = meshId;
326
+
327
+ scene.add(mesh);
328
+ state.meshes.set(meshId, { mesh, geometry, material, name });
329
+
330
+ return meshId;
331
+ }
332
+
333
+ /**
334
+ * Remove mesh from scene
335
+ */
336
+ function removeMesh(meshId) {
337
+ const entry = state.meshes.get(meshId);
338
+ if (!entry) return;
339
+
340
+ scene.remove(entry.mesh);
341
+ entry.geometry.dispose();
342
+ entry.material.dispose?.();
343
+ state.meshes.delete(meshId);
344
+ }
345
+
346
+ /**
347
+ * Set background color
348
+ */
349
+ function setBackground(color) {
350
+ scene.background = new THREE.Color(color);
351
+ scene.fog.color.set(color);
352
+ }
353
+
354
+ /**
355
+ * Capture screenshot
356
+ */
357
+ function screenshot(width = 1920, height = 1080) {
358
+ const oldSize = renderer.getSize(new THREE.Vector2());
359
+ renderer.setSize(width, height);
360
+ renderer.render(scene, camera);
361
+ const dataUrl = renderer.domElement.toDataURL('image/png');
362
+ renderer.setSize(oldSize.width, oldSize.height);
363
+ renderer.render(scene, camera);
364
+ return dataUrl;
365
+ }
366
+
367
+ // === REGISTER WITH KERNEL ===
368
+ kernel.state.set('scene', scene);
369
+ kernel.state.set('camera', camera);
370
+ kernel.state.set('renderer', renderer);
371
+ kernel.state.set('controls', controls);
372
+ kernel.state.set('raycaster', raycaster);
373
+
374
+ // Store module methods for later access
375
+ kernel.state.set('viewport', {
376
+ scene, camera, renderer, controls,
377
+ fitToObject, setView, toggleGrid, toggleWireframe, toggleShadows,
378
+ addMesh, removeMesh, setBackground, screenshot,
379
+ calculateBounds, state
380
+ });
381
+
382
+ console.log('[ViewportModule] Loaded successfully');
383
+ return { scene, camera, renderer, controls };
384
+ },
385
+
386
+ async activate(kernel) {
387
+ console.log('[ViewportModule] Activating...');
388
+
389
+ const container = document.getElementById('viewport-container') || (() => {
390
+ const div = document.createElement('div');
391
+ div.id = 'viewport-container';
392
+ div.style.cssText = 'position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden;';
393
+ document.body.appendChild(div);
394
+ return div;
395
+ })();
396
+
397
+ const viewport = kernel.state.get('viewport');
398
+ const renderer = kernel.state.get('renderer');
399
+
400
+ container.appendChild(renderer.domElement);
401
+
402
+ // Start animation loop
403
+ const { state } = viewport;
404
+ function animate() {
405
+ state.animationId = requestAnimationFrame(animate);
406
+ const controls = kernel.state.get('controls');
407
+ controls.update();
408
+ renderer.render(kernel.state.get('scene'), kernel.state.get('camera'));
409
+ }
410
+ animate();
411
+
412
+ kernel.bus.emit('viewport:ready', {});
413
+ console.log('[ViewportModule] Activated');
414
+ },
415
+
416
+ async deactivate(kernel) {
417
+ console.log('[ViewportModule] Deactivating...');
418
+
419
+ const viewport = kernel.state.get('viewport');
420
+ const renderer = kernel.state.get('renderer');
421
+
422
+ if (viewport.state.animationId) {
423
+ cancelAnimationFrame(viewport.state.animationId);
424
+ }
425
+
426
+ const container = document.getElementById('viewport-container');
427
+ if (container && renderer.domElement.parentNode === container) {
428
+ container.removeChild(renderer.domElement);
429
+ }
430
+
431
+ console.log('[ViewportModule] Deactivated');
432
+ },
433
+
434
+ async unload(kernel) {
435
+ console.log('[ViewportModule] Unloading...');
436
+
437
+ const scene = kernel.state.get('scene');
438
+ const renderer = kernel.state.get('renderer');
439
+ const viewport = kernel.state.get('viewport');
440
+
441
+ // Dispose all geometries and materials
442
+ scene.traverse(obj => {
443
+ if (obj.geometry) obj.geometry.dispose();
444
+ if (obj.material) {
445
+ if (Array.isArray(obj.material)) {
446
+ obj.material.forEach(m => m.dispose());
447
+ } else {
448
+ obj.material.dispose();
449
+ }
450
+ }
451
+ });
452
+
453
+ // Dispose renderer
454
+ renderer.dispose();
455
+ renderer.forceContextLoss();
456
+
457
+ // Clear state
458
+ kernel.state.delete('scene');
459
+ kernel.state.delete('camera');
460
+ kernel.state.delete('renderer');
461
+ kernel.state.delete('controls');
462
+ kernel.state.delete('raycaster');
463
+ kernel.state.delete('viewport');
464
+
465
+ console.log('[ViewportModule] Unloaded');
466
+ },
467
+
468
+ provides: {
469
+ commands: {
470
+ 'viewport.fitAll': (kernel) => () => {
471
+ const viewport = kernel.state.get('viewport');
472
+ viewport.fitToObject(kernel.state.get('scene'));
473
+ },
474
+
475
+ 'viewport.fitTo': (kernel) => (meshId) => {
476
+ const viewport = kernel.state.get('viewport');
477
+ viewport.fitToObject(meshId);
478
+ },
479
+
480
+ 'viewport.setView': (kernel) => (viewName) => {
481
+ const viewport = kernel.state.get('viewport');
482
+ viewport.setView(viewName);
483
+ },
484
+
485
+ 'viewport.toggleGrid': (kernel) => () => {
486
+ const viewport = kernel.state.get('viewport');
487
+ viewport.toggleGrid();
488
+ },
489
+
490
+ 'viewport.toggleWireframe': (kernel) => () => {
491
+ const viewport = kernel.state.get('viewport');
492
+ viewport.toggleWireframe();
493
+ },
494
+
495
+ 'viewport.toggleShadows': (kernel) => () => {
496
+ const viewport = kernel.state.get('viewport');
497
+ viewport.toggleShadows();
498
+ },
499
+
500
+ 'viewport.addMesh': (kernel) => (geometry, material, name) => {
501
+ const viewport = kernel.state.get('viewport');
502
+ return viewport.addMesh(geometry, material, name);
503
+ },
504
+
505
+ 'viewport.removeMesh': (kernel) => (meshId) => {
506
+ const viewport = kernel.state.get('viewport');
507
+ viewport.removeMesh(meshId);
508
+ },
509
+
510
+ 'viewport.setBackground': (kernel) => (color) => {
511
+ const viewport = kernel.state.get('viewport');
512
+ viewport.setBackground(color);
513
+ },
514
+
515
+ 'viewport.screenshot': (kernel) => (width, height) => {
516
+ const viewport = kernel.state.get('viewport');
517
+ return viewport.screenshot(width, height);
518
+ }
519
+ },
520
+
521
+ events: [
522
+ 'part:selected',
523
+ 'part:deselected',
524
+ 'viewport:ready',
525
+ 'viewport:resize'
526
+ ]
527
+ }
528
+ };
529
+
530
+ export default ViewportModule;