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,899 @@
1
+ /**
2
+ * CycleCAD 2D Sketch Engine
3
+ * Browser-based parametric 3D modeler sketch module
4
+ * ES Module - converts 2D sketches to 3D wireframe geometry
5
+ */
6
+
7
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
8
+
9
+ // State machine for sketch mode
10
+ let sketchState = {
11
+ active: false,
12
+ plane: null, // 'XY', 'XZ', or 'YZ'
13
+ camera: null,
14
+ controls: null,
15
+ canvas: null,
16
+ ctx: null,
17
+ raycaster: new THREE.Raycaster(),
18
+
19
+ currentTool: 'line',
20
+ isDrawing: false,
21
+ entities: [], // Final confirmed entities
22
+ inProgressEntity: null, // Currently being drawn
23
+ currentPoints: [], // Points of current entity
24
+
25
+ gridSize: 1, // 1mm grid
26
+ snapDistance: 8, // pixels
27
+ snapEnabled: true,
28
+ snapPoint: null, // {x, y} in world coords
29
+
30
+ constraints: [], // {type, entity1, entity2, value}
31
+ };
32
+
33
+ /**
34
+ * Start sketch mode on specified plane
35
+ * @param {string} plane - 'XY', 'XZ', or 'YZ'
36
+ * @param {THREE.Camera} camera - Scene camera
37
+ * @param {Object} controls - OrbitControls instance
38
+ */
39
+ export function startSketch(plane, camera, controls) {
40
+ if (sketchState.active) endSketch();
41
+
42
+ sketchState.active = true;
43
+ sketchState.plane = plane;
44
+ sketchState.camera = camera;
45
+ sketchState.controls = controls;
46
+ sketchState.entities = [];
47
+ sketchState.inProgressEntity = null;
48
+ sketchState.currentPoints = [];
49
+
50
+ // Disable orbit controls
51
+ if (controls) controls.enabled = false;
52
+
53
+ // Create canvas overlay
54
+ const container = document.body;
55
+ const canvas = document.createElement('canvas');
56
+ canvas.id = 'sketch-canvas';
57
+ canvas.width = window.innerWidth;
58
+ canvas.height = window.innerHeight;
59
+ canvas.style.position = 'fixed';
60
+ canvas.style.top = '0';
61
+ canvas.style.left = '0';
62
+ canvas.style.cursor = 'crosshair';
63
+ canvas.style.zIndex = '9999';
64
+ canvas.style.backgroundColor = 'rgba(0,0,0,0)';
65
+
66
+ container.appendChild(canvas);
67
+ sketchState.canvas = canvas;
68
+ sketchState.ctx = canvas.getContext('2d');
69
+
70
+ // Position camera perpendicular to plane
71
+ _positionCameraForPlane(plane, camera);
72
+
73
+ // Setup event listeners
74
+ canvas.addEventListener('mousemove', _onSketchMouseMove);
75
+ canvas.addEventListener('click', _onSketchClick);
76
+ canvas.addEventListener('contextmenu', _onSketchContextMenu);
77
+ canvas.addEventListener('dblclick', _onSketchDoubleClick);
78
+ canvas.addEventListener('keydown', _onSketchKeyDown);
79
+ window.addEventListener('resize', _onSketchResize);
80
+
81
+ // Initial render
82
+ _renderSketchCanvas();
83
+ }
84
+
85
+ /**
86
+ * End sketch mode and return entities
87
+ * @returns {Array} Array of sketch entities
88
+ */
89
+ export function endSketch() {
90
+ if (!sketchState.active) return [];
91
+
92
+ // Finalize any in-progress entity
93
+ if (sketchState.inProgressEntity && sketchState.currentPoints.length > 0) {
94
+ _finalizeEntity();
95
+ }
96
+
97
+ sketchState.active = false;
98
+
99
+ // Restore controls
100
+ if (sketchState.controls) {
101
+ sketchState.controls.enabled = true;
102
+ }
103
+
104
+ // Remove canvas
105
+ if (sketchState.canvas) {
106
+ sketchState.canvas.removeEventListener('mousemove', _onSketchMouseMove);
107
+ sketchState.canvas.removeEventListener('click', _onSketchClick);
108
+ sketchState.canvas.removeEventListener('contextmenu', _onSketchContextMenu);
109
+ sketchState.canvas.removeEventListener('dblclick', _onSketchDoubleClick);
110
+ sketchState.canvas.removeEventListener('keydown', _onSketchKeyDown);
111
+ sketchState.canvas.remove();
112
+ }
113
+
114
+ window.removeEventListener('resize', _onSketchResize);
115
+
116
+ const result = [...sketchState.entities];
117
+ clearSketch();
118
+ return result;
119
+ }
120
+
121
+ /**
122
+ * Set active drawing tool
123
+ * @param {string} toolName - 'line', 'rectangle', 'circle', 'arc', 'polyline'
124
+ */
125
+ export function setTool(toolName) {
126
+ if (sketchState.active && sketchState.inProgressEntity) {
127
+ _finalizeEntity();
128
+ }
129
+ sketchState.currentTool = toolName.toLowerCase();
130
+ sketchState.currentPoints = [];
131
+ _renderSketchCanvas();
132
+ }
133
+
134
+ /**
135
+ * Get current sketch entities
136
+ * @returns {Array} Array of entities with type, points, dimensions
137
+ */
138
+ export function getEntities() {
139
+ return JSON.parse(JSON.stringify(sketchState.entities));
140
+ }
141
+
142
+ /**
143
+ * Clear sketch (reset to initial state)
144
+ */
145
+ export function clearSketch() {
146
+ sketchState.entities = [];
147
+ sketchState.inProgressEntity = null;
148
+ sketchState.currentPoints = [];
149
+ sketchState.constraints = [];
150
+ sketchState.snapPoint = null;
151
+ _renderSketchCanvas();
152
+ }
153
+
154
+ /**
155
+ * Toggle grid snapping
156
+ * @param {boolean} enabled
157
+ */
158
+ export function setSnapEnabled(enabled) {
159
+ sketchState.snapEnabled = enabled;
160
+ }
161
+
162
+ /**
163
+ * Set grid size in mm
164
+ * @param {number} size
165
+ */
166
+ export function setGridSize(size) {
167
+ sketchState.gridSize = size;
168
+ }
169
+
170
+ /**
171
+ * Convert client canvas coordinates to world coordinates on sketch plane
172
+ * @param {number} clientX
173
+ * @param {number} clientY
174
+ * @param {THREE.Camera} camera
175
+ * @param {string} plane
176
+ * @returns {Object} {x, y, z} in world coordinates
177
+ */
178
+ export function canvasToWorld(clientX, clientY, camera, plane) {
179
+ const canvas = sketchState.canvas;
180
+ if (!canvas) return { x: 0, y: 0, z: 0 };
181
+
182
+ // Normalized device coordinates
183
+ const ndcX = (clientX / canvas.width) * 2 - 1;
184
+ const ndcY = -(clientY / canvas.height) * 2 + 1;
185
+
186
+ // Ray from camera through pixel
187
+ const raycaster = sketchState.raycaster;
188
+ raycaster.setFromCamera({ x: ndcX, y: ndcY }, camera);
189
+
190
+ // Create plane perpendicular to sketch axis
191
+ const planeNormal = _getPlaneNormal(plane);
192
+ const planeObj = new THREE.Plane(planeNormal, 0);
193
+ const intersection = new THREE.Vector3();
194
+
195
+ raycaster.ray.intersectPlane(planeObj, intersection);
196
+
197
+ return { x: intersection.x, y: intersection.y, z: intersection.z };
198
+ }
199
+
200
+ /**
201
+ * Convert world coordinates to canvas (screen) coordinates
202
+ * @param {number} wx - World X
203
+ * @param {number} wy - World Y
204
+ * @param {THREE.Camera} camera
205
+ * @param {number} canvasW - Canvas width
206
+ * @param {number} canvasH - Canvas height
207
+ * @returns {Object} {x, y} in screen pixels
208
+ */
209
+ export function worldToCanvas(wx, wy, camera, canvasW, canvasH) {
210
+ const v = new THREE.Vector3(wx, wy, 0);
211
+ v.project(camera);
212
+
213
+ const screenX = (v.x + 1) * canvasW / 2;
214
+ const screenY = (-v.y + 1) * canvasH / 2;
215
+
216
+ return { x: screenX, y: screenY };
217
+ }
218
+
219
+ /**
220
+ * Undo last entity
221
+ */
222
+ export function undo() {
223
+ if (sketchState.entities.length > 0) {
224
+ sketchState.entities.pop();
225
+ _renderSketchCanvas();
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Convert sketch entities to Three.js 3D geometry
231
+ * @param {Array} entities - Sketch entities from getEntities()
232
+ * @returns {THREE.Group} 3D wireframe group
233
+ */
234
+ export function entitiesToGeometry(entities) {
235
+ const group = new THREE.Group();
236
+ const material = new THREE.LineBasicMaterial({ color: 0x58a6ff, linewidth: 2 });
237
+
238
+ entities.forEach(entity => {
239
+ let geometry;
240
+
241
+ switch (entity.type) {
242
+ case 'line':
243
+ geometry = new THREE.BufferGeometry();
244
+ geometry.setFromPoints([
245
+ new THREE.Vector3(entity.points[0].x, entity.points[0].y, 0),
246
+ new THREE.Vector3(entity.points[1].x, entity.points[1].y, 0)
247
+ ]);
248
+ group.add(new THREE.Line(geometry, material));
249
+ break;
250
+
251
+ case 'rectangle':
252
+ geometry = new THREE.BufferGeometry();
253
+ const pts = entity.points;
254
+ const corners = [
255
+ pts[0],
256
+ new THREE.Vector2(pts[1].x, pts[0].y),
257
+ pts[1],
258
+ new THREE.Vector2(pts[0].x, pts[1].y),
259
+ pts[0]
260
+ ];
261
+ geometry.setFromPoints(
262
+ corners.map(p => new THREE.Vector3(p.x, p.y, 0))
263
+ );
264
+ group.add(new THREE.Line(geometry, material));
265
+ break;
266
+
267
+ case 'circle':
268
+ geometry = new THREE.BufferGeometry();
269
+ const radius = entity.dimensions.radius;
270
+ const points = [];
271
+ for (let i = 0; i <= 32; i++) {
272
+ const angle = (i / 32) * Math.PI * 2;
273
+ points.push(new THREE.Vector3(
274
+ entity.points[0].x + radius * Math.cos(angle),
275
+ entity.points[0].y + radius * Math.sin(angle),
276
+ 0
277
+ ));
278
+ }
279
+ geometry.setFromPoints(points);
280
+ group.add(new THREE.Line(geometry, material));
281
+ break;
282
+
283
+ case 'arc':
284
+ geometry = new THREE.BufferGeometry();
285
+ const arcRadius = Math.hypot(
286
+ entity.points[1].x - entity.points[0].x,
287
+ entity.points[1].y - entity.points[0].y
288
+ );
289
+ const startAngle = Math.atan2(
290
+ entity.points[1].y - entity.points[0].y,
291
+ entity.points[1].x - entity.points[0].x
292
+ );
293
+ const endAngle = Math.atan2(
294
+ entity.points[2].y - entity.points[0].y,
295
+ entity.points[2].x - entity.points[0].x
296
+ );
297
+ const arcPoints = [];
298
+ const steps = 32;
299
+ for (let i = 0; i <= steps; i++) {
300
+ const angle = startAngle + (endAngle - startAngle) * (i / steps);
301
+ arcPoints.push(new THREE.Vector3(
302
+ entity.points[0].x + arcRadius * Math.cos(angle),
303
+ entity.points[0].y + arcRadius * Math.sin(angle),
304
+ 0
305
+ ));
306
+ }
307
+ geometry.setFromPoints(arcPoints);
308
+ group.add(new THREE.Line(geometry, material));
309
+ break;
310
+
311
+ case 'polyline':
312
+ geometry = new THREE.BufferGeometry();
313
+ geometry.setFromPoints(
314
+ entity.points.map(p => new THREE.Vector3(p.x, p.y, 0))
315
+ );
316
+ group.add(new THREE.Line(geometry, material));
317
+ break;
318
+ }
319
+ });
320
+
321
+ return group;
322
+ }
323
+
324
+ // ============================================================================
325
+ // PRIVATE FUNCTIONS
326
+ // ============================================================================
327
+
328
+ /**
329
+ * Position camera perpendicular to sketch plane
330
+ */
331
+ function _positionCameraForPlane(plane, camera) {
332
+ const distance = 50;
333
+
334
+ switch (plane) {
335
+ case 'XY':
336
+ camera.position.set(0, 0, distance);
337
+ camera.lookAt(0, 0, 0);
338
+ break;
339
+ case 'XZ':
340
+ camera.position.set(0, distance, 0);
341
+ camera.lookAt(0, 0, 0);
342
+ break;
343
+ case 'YZ':
344
+ camera.position.set(distance, 0, 0);
345
+ camera.lookAt(0, 0, 0);
346
+ break;
347
+ }
348
+ camera.updateProjectionMatrix();
349
+ }
350
+
351
+ /**
352
+ * Get plane normal vector
353
+ */
354
+ function _getPlaneNormal(plane) {
355
+ switch (plane) {
356
+ case 'XY': return new THREE.Vector3(0, 0, 1);
357
+ case 'XZ': return new THREE.Vector3(0, 1, 0);
358
+ case 'YZ': return new THREE.Vector3(1, 0, 0);
359
+ default: return new THREE.Vector3(0, 0, 1);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Convert world point to 2D sketch coordinates (based on active plane)
365
+ */
366
+ function _worldToSketch(worldPoint, plane) {
367
+ switch (plane) {
368
+ case 'XY': return { x: worldPoint.x, y: worldPoint.y };
369
+ case 'XZ': return { x: worldPoint.x, y: worldPoint.z };
370
+ case 'YZ': return { x: worldPoint.y, y: worldPoint.z };
371
+ default: return { x: 0, y: 0 };
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Convert 2D sketch coordinates to world point
377
+ */
378
+ function _sketchToWorld(sketchPoint, plane) {
379
+ switch (plane) {
380
+ case 'XY': return { x: sketchPoint.x, y: sketchPoint.y, z: 0 };
381
+ case 'XZ': return { x: sketchPoint.x, y: 0, z: sketchPoint.y };
382
+ case 'YZ': return { x: 0, y: sketchPoint.x, z: sketchPoint.y };
383
+ default: return { x: 0, y: 0, z: 0 };
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Find snap point on grid
389
+ */
390
+ function _findSnapPoint(clientX, clientY) {
391
+ const worldPoint = canvasToWorld(clientX, clientY, sketchState.camera, sketchState.plane);
392
+ const sketchPoint = _worldToSketch(worldPoint, sketchState.plane);
393
+
394
+ // Snap to grid
395
+ const gridSize = sketchState.gridSize;
396
+ const snappedX = Math.round(sketchPoint.x / gridSize) * gridSize;
397
+ const snappedY = Math.round(sketchPoint.y / gridSize) * gridSize;
398
+
399
+ // Convert back to screen coords to check distance
400
+ const canvas = sketchState.canvas;
401
+ const screenSnapped = worldToCanvas(
402
+ _sketchToWorld({ x: snappedX, y: snappedY }, sketchState.plane).x,
403
+ _sketchToWorld({ x: snappedX, y: snappedY }, sketchState.plane).y,
404
+ sketchState.camera,
405
+ canvas.width,
406
+ canvas.height
407
+ );
408
+
409
+ const dist = Math.hypot(screenSnapped.x - clientX, screenSnapped.y - clientY);
410
+
411
+ if (dist < sketchState.snapDistance && sketchState.snapEnabled) {
412
+ return { x: snappedX, y: snappedY, screen: screenSnapped };
413
+ }
414
+
415
+ return { x: sketchPoint.x, y: sketchPoint.y, screen: worldToCanvas(
416
+ worldPoint.x, worldPoint.y, sketchState.camera, canvas.width, canvas.height
417
+ )};
418
+ }
419
+
420
+ /**
421
+ * Check for constraints (horizontal, vertical, equal length, perpendicular)
422
+ */
423
+ function _detectConstraints(entity) {
424
+ const constraints = [];
425
+
426
+ if (entity.type === 'line') {
427
+ const dx = Math.abs(entity.points[1].x - entity.points[0].x);
428
+ const dy = Math.abs(entity.points[1].y - entity.points[0].y);
429
+ const angle = Math.atan2(dy, dx) * 180 / Math.PI;
430
+
431
+ // Horizontal
432
+ if (angle < 5 || angle > 85) {
433
+ constraints.push({ type: 'horizontal' });
434
+ entity.points[1].y = entity.points[0].y; // Lock Y
435
+ }
436
+ // Vertical
437
+ if (Math.abs(angle - 90) < 5) {
438
+ constraints.push({ type: 'vertical' });
439
+ entity.points[1].x = entity.points[0].x; // Lock X
440
+ }
441
+ }
442
+
443
+ return constraints;
444
+ }
445
+
446
+ /**
447
+ * Finalize current drawing entity
448
+ */
449
+ function _finalizeEntity() {
450
+ if (!sketchState.inProgressEntity || sketchState.currentPoints.length < 2) {
451
+ sketchState.inProgressEntity = null;
452
+ sketchState.currentPoints = [];
453
+ return;
454
+ }
455
+
456
+ const entity = {
457
+ type: sketchState.currentTool,
458
+ points: [...sketchState.currentPoints],
459
+ dimensions: {},
460
+ constraints: []
461
+ };
462
+
463
+ // Calculate dimensions
464
+ if (entity.type === 'line') {
465
+ const dx = entity.points[1].x - entity.points[0].x;
466
+ const dy = entity.points[1].y - entity.points[0].y;
467
+ entity.dimensions.length = Math.hypot(dx, dy);
468
+ entity.constraints = _detectConstraints(entity);
469
+ } else if (entity.type === 'rectangle') {
470
+ const width = Math.abs(entity.points[1].x - entity.points[0].x);
471
+ const height = Math.abs(entity.points[1].y - entity.points[0].y);
472
+ entity.dimensions.width = width;
473
+ entity.dimensions.height = height;
474
+ } else if (entity.type === 'circle') {
475
+ const radius = Math.hypot(
476
+ entity.points[1].x - entity.points[0].x,
477
+ entity.points[1].y - entity.points[0].y
478
+ );
479
+ entity.dimensions.radius = radius;
480
+ entity.dimensions.diameter = radius * 2;
481
+ } else if (entity.type === 'arc') {
482
+ if (entity.points.length >= 2) {
483
+ const radius = Math.hypot(
484
+ entity.points[1].x - entity.points[0].x,
485
+ entity.points[1].y - entity.points[0].y
486
+ );
487
+ entity.dimensions.radius = radius;
488
+ }
489
+ } else if (entity.type === 'polyline') {
490
+ let totalLength = 0;
491
+ for (let i = 0; i < entity.points.length - 1; i++) {
492
+ const dx = entity.points[i + 1].x - entity.points[i].x;
493
+ const dy = entity.points[i + 1].y - entity.points[i].y;
494
+ totalLength += Math.hypot(dx, dy);
495
+ }
496
+ entity.dimensions.length = totalLength;
497
+ }
498
+
499
+ sketchState.entities.push(entity);
500
+ sketchState.inProgressEntity = null;
501
+ sketchState.currentPoints = [];
502
+ }
503
+
504
+ /**
505
+ * Mouse move handler
506
+ */
507
+ function _onSketchMouseMove(e) {
508
+ const snap = _findSnapPoint(e.clientX, e.clientY);
509
+ sketchState.snapPoint = snap.screen;
510
+
511
+ const sketchPoint = { x: snap.x, y: snap.y };
512
+
513
+ // Update in-progress entity for rubber band preview
514
+ if (sketchState.isDrawing && sketchState.currentPoints.length > 0) {
515
+ if (sketchState.currentTool === 'line' || sketchState.currentTool === 'polyline') {
516
+ sketchState.inProgressEntity = {
517
+ type: sketchState.currentTool,
518
+ points: [...sketchState.currentPoints, sketchPoint],
519
+ dimensions: {}
520
+ };
521
+ } else if (sketchState.currentTool === 'rectangle' && sketchState.currentPoints.length === 1) {
522
+ sketchState.inProgressEntity = {
523
+ type: 'rectangle',
524
+ points: [sketchState.currentPoints[0], sketchPoint],
525
+ dimensions: {}
526
+ };
527
+ } else if (sketchState.currentTool === 'circle' && sketchState.currentPoints.length === 1) {
528
+ const radius = Math.hypot(
529
+ sketchPoint.x - sketchState.currentPoints[0].x,
530
+ sketchPoint.y - sketchState.currentPoints[0].y
531
+ );
532
+ sketchState.inProgressEntity = {
533
+ type: 'circle',
534
+ points: [sketchState.currentPoints[0], sketchPoint],
535
+ dimensions: { radius }
536
+ };
537
+ } else if (sketchState.currentTool === 'arc' && sketchState.currentPoints.length >= 1) {
538
+ sketchState.inProgressEntity = {
539
+ type: 'arc',
540
+ points: [...sketchState.currentPoints, sketchPoint],
541
+ dimensions: {}
542
+ };
543
+ }
544
+ }
545
+
546
+ _renderSketchCanvas();
547
+ }
548
+
549
+ /**
550
+ * Click handler
551
+ */
552
+ function _onSketchClick(e) {
553
+ if (e.button === 2) return; // Right-click handled separately
554
+
555
+ const snap = _findSnapPoint(e.clientX, e.clientY);
556
+ const sketchPoint = { x: snap.x, y: snap.y };
557
+
558
+ sketchState.isDrawing = true;
559
+
560
+ const tool = sketchState.currentTool;
561
+
562
+ if (tool === 'line') {
563
+ if (sketchState.currentPoints.length === 0) {
564
+ sketchState.currentPoints = [sketchPoint];
565
+ } else if (sketchState.currentPoints.length === 1) {
566
+ sketchState.currentPoints.push(sketchPoint);
567
+ _finalizeEntity();
568
+ // Chain mode: end becomes new start
569
+ sketchState.currentPoints = [sketchPoint];
570
+ }
571
+ } else if (tool === 'rectangle') {
572
+ if (sketchState.currentPoints.length === 0) {
573
+ sketchState.currentPoints = [sketchPoint];
574
+ } else if (sketchState.currentPoints.length === 1) {
575
+ sketchState.currentPoints.push(sketchPoint);
576
+ _finalizeEntity();
577
+ }
578
+ } else if (tool === 'circle') {
579
+ if (sketchState.currentPoints.length === 0) {
580
+ sketchState.currentPoints = [sketchPoint];
581
+ } else if (sketchState.currentPoints.length === 1) {
582
+ sketchState.currentPoints.push(sketchPoint);
583
+ _finalizeEntity();
584
+ }
585
+ } else if (tool === 'arc') {
586
+ if (sketchState.currentPoints.length < 3) {
587
+ sketchState.currentPoints.push(sketchPoint);
588
+ if (sketchState.currentPoints.length === 3) {
589
+ _finalizeEntity();
590
+ }
591
+ }
592
+ } else if (tool === 'polyline') {
593
+ sketchState.currentPoints.push(sketchPoint);
594
+ }
595
+
596
+ _renderSketchCanvas();
597
+ }
598
+
599
+ /**
600
+ * Right-click context menu handler
601
+ */
602
+ function _onSketchContextMenu(e) {
603
+ e.preventDefault();
604
+
605
+ if (sketchState.currentTool === 'line' && sketchState.currentPoints.length > 0) {
606
+ // Stop line chain mode
607
+ if (sketchState.currentPoints.length >= 2) {
608
+ _finalizeEntity();
609
+ }
610
+ sketchState.currentPoints = [];
611
+ sketchState.isDrawing = false;
612
+ } else if (sketchState.currentTool === 'polyline' && sketchState.currentPoints.length >= 2) {
613
+ // Close polyline
614
+ _finalizeEntity();
615
+ sketchState.isDrawing = false;
616
+ }
617
+
618
+ _renderSketchCanvas();
619
+ }
620
+
621
+ /**
622
+ * Double-click handler
623
+ */
624
+ function _onSketchDoubleClick(e) {
625
+ if (sketchState.currentTool === 'polyline' && sketchState.currentPoints.length >= 2) {
626
+ _finalizeEntity();
627
+ sketchState.isDrawing = false;
628
+ _renderSketchCanvas();
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Keyboard handler
634
+ */
635
+ function _onSketchKeyDown(e) {
636
+ if (e.key === 'Enter' && sketchState.currentTool === 'polyline') {
637
+ if (sketchState.currentPoints.length >= 2) {
638
+ _finalizeEntity();
639
+ sketchState.isDrawing = false;
640
+ }
641
+ _renderSketchCanvas();
642
+ } else if (e.key === 'Escape') {
643
+ endSketch();
644
+ } else if (e.key === 'z' && (e.ctrlKey || e.metaKey)) {
645
+ undo();
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Resize handler
651
+ */
652
+ function _onSketchResize() {
653
+ if (sketchState.canvas) {
654
+ sketchState.canvas.width = window.innerWidth;
655
+ sketchState.canvas.height = window.innerHeight;
656
+ _renderSketchCanvas();
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Render sketch canvas with grid, entities, and preview
662
+ */
663
+ function _renderSketchCanvas() {
664
+ const canvas = sketchState.canvas;
665
+ const ctx = sketchState.ctx;
666
+ if (!canvas || !ctx) return;
667
+
668
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
669
+
670
+ // Draw grid
671
+ _drawGrid(ctx, canvas.width, canvas.height);
672
+
673
+ // Draw confirmed entities (blue)
674
+ ctx.strokeStyle = '#58a6ff';
675
+ ctx.lineWidth = 2;
676
+ ctx.fillStyle = 'rgba(88, 166, 255, 0.1)';
677
+
678
+ sketchState.entities.forEach(entity => {
679
+ _drawEntity(ctx, entity, false);
680
+ });
681
+
682
+ // Draw in-progress entity (dashed blue)
683
+ if (sketchState.inProgressEntity) {
684
+ ctx.setLineDash([5, 5]);
685
+ ctx.strokeStyle = '#58a6ff';
686
+ _drawEntity(ctx, sketchState.inProgressEntity, true);
687
+ ctx.setLineDash([]);
688
+ }
689
+
690
+ // Draw snap point (green dot)
691
+ if (sketchState.snapPoint) {
692
+ ctx.fillStyle = '#00ff00';
693
+ ctx.beginPath();
694
+ ctx.arc(sketchState.snapPoint.x, sketchState.snapPoint.y, 4, 0, Math.PI * 2);
695
+ ctx.fill();
696
+ }
697
+
698
+ // Draw crosshair
699
+ if (sketchState.snapPoint) {
700
+ ctx.strokeStyle = 'rgba(88, 166, 255, 0.3)';
701
+ ctx.lineWidth = 1;
702
+ ctx.setLineDash([3, 3]);
703
+ ctx.beginPath();
704
+ ctx.moveTo(sketchState.snapPoint.x, 0);
705
+ ctx.lineTo(sketchState.snapPoint.x, canvas.height);
706
+ ctx.stroke();
707
+ ctx.beginPath();
708
+ ctx.moveTo(0, sketchState.snapPoint.y);
709
+ ctx.lineTo(canvas.width, sketchState.snapPoint.y);
710
+ ctx.stroke();
711
+ ctx.setLineDash([]);
712
+ }
713
+
714
+ // Draw dimension labels
715
+ ctx.fillStyle = '#58a6ff';
716
+ ctx.font = '12px monospace';
717
+ ctx.textAlign = 'left';
718
+
719
+ sketchState.entities.forEach(entity => {
720
+ _drawDimensionLabel(ctx, entity);
721
+ });
722
+
723
+ if (sketchState.inProgressEntity) {
724
+ _drawDimensionLabel(ctx, sketchState.inProgressEntity);
725
+ }
726
+ }
727
+
728
+ /**
729
+ * Draw grid on canvas
730
+ */
731
+ function _drawGrid(ctx, width, height) {
732
+ const gridSize = sketchState.gridSize;
733
+ const screenGridSize = 20; // pixels per grid cell for display
734
+
735
+ ctx.strokeStyle = 'rgba(88, 166, 255, 0.1)';
736
+ ctx.lineWidth = 0.5;
737
+
738
+ // Vertical lines
739
+ for (let x = 0; x < width; x += screenGridSize) {
740
+ ctx.beginPath();
741
+ ctx.moveTo(x, 0);
742
+ ctx.lineTo(x, height);
743
+ ctx.stroke();
744
+ }
745
+
746
+ // Horizontal lines
747
+ for (let y = 0; y < height; y += screenGridSize) {
748
+ ctx.beginPath();
749
+ ctx.moveTo(0, y);
750
+ ctx.lineTo(width, y);
751
+ ctx.stroke();
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Draw single entity on canvas
757
+ */
758
+ function _drawEntity(ctx, entity, isPreview) {
759
+ const canvas = sketchState.canvas;
760
+ const points = entity.points.map(p => {
761
+ const world = _sketchToWorld(p, sketchState.plane);
762
+ return worldToCanvas(world.x, world.y, sketchState.camera, canvas.width, canvas.height);
763
+ });
764
+
765
+ switch (entity.type) {
766
+ case 'line':
767
+ if (points.length >= 2) {
768
+ ctx.beginPath();
769
+ ctx.moveTo(points[0].x, points[0].y);
770
+ ctx.lineTo(points[1].x, points[1].y);
771
+ ctx.stroke();
772
+ }
773
+ break;
774
+
775
+ case 'rectangle':
776
+ if (points.length >= 2) {
777
+ const x = Math.min(points[0].x, points[1].x);
778
+ const y = Math.min(points[0].y, points[1].y);
779
+ const w = Math.abs(points[1].x - points[0].x);
780
+ const h = Math.abs(points[1].y - points[0].y);
781
+ ctx.strokeRect(x, y, w, h);
782
+ if (!isPreview) ctx.fillRect(x, y, w, h);
783
+ }
784
+ break;
785
+
786
+ case 'circle':
787
+ if (points.length >= 2) {
788
+ const radius = Math.hypot(
789
+ points[1].x - points[0].x,
790
+ points[1].y - points[0].y
791
+ );
792
+ ctx.beginPath();
793
+ ctx.arc(points[0].x, points[0].y, radius, 0, Math.PI * 2);
794
+ ctx.stroke();
795
+ if (!isPreview) ctx.fill();
796
+ }
797
+ break;
798
+
799
+ case 'arc':
800
+ if (points.length >= 2) {
801
+ const radius = Math.hypot(
802
+ points[1].x - points[0].x,
803
+ points[1].y - points[0].y
804
+ );
805
+ let startAngle = Math.atan2(points[1].y - points[0].y, points[1].x - points[0].x);
806
+ let endAngle = startAngle;
807
+ if (points.length >= 3) {
808
+ endAngle = Math.atan2(points[2].y - points[0].y, points[2].x - points[0].x);
809
+ }
810
+ ctx.beginPath();
811
+ ctx.arc(points[0].x, points[0].y, radius, startAngle, endAngle);
812
+ ctx.stroke();
813
+ }
814
+ break;
815
+
816
+ case 'polyline':
817
+ if (points.length >= 1) {
818
+ ctx.beginPath();
819
+ ctx.moveTo(points[0].x, points[0].y);
820
+ for (let i = 1; i < points.length; i++) {
821
+ ctx.lineTo(points[i].x, points[i].y);
822
+ }
823
+ ctx.stroke();
824
+ }
825
+ break;
826
+ }
827
+ }
828
+
829
+ /**
830
+ * Draw dimension label for entity
831
+ */
832
+ function _drawDimensionLabel(ctx, entity) {
833
+ const canvas = sketchState.canvas;
834
+ const points = entity.points.map(p => {
835
+ const world = _sketchToWorld(p, sketchState.plane);
836
+ return worldToCanvas(world.x, world.y, sketchState.camera, canvas.width, canvas.height);
837
+ });
838
+
839
+ if (!entity.dimensions) return;
840
+
841
+ let label = '';
842
+ let x = 0, y = 0;
843
+
844
+ switch (entity.type) {
845
+ case 'line':
846
+ if (entity.dimensions.length) {
847
+ label = `${entity.dimensions.length.toFixed(2)} mm`;
848
+ if (points.length >= 2) {
849
+ x = (points[0].x + points[1].x) / 2;
850
+ y = (points[0].y + points[1].y) / 2 - 15;
851
+ }
852
+ }
853
+ break;
854
+
855
+ case 'rectangle':
856
+ if (entity.dimensions.width && entity.dimensions.height) {
857
+ label = `${entity.dimensions.width.toFixed(1)} × ${entity.dimensions.height.toFixed(1)} mm`;
858
+ if (points.length >= 2) {
859
+ x = (points[0].x + points[1].x) / 2;
860
+ y = (points[0].y + points[1].y) / 2;
861
+ }
862
+ }
863
+ break;
864
+
865
+ case 'circle':
866
+ if (entity.dimensions.diameter) {
867
+ label = `⌀${entity.dimensions.diameter.toFixed(2)} mm`;
868
+ if (points.length >= 1) {
869
+ x = points[0].x + 20;
870
+ y = points[0].y - 5;
871
+ }
872
+ }
873
+ break;
874
+
875
+ case 'arc':
876
+ if (entity.dimensions.radius) {
877
+ label = `R${entity.dimensions.radius.toFixed(2)} mm`;
878
+ if (points.length >= 1) {
879
+ x = points[0].x + 20;
880
+ y = points[0].y - 5;
881
+ }
882
+ }
883
+ break;
884
+
885
+ case 'polyline':
886
+ if (entity.dimensions.length) {
887
+ label = `${entity.dimensions.length.toFixed(2)} mm`;
888
+ if (points.length >= 2) {
889
+ x = (points[0].x + points[points.length - 1].x) / 2;
890
+ y = (points[0].y + points[points.length - 1].y) / 2 - 15;
891
+ }
892
+ }
893
+ break;
894
+ }
895
+
896
+ if (label && x > 0 && y > 0) {
897
+ ctx.fillText(label, x, y);
898
+ }
899
+ }