cyclecad 1.3.1 → 1.3.3
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/DRAWING_MODULE_INTEGRATION.md +633 -0
- package/README.md +138 -317
- package/app/index.html +2 -0
- package/app/js/brep-kernel.js +853 -0
- package/app/js/kernel.js +684 -0
- package/app/js/modules/assembly-module.js +582 -0
- package/app/js/modules/brep-module.js +583 -0
- package/app/js/modules/drawing-module.js +883 -0
- package/app/js/modules/operations-module.js +660 -0
- package/app/js/modules/simulation-module.js +834 -0
- package/app/js/modules/sketch-module.js +720 -0
- package/app/js/modules/step-module.js +510 -0
- package/app/js/modules/viewport-module.js +530 -0
- package/fusion360-gap-analysis.html +636 -0
- package/package.json +1 -1
|
@@ -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;
|