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,720 @@
1
+ /**
2
+ * SketchModule — 2D Sketching Engine (Fusion 360 parity)
3
+ * LEGO block for cycleCAD microkernel
4
+ *
5
+ * Tools: Line, Rectangle, Circle, Arc, Ellipse, Spline, Polygon, Slot, Text,
6
+ * Trim, Extend, Offset, Mirror, Fillet, Chamfer, Construction, Dimension
7
+ */
8
+
9
+ const SketchModule = {
10
+ id: 'sketch',
11
+ name: 'Sketch Engine',
12
+ version: '1.0.0',
13
+ category: 'engine',
14
+ dependencies: ['viewport'],
15
+ memoryEstimate: 15,
16
+
17
+ // ===== STATE =====
18
+ state: {
19
+ isActive: false,
20
+ plane: null, // { normal, origin, u, v } — local coordinate frame
21
+ entities: [], // { id, type, points[], constraints[], isConstruction, selected }
22
+ dimensions: [], // { id, type, entities[], value, driven }
23
+ currentTool: 'line',
24
+ selectedEntityIds: new Set(),
25
+ isDrawing: false,
26
+ tempPoints: [], // points being drawn for current tool
27
+ gridSize: 5,
28
+ snapToGrid: true,
29
+ snapDistance: 15, // pixels
30
+ canvas: null,
31
+ ctx: null,
32
+ canvasGroup: null, // THREE.Group for 3D sketch entities
33
+ },
34
+
35
+ // ===== LEGO INTERFACE =====
36
+ init() {
37
+ // Called by app.js on startup
38
+ this.setupCanvasOverlay();
39
+ this.setupEventHandlers();
40
+ this.setupToolbar();
41
+ window.addEventListener('sketch:start', (e) => this.start(e.detail.plane));
42
+ window.addEventListener('sketch:finish', () => this.finish());
43
+ },
44
+
45
+ getUI() {
46
+ return `
47
+ <div id="sketch-toolbar" style="display: none; background: #2a2a2a; padding: 8px; border-radius: 4px; flex-wrap: wrap; gap: 4px;">
48
+ <button data-tool="line" class="sketch-tool-btn" title="Line (L)">—</button>
49
+ <button data-tool="rectangle" class="sketch-tool-btn" title="Rectangle (R)">▭</button>
50
+ <button data-tool="circle" class="sketch-tool-btn" title="Circle (C)">●</button>
51
+ <button data-tool="arc" class="sketch-tool-btn" title="Arc (A)">⌒</button>
52
+ <button data-tool="ellipse" class="sketch-tool-btn" title="Ellipse (E)">⬭</button>
53
+ <button data-tool="spline" class="sketch-tool-btn" title="Spline (S)">✓</button>
54
+ <button data-tool="polygon" class="sketch-tool-btn" title="Polygon (P)">⬡</button>
55
+ <button data-tool="slot" class="sketch-tool-btn" title="Slot">⊟</button>
56
+ <button data-tool="text" class="sketch-tool-btn" title="Text (T)">T</button>
57
+ <button data-tool="trim" class="sketch-tool-btn" title="Trim">✂</button>
58
+ <button data-tool="extend" class="sketch-tool-btn" title="Extend">→</button>
59
+ <button data-tool="offset" class="sketch-tool-btn" title="Offset">⟿</button>
60
+ <button data-tool="mirror" class="sketch-tool-btn" title="Mirror">⇄</button>
61
+ <button data-tool="fillet" class="sketch-tool-btn" title="Fillet">⌢</button>
62
+ <button data-tool="chamfer" class="sketch-tool-btn" title="Chamfer">/</button>
63
+ <button data-tool="construction" class="sketch-tool-btn" title="Toggle Construction (G)">⋯</button>
64
+ <button id="sketch-dimension-btn" class="sketch-tool-btn" title="Add Dimension (D)">📏</button>
65
+ <button id="sketch-finish-btn" style="margin-left: 16px; background: #00aa00; color: white;" title="Finish Sketch (Esc)">✓ Finish</button>
66
+ </div>
67
+ <div id="sketch-status-bar" style="display: none; color: #aaa; font-size: 12px; padding: 4px 8px; border-top: 1px solid #444; background: #1a1a1a;">
68
+ Tool: <span id="sketch-tool-name">Line</span> | Grid: <span id="sketch-grid-size">5mm</span> | Entities: <span id="sketch-entity-count">0</span>
69
+ </div>
70
+ <div id="sketch-dimension-input" style="display: none; position: fixed; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; padding: 12px; z-index: 10000;">
71
+ <label style="display: block; font-size: 12px; color: #aaa; margin-bottom: 4px;">Dimension Value (mm)</label>
72
+ <input id="sketch-dim-value" type="number" style="width: 120px; padding: 4px; background: #1a1a1a; border: 1px solid #666; color: #fff; border-radius: 2px;">
73
+ <button id="sketch-dim-ok" style="margin-left: 4px; padding: 4px 8px; background: #00aa00; color: white; border: none; border-radius: 2px; cursor: pointer;">OK</button>
74
+ </div>
75
+ `;
76
+ },
77
+
78
+ execute(command, params = {}) {
79
+ // Microkernel command dispatch
80
+ switch (command) {
81
+ case 'start': return this.start(params.plane);
82
+ case 'startOnFace': return this.startOnFace(params.faceId);
83
+ case 'finish': return this.finish();
84
+ case 'setTool': return this.setTool(params.tool);
85
+ case 'addEntity': return this.addEntity(params.type, params.params);
86
+ case 'trim': return this.trim(params.entityId, params.point);
87
+ case 'extend': return this.extend(params.entityId);
88
+ case 'offset': return this.offset(params.entityIds, params.distance);
89
+ case 'mirror': return this.mirror(params.entityIds, params.lineId);
90
+ case 'addDimension': return this.addDimension(params.type, params.entities, params.value);
91
+ case 'toggleConstruction': return this.toggleConstruction(params.entityIds);
92
+ case 'getProfile': return this.getProfile();
93
+ case 'undo': return this.undo();
94
+ case 'redo': return this.redo();
95
+ default: throw new Error(`Unknown sketch command: ${command}`);
96
+ }
97
+ },
98
+
99
+ // ===== CORE METHODS =====
100
+
101
+ start(plane) {
102
+ this.state.isActive = true;
103
+ this.state.plane = plane || {
104
+ normal: new THREE.Vector3(0, 0, 1),
105
+ origin: new THREE.Vector3(0, 0, 0),
106
+ u: new THREE.Vector3(1, 0, 0),
107
+ v: new THREE.Vector3(0, 1, 0)
108
+ };
109
+ this.state.entities = [];
110
+ this.state.dimensions = [];
111
+ this.state.selectedEntityIds.clear();
112
+ this.state.tempPoints = [];
113
+
114
+ // Show sketch toolbar and canvas
115
+ document.getElementById('sketch-toolbar').style.display = 'flex';
116
+ document.getElementById('sketch-status-bar').style.display = 'block';
117
+ if (this.state.canvas) this.state.canvas.style.display = 'block';
118
+
119
+ // Create 3D group for sketch entities
120
+ this.state.canvasGroup = new THREE.Group();
121
+ if (window._scene) window._scene.add(this.state.canvasGroup);
122
+
123
+ this.setTool('line');
124
+ this.updateStatusBar();
125
+ window.dispatchEvent(new CustomEvent('sketch:started', { detail: { plane: this.state.plane } }));
126
+ },
127
+
128
+ startOnFace(faceId) {
129
+ // Get face from current model
130
+ // For now, simplified — in real implementation, get face normal from model
131
+ const face = window._selectedFace || {};
132
+ const plane = {
133
+ normal: face.normal || new THREE.Vector3(0, 0, 1),
134
+ origin: face.origin || new THREE.Vector3(0, 0, 0),
135
+ u: face.u || new THREE.Vector3(1, 0, 0),
136
+ v: face.v || new THREE.Vector3(0, 1, 0)
137
+ };
138
+ this.start(plane);
139
+ },
140
+
141
+ finish() {
142
+ if (!this.state.isActive) return null;
143
+
144
+ this.state.isActive = false;
145
+ document.getElementById('sketch-toolbar').style.display = 'none';
146
+ document.getElementById('sketch-status-bar').style.display = 'none';
147
+ if (this.state.canvas) this.state.canvas.style.display = 'none';
148
+
149
+ // Remove 3D group
150
+ if (this.state.canvasGroup && window._scene) {
151
+ window._scene.remove(this.state.canvasGroup);
152
+ }
153
+
154
+ const profile = this.getProfile();
155
+ window.dispatchEvent(new CustomEvent('sketch:finished', {
156
+ detail: { entities: this.state.entities, profile, plane: this.state.plane }
157
+ }));
158
+
159
+ return { entities: this.state.entities, profile };
160
+ },
161
+
162
+ setTool(toolName) {
163
+ this.state.currentTool = toolName;
164
+ this.state.tempPoints = [];
165
+ this.state.isDrawing = false;
166
+
167
+ // Update toolbar button highlighting
168
+ document.querySelectorAll('.sketch-tool-btn').forEach(btn => {
169
+ btn.style.background = btn.dataset.tool === toolName ? '#00aa00' : '';
170
+ btn.style.color = btn.dataset.tool === toolName ? 'white' : '';
171
+ });
172
+
173
+ const toolLabels = {
174
+ line: 'Line', rectangle: 'Rectangle', circle: 'Circle', arc: 'Arc',
175
+ ellipse: 'Ellipse', spline: 'Spline', polygon: 'Polygon', slot: 'Slot',
176
+ text: 'Text', trim: 'Trim', extend: 'Extend', offset: 'Offset',
177
+ mirror: 'Mirror', fillet: 'Fillet', chamfer: 'Chamfer', construction: 'Construction'
178
+ };
179
+ document.getElementById('sketch-tool-name').textContent = toolLabels[toolName] || toolName;
180
+ window.dispatchEvent(new CustomEvent('sketch:toolChanged', { detail: { tool: toolName } }));
181
+ },
182
+
183
+ // ===== DRAWING TOOLS =====
184
+
185
+ addEntity(type, params = {}) {
186
+ const entity = {
187
+ id: `entity_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
188
+ type,
189
+ points: params.points || [],
190
+ constraints: params.constraints || [],
191
+ isConstruction: params.isConstruction || false,
192
+ selected: false,
193
+ data: params.data || {}
194
+ };
195
+
196
+ this.state.entities.push(entity);
197
+ this.renderEntity(entity);
198
+ window.dispatchEvent(new CustomEvent('sketch:entityAdded', { detail: { entity } }));
199
+ this.updateStatusBar();
200
+ return entity;
201
+ },
202
+
203
+ drawLine(p1, p2) {
204
+ return this.addEntity('line', { points: [p1, p2] });
205
+ },
206
+
207
+ drawRectangle(corner1, corner2) {
208
+ const p1 = corner1, p2 = new THREE.Vector2(corner2.x, corner1.y);
209
+ const p3 = corner2, p4 = new THREE.Vector2(corner1.x, corner2.y);
210
+ return [
211
+ this.addEntity('line', { points: [p1, p2] }),
212
+ this.addEntity('line', { points: [p2, p3] }),
213
+ this.addEntity('line', { points: [p3, p4] }),
214
+ this.addEntity('line', { points: [p4, p1] })
215
+ ];
216
+ },
217
+
218
+ drawCircle(center, radius) {
219
+ return this.addEntity('circle', {
220
+ points: [center],
221
+ data: { radius },
222
+ constraints: [{ type: 'fixed', point: center }]
223
+ });
224
+ },
225
+
226
+ drawArc(start, end, center) {
227
+ return this.addEntity('arc', {
228
+ points: [start, end, center],
229
+ data: {
230
+ radius: center.distanceTo(start),
231
+ startAngle: Math.atan2(start.y - center.y, start.x - center.x),
232
+ endAngle: Math.atan2(end.y - center.y, end.x - center.x)
233
+ }
234
+ });
235
+ },
236
+
237
+ drawEllipse(center, majorAxis, minorAxis, rotation = 0) {
238
+ return this.addEntity('ellipse', {
239
+ points: [center],
240
+ data: { majorAxis, minorAxis, rotation },
241
+ constraints: [{ type: 'fixed', point: center }]
242
+ });
243
+ },
244
+
245
+ drawSpline(controlPoints) {
246
+ return this.addEntity('spline', {
247
+ points: controlPoints,
248
+ data: { degree: 3 }
249
+ });
250
+ },
251
+
252
+ drawPolygon(center, sides, radius, circumscribed = true) {
253
+ const points = [];
254
+ for (let i = 0; i < sides; i++) {
255
+ const angle = (i / sides) * Math.PI * 2;
256
+ points.push(new THREE.Vector2(
257
+ center.x + Math.cos(angle) * radius,
258
+ center.y + Math.sin(angle) * radius
259
+ ));
260
+ }
261
+ // Close polygon
262
+ points.push(points[0]);
263
+ return this.addEntity('polygon', {
264
+ points,
265
+ data: { sides, radius, circumscribed }
266
+ });
267
+ },
268
+
269
+ drawSlot(centerStart, centerEnd, radius) {
270
+ return this.addEntity('slot', {
271
+ points: [centerStart, centerEnd],
272
+ data: { radius }
273
+ });
274
+ },
275
+
276
+ drawText(point, text, fontSize = 10) {
277
+ return this.addEntity('text', {
278
+ points: [point],
279
+ data: { text, fontSize }
280
+ });
281
+ },
282
+
283
+ // ===== EDITING TOOLS =====
284
+
285
+ trim(entityId, clickPoint) {
286
+ const entity = this.state.entities.find(e => e.id === entityId);
287
+ if (!entity) return;
288
+
289
+ // Find intersection points on entity
290
+ const intersections = this.findIntersections(entity);
291
+ const closestIntersection = intersections.reduce((closest, int) => {
292
+ const dist = int.distanceTo(clickPoint);
293
+ return dist < closest.dist ? { point: int, dist } : closest;
294
+ }, { dist: Infinity });
295
+
296
+ if (closestIntersection.dist < this.state.snapDistance) {
297
+ // Split entity at intersection
298
+ this.splitEntity(entityId, closestIntersection.point);
299
+ }
300
+ },
301
+
302
+ extend(entityId) {
303
+ const entity = this.state.entities.find(e => e.id === entityId);
304
+ if (!entity || !['line', 'arc'].includes(entity.type)) return;
305
+
306
+ // Find nearest intersection on other entities
307
+ const allIntersections = [];
308
+ this.state.entities.forEach(e => {
309
+ if (e.id !== entityId) {
310
+ const ints = this.findIntersectionsBetween(entity, e);
311
+ allIntersections.push(...ints);
312
+ }
313
+ });
314
+
315
+ if (allIntersections.length > 0) {
316
+ const nearest = allIntersections.reduce((n, int) => {
317
+ const d = int.distanceTo(entity.points[entity.points.length - 1]);
318
+ return d < n.dist ? { point: int, dist: d } : n;
319
+ }, { dist: Infinity });
320
+
321
+ entity.points[entity.points.length - 1] = nearest.point;
322
+ this.renderEntity(entity);
323
+ }
324
+ },
325
+
326
+ offset(entityIds, distance) {
327
+ entityIds.forEach(id => {
328
+ const entity = this.state.entities.find(e => e.id === id);
329
+ if (!entity) return;
330
+
331
+ let offsetPoints = [];
332
+ if (entity.type === 'line' && entity.points.length === 2) {
333
+ const [p1, p2] = entity.points;
334
+ const dir = p2.clone().sub(p1).normalize();
335
+ const perp = new THREE.Vector2(-dir.y, dir.x).multiplyScalar(distance);
336
+ offsetPoints = [p1.clone().add(perp), p2.clone().add(perp)];
337
+ } else if (entity.type === 'circle') {
338
+ // Offset circle by changing radius
339
+ const newEntity = this.addEntity('circle', {
340
+ points: entity.points,
341
+ data: { radius: entity.data.radius + distance }
342
+ });
343
+ return;
344
+ }
345
+
346
+ if (offsetPoints.length > 0) {
347
+ this.addEntity(entity.type, {
348
+ points: offsetPoints,
349
+ isConstruction: entity.isConstruction
350
+ });
351
+ }
352
+ });
353
+ },
354
+
355
+ mirror(entityIds, lineId) {
356
+ const mirrorLine = this.state.entities.find(e => e.id === lineId);
357
+ if (!mirrorLine || mirrorLine.type !== 'line') return;
358
+
359
+ const [p1, p2] = mirrorLine.points;
360
+ const lineDir = p2.clone().sub(p1).normalize();
361
+
362
+ entityIds.forEach(id => {
363
+ const entity = this.state.entities.find(e => e.id === id);
364
+ if (!entity) return;
365
+
366
+ const mirroredPoints = entity.points.map(point => {
367
+ // Mirror point across line
368
+ const toPoint = point.clone().sub(p1);
369
+ const projected = toPoint.dot(lineDir);
370
+ const perpendicular = toPoint.clone().sub(lineDir.clone().multiplyScalar(projected));
371
+ return p1.clone().add(lineDir.clone().multiplyScalar(projected)).sub(perpendicular);
372
+ });
373
+
374
+ this.addEntity(entity.type, {
375
+ points: mirroredPoints,
376
+ isConstruction: entity.isConstruction,
377
+ data: entity.data
378
+ });
379
+ });
380
+ },
381
+
382
+ fillet(entityId1, entityId2, radius) {
383
+ const e1 = this.state.entities.find(e => e.id === entityId1);
384
+ const e2 = this.state.entities.find(e => e.id === entityId2);
385
+ if (!e1 || !e2) return;
386
+
387
+ // Find intersection point
388
+ const intPoint = this.findIntersectionsBetween(e1, e2)[0];
389
+ if (!intPoint) return;
390
+
391
+ // Create arc connecting the two lines
392
+ const arc = this.addEntity('arc', {
393
+ points: [intPoint],
394
+ data: { radius },
395
+ constraints: [{ type: 'tangent', entity1: e1.id, entity2: e2.id }]
396
+ });
397
+
398
+ return arc;
399
+ },
400
+
401
+ chamfer(entityId1, entityId2, distance) {
402
+ const e1 = this.state.entities.find(e => e.id === entityId1);
403
+ const e2 = this.state.entities.find(e => e.id === entityId2);
404
+ if (!e1 || !e2) return;
405
+
406
+ // Find intersection
407
+ const intPoint = this.findIntersectionsBetween(e1, e2)[0];
408
+ if (!intPoint) return;
409
+
410
+ // Trim entities and add chamfer line
411
+ this.addEntity('line', {
412
+ points: [
413
+ intPoint.clone().add(new THREE.Vector2(distance, 0)),
414
+ intPoint.clone().add(new THREE.Vector2(0, distance))
415
+ ]
416
+ });
417
+ },
418
+
419
+ // ===== DIMENSIONS =====
420
+
421
+ addDimension(type, entityIds, value) {
422
+ const dimension = {
423
+ id: `dim_${Date.now()}`,
424
+ type,
425
+ entities: entityIds,
426
+ value,
427
+ driven: true
428
+ };
429
+
430
+ this.state.dimensions.push(dimension);
431
+ window.dispatchEvent(new CustomEvent('sketch:dimensionAdded', { detail: { dimension } }));
432
+ return dimension;
433
+ },
434
+
435
+ // ===== CONSTRAINTS =====
436
+
437
+ toggleConstruction(entityIds) {
438
+ entityIds.forEach(id => {
439
+ const entity = this.state.entities.find(e => e.id === id);
440
+ if (entity) {
441
+ entity.isConstruction = !entity.isConstruction;
442
+ this.renderEntity(entity);
443
+ }
444
+ });
445
+ },
446
+
447
+ // ===== RENDERING =====
448
+
449
+ setupCanvasOverlay() {
450
+ const canvas = document.createElement('canvas');
451
+ canvas.id = 'sketch-canvas';
452
+ canvas.style.position = 'fixed';
453
+ canvas.style.top = '32px';
454
+ canvas.style.left = '32px';
455
+ canvas.style.zIndex = '999';
456
+ canvas.style.cursor = 'crosshair';
457
+ canvas.style.display = 'none';
458
+ canvas.style.background = 'rgba(0,0,0,0.05)';
459
+
460
+ const rect = document.body.getBoundingClientRect();
461
+ canvas.width = rect.width - 64;
462
+ canvas.height = rect.height - 64;
463
+
464
+ document.body.appendChild(canvas);
465
+ this.state.canvas = canvas;
466
+ this.state.ctx = canvas.getContext('2d');
467
+ },
468
+
469
+ renderEntity(entity) {
470
+ if (!this.state.canvasGroup) return;
471
+
472
+ // Remove existing geometry
473
+ const existing = this.state.canvasGroup.children.find(c => c.userData.entityId === entity.id);
474
+ if (existing) this.state.canvasGroup.remove(existing);
475
+
476
+ let geometry, material;
477
+ const color = entity.isConstruction ? 0x888888 : 0x00ff00;
478
+ const linewidth = entity.selected ? 0.3 : 0.1;
479
+
480
+ switch (entity.type) {
481
+ case 'line':
482
+ geometry = new THREE.BufferGeometry().setFromPoints(entity.points);
483
+ material = new THREE.LineBasicMaterial({ color, linewidth });
484
+ break;
485
+
486
+ case 'circle':
487
+ geometry = new THREE.BufferGeometry();
488
+ const [center] = entity.points;
489
+ const { radius } = entity.data;
490
+ const circlePoints = [];
491
+ for (let i = 0; i <= 64; i++) {
492
+ const angle = (i / 64) * Math.PI * 2;
493
+ circlePoints.push(new THREE.Vector3(
494
+ center.x + Math.cos(angle) * radius,
495
+ center.y + Math.sin(angle) * radius,
496
+ 0
497
+ ));
498
+ }
499
+ geometry.setFromPoints(circlePoints);
500
+ material = new THREE.LineBasicMaterial({ color, linewidth });
501
+ break;
502
+
503
+ case 'arc':
504
+ geometry = new THREE.BufferGeometry();
505
+ const [s, e, c] = entity.points;
506
+ const { startAngle, endAngle } = entity.data;
507
+ const arcPoints = [];
508
+ for (let i = 0; i <= 32; i++) {
509
+ const t = i / 32;
510
+ const angle = startAngle + (endAngle - startAngle) * t;
511
+ const r = entity.data.radius;
512
+ arcPoints.push(new THREE.Vector3(
513
+ c.x + Math.cos(angle) * r,
514
+ c.y + Math.sin(angle) * r,
515
+ 0
516
+ ));
517
+ }
518
+ geometry.setFromPoints(arcPoints);
519
+ material = new THREE.LineBasicMaterial({ color, linewidth });
520
+ break;
521
+
522
+ default:
523
+ return;
524
+ }
525
+
526
+ const line = new THREE.Line(geometry, material);
527
+ line.userData.entityId = entity.id;
528
+ this.state.canvasGroup.add(line);
529
+ },
530
+
531
+ updateStatusBar() {
532
+ if (document.getElementById('sketch-entity-count')) {
533
+ document.getElementById('sketch-entity-count').textContent = this.state.entities.length;
534
+ }
535
+ },
536
+
537
+ // ===== HELPER METHODS =====
538
+
539
+ findIntersections(entity) {
540
+ const intersections = [];
541
+ this.state.entities.forEach(other => {
542
+ if (other.id !== entity.id) {
543
+ const ints = this.findIntersectionsBetween(entity, other);
544
+ intersections.push(...ints);
545
+ }
546
+ });
547
+ return intersections;
548
+ },
549
+
550
+ findIntersectionsBetween(e1, e2) {
551
+ // Simplified intersection detection
552
+ // In production, use robust geometric intersection library
553
+ const intersections = [];
554
+
555
+ if (e1.type === 'line' && e2.type === 'line') {
556
+ const [p1, p2] = e1.points;
557
+ const [p3, p4] = e2.points;
558
+ const int = this.lineLineIntersection(p1, p2, p3, p4);
559
+ if (int) intersections.push(int);
560
+ }
561
+
562
+ return intersections;
563
+ },
564
+
565
+ lineLineIntersection(p1, p2, p3, p4) {
566
+ const x1 = p1.x, y1 = p1.y, x2 = p2.x, y2 = p2.y;
567
+ const x3 = p3.x, y3 = p3.y, x4 = p4.x, y4 = p4.y;
568
+
569
+ const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
570
+ if (Math.abs(denom) < 0.0001) return null;
571
+
572
+ const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
573
+ if (t < 0 || t > 1) return null;
574
+
575
+ return new THREE.Vector2(x1 + t * (x2 - x1), y1 + t * (y2 - y1));
576
+ },
577
+
578
+ splitEntity(entityId, point) {
579
+ const entity = this.state.entities.find(e => e.id === entityId);
580
+ if (!entity) return;
581
+
582
+ // Find closest point on entity to split at
583
+ const idx = entity.points.findIndex(p => p.distanceTo(point) < 0.1);
584
+ if (idx !== -1) {
585
+ entity.points.splice(idx, 0, point);
586
+ this.renderEntity(entity);
587
+ }
588
+ },
589
+
590
+ getProfile() {
591
+ // Extract closed wire from entities for extrude/revolve
592
+ return {
593
+ entities: this.state.entities.filter(e => !e.isConstruction),
594
+ dimensions: this.state.dimensions,
595
+ plane: this.state.plane
596
+ };
597
+ },
598
+
599
+ setupEventHandlers() {
600
+ // Tool button clicks
601
+ document.addEventListener('click', (e) => {
602
+ if (e.target.classList.contains('sketch-tool-btn') && e.target.dataset.tool) {
603
+ this.setTool(e.target.dataset.tool);
604
+ }
605
+ if (e.target.id === 'sketch-finish-btn') this.finish();
606
+ });
607
+
608
+ // Canvas drawing
609
+ if (this.state.canvas) {
610
+ this.state.canvas.addEventListener('click', (e) => this.handleCanvasClick(e));
611
+ this.state.canvas.addEventListener('mousemove', (e) => this.handleCanvasMove(e));
612
+ }
613
+
614
+ // Keyboard shortcuts
615
+ document.addEventListener('keydown', (e) => {
616
+ if (!this.state.isActive) return;
617
+ switch (e.key.toLowerCase()) {
618
+ case 'l': this.setTool('line'); break;
619
+ case 'r': this.setTool('rectangle'); break;
620
+ case 'c': this.setTool('circle'); break;
621
+ case 'a': this.setTool('arc'); break;
622
+ case 's': this.setTool('spline'); break;
623
+ case 't': this.setTool('text'); break;
624
+ case 'g': this.toggleConstruction(Array.from(this.state.selectedEntityIds)); break;
625
+ case 'd': this.setTool('dimension'); break;
626
+ case 'escape': this.finish(); break;
627
+ }
628
+ });
629
+ },
630
+
631
+ setupToolbar() {
632
+ // Toolbar setup happens in getUI()
633
+ },
634
+
635
+ handleCanvasClick(e) {
636
+ const rect = this.state.canvas.getBoundingClientRect();
637
+ const x = (e.clientX - rect.left) / this.state.canvas.width * 100;
638
+ const y = (e.clientY - rect.top) / this.state.canvas.height * 100;
639
+ const point = new THREE.Vector2(x, y);
640
+
641
+ if (this.state.snapToGrid) {
642
+ point.x = Math.round(point.x / this.state.gridSize) * this.state.gridSize;
643
+ point.y = Math.round(point.y / this.state.gridSize) * this.state.gridSize;
644
+ }
645
+
646
+ this.state.tempPoints.push(point);
647
+
648
+ // Tool-specific logic
649
+ switch (this.state.currentTool) {
650
+ case 'line':
651
+ if (this.state.tempPoints.length === 2) {
652
+ this.drawLine(this.state.tempPoints[0], this.state.tempPoints[1]);
653
+ this.state.tempPoints = [];
654
+ }
655
+ break;
656
+
657
+ case 'rectangle':
658
+ if (this.state.tempPoints.length === 2) {
659
+ this.drawRectangle(this.state.tempPoints[0], this.state.tempPoints[1]);
660
+ this.state.tempPoints = [];
661
+ }
662
+ break;
663
+
664
+ case 'circle':
665
+ if (this.state.tempPoints.length === 2) {
666
+ const radius = this.state.tempPoints[0].distanceTo(this.state.tempPoints[1]);
667
+ this.drawCircle(this.state.tempPoints[0], radius);
668
+ this.state.tempPoints = [];
669
+ }
670
+ break;
671
+
672
+ case 'arc':
673
+ if (this.state.tempPoints.length === 3) {
674
+ this.drawArc(this.state.tempPoints[0], this.state.tempPoints[1], this.state.tempPoints[2]);
675
+ this.state.tempPoints = [];
676
+ }
677
+ break;
678
+
679
+ case 'trim':
680
+ this.trim(this.state.selectedEntityIds.values().next().value, point);
681
+ break;
682
+
683
+ case 'polygon':
684
+ if (this.state.tempPoints.length === 2) {
685
+ const sides = 6;
686
+ const radius = this.state.tempPoints[0].distanceTo(this.state.tempPoints[1]);
687
+ this.drawPolygon(this.state.tempPoints[0], sides, radius);
688
+ this.state.tempPoints = [];
689
+ }
690
+ break;
691
+ }
692
+ },
693
+
694
+ handleCanvasMove(e) {
695
+ // Live preview while drawing — update preview geometry
696
+ const rect = this.state.canvas.getBoundingClientRect();
697
+ const x = (e.clientX - rect.left) / this.state.canvas.width * 100;
698
+ const y = (e.clientY - rect.top) / this.state.canvas.height * 100;
699
+ const point = new THREE.Vector2(x, y);
700
+
701
+ // Render preview based on current tool and tempPoints
702
+ },
703
+
704
+ undo() {
705
+ if (this.state.entities.length > 0) {
706
+ this.state.entities.pop();
707
+ if (this.state.canvasGroup) {
708
+ this.state.canvasGroup.clear();
709
+ this.state.entities.forEach(e => this.renderEntity(e));
710
+ }
711
+ this.updateStatusBar();
712
+ }
713
+ },
714
+
715
+ redo() {
716
+ // Simple redo — in production, use proper undo/redo stack
717
+ }
718
+ };
719
+
720
+ export default SketchModule;