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.
- package/CNAME +1 -0
- package/app/docs/api-reference.html +1436 -0
- package/app/docs/examples.html +803 -0
- package/app/docs/getting-started.html +1620 -0
- package/app/duo-project-browser.html +1321 -0
- package/app/duo-rebuild-guide.html +861 -0
- package/app/index.html +1635 -0
- package/app/js/ai-chat.js +992 -0
- package/app/js/app.js +724 -0
- package/app/js/export.js +658 -0
- package/app/js/inventor-parser.js +1138 -0
- package/app/js/operations.js +689 -0
- package/app/js/params.js +523 -0
- package/app/js/reverse-engineer.js +1275 -0
- package/app/js/shortcuts.js +350 -0
- package/app/js/sketch.js +899 -0
- package/app/js/tree.js +479 -0
- package/app/js/viewport.js +643 -0
- package/app/samples/Leistenbuerstenblech.ipt +0 -0
- package/app/samples/Rahmen_Seite.iam +0 -0
- package/app/samples/TraegerHoehe1.ipt +0 -0
- package/index.html +1226 -0
- package/package.json +33 -0
package/app/js/sketch.js
ADDED
|
@@ -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
|
+
}
|