cyclecad 1.1.2 → 1.3.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,661 @@
1
+ /**
2
+ * brep-engine.js — Real B-rep kernel for cycleCAD using OpenCascade.js
3
+ *
4
+ * Provides true solid modeling operations:
5
+ * - Primitive creation (box, cylinder, sphere, cone)
6
+ * - Boolean operations (cut, fuse, intersect)
7
+ * - Fillet and chamfer on real edges
8
+ * - Shape → Three.js BufferGeometry conversion
9
+ *
10
+ * Uses opencascade.js v2.0.0-beta (modular WASM build)
11
+ *
12
+ * MIT License — cycleCAD (c) 2026
13
+ */
14
+
15
+ // ============================================================================
16
+ // STATE
17
+ // ============================================================================
18
+
19
+ let oc = null; // OpenCascade.js instance
20
+ let _loading = false; // Prevent double-init
21
+ let _ready = false; // True when WASM is loaded
22
+ let _currentShape = null; // Active TopoDS_Shape (the "document")
23
+ const _shapeStack = []; // Undo stack of shapes
24
+
25
+ // CDN for opencascade.js 2.0.0-beta
26
+ const OCC_CDN = 'https://unpkg.com/opencascade.js@2.0.0-beta.533428a/dist/';
27
+
28
+ // ============================================================================
29
+ // INITIALIZATION
30
+ // ============================================================================
31
+
32
+ /**
33
+ * Load and initialize the OpenCascade.js WASM kernel.
34
+ * Returns a promise that resolves when ready.
35
+ * Subsequent calls return immediately if already loaded.
36
+ */
37
+ export async function initBRep() {
38
+ if (_ready && oc) return oc;
39
+ if (_loading) {
40
+ // Wait for in-progress load
41
+ return new Promise((resolve) => {
42
+ const check = setInterval(() => {
43
+ if (_ready && oc) { clearInterval(check); resolve(oc); }
44
+ }, 200);
45
+ });
46
+ }
47
+
48
+ _loading = true;
49
+ console.log('[BRep] Loading OpenCascade.js WASM kernel...');
50
+
51
+ try {
52
+ // Dynamic import from CDN
53
+ const module = await import(/* webpackIgnore: true */ OCC_CDN + 'opencascade.full.js');
54
+ const factory = module.default || module;
55
+
56
+ // Initialize WASM — factory returns a promise
57
+ oc = await factory({
58
+ locateFile: (file) => OCC_CDN + file,
59
+ });
60
+
61
+ _ready = true;
62
+ _loading = false;
63
+ console.log('[BRep] OpenCascade.js ready ✓');
64
+ return oc;
65
+ } catch (err) {
66
+ console.error('[BRep] Failed to load OpenCascade.js:', err);
67
+ _loading = false;
68
+
69
+ // Fallback: try loading via script tag
70
+ return new Promise((resolve, reject) => {
71
+ console.log('[BRep] Trying script tag fallback...');
72
+ const savedModule = window.Module;
73
+ const script = document.createElement('script');
74
+ script.src = OCC_CDN + 'opencascade.full.js';
75
+ script.onload = async () => {
76
+ try {
77
+ const occFactory = window.Module;
78
+ window.Module = savedModule; // restore
79
+ oc = await new occFactory({
80
+ locateFile: (file) => OCC_CDN + file,
81
+ });
82
+ _ready = true;
83
+ _loading = false;
84
+ console.log('[BRep] OpenCascade.js ready (script fallback) ✓');
85
+ resolve(oc);
86
+ } catch (e2) {
87
+ _loading = false;
88
+ reject(e2);
89
+ }
90
+ };
91
+ script.onerror = () => { _loading = false; reject(new Error('Script load failed')); };
92
+ document.head.appendChild(script);
93
+ });
94
+ }
95
+ }
96
+
97
+ /** Check if the B-rep kernel is ready */
98
+ export function isReady() { return _ready && oc !== null; }
99
+
100
+ // ============================================================================
101
+ // HELPER: gp_Pnt constructor
102
+ // ============================================================================
103
+
104
+ function pnt(x, y, z) {
105
+ return new oc.gp_Pnt_3(x, y, z);
106
+ }
107
+
108
+ function ax2(origin, dir) {
109
+ return new oc.gp_Ax2_3(
110
+ pnt(origin[0], origin[1], origin[2]),
111
+ new oc.gp_Dir_4(dir[0], dir[1], dir[2])
112
+ );
113
+ }
114
+
115
+ function progress() {
116
+ return new oc.Message_ProgressRange_1();
117
+ }
118
+
119
+ // ============================================================================
120
+ // PRIMITIVES
121
+ // ============================================================================
122
+
123
+ /**
124
+ * Create a B-rep box centered at origin
125
+ * @param {number} width - X dimension in mm
126
+ * @param {number} height - Y dimension in mm
127
+ * @param {number} depth - Z dimension in mm
128
+ * @returns {TopoDS_Shape}
129
+ */
130
+ export function makeBox(width, height, depth) {
131
+ const w = width, h = height, d = depth;
132
+ // Create box at (-w/2, -h/2, -d/2) so it's centered
133
+ const builder = new oc.BRepPrimAPI_MakeBox_3(
134
+ pnt(-w / 2, -h / 2, -d / 2), w, h, d
135
+ );
136
+ const shape = builder.Shape();
137
+ builder.delete();
138
+ return shape;
139
+ }
140
+
141
+ /**
142
+ * Create a B-rep cylinder centered at origin, axis along Y
143
+ * @param {number} radius - Radius in mm
144
+ * @param {number} height - Height in mm
145
+ * @returns {TopoDS_Shape}
146
+ */
147
+ export function makeCylinder(radius, height) {
148
+ const axis = ax2([0, -height / 2, 0], [0, 1, 0]);
149
+ const builder = new oc.BRepPrimAPI_MakeCylinder_2(axis, radius, height);
150
+ const shape = builder.Shape();
151
+ builder.delete();
152
+ return shape;
153
+ }
154
+
155
+ /**
156
+ * Create a B-rep sphere centered at origin
157
+ * @param {number} radius - Radius in mm
158
+ * @returns {TopoDS_Shape}
159
+ */
160
+ export function makeSphere(radius) {
161
+ const builder = new oc.BRepPrimAPI_MakeSphere_1(radius);
162
+ const shape = builder.Shape();
163
+ builder.delete();
164
+ return shape;
165
+ }
166
+
167
+ /**
168
+ * Create a B-rep cone centered at origin, axis along Y
169
+ * @param {number} bottomRadius
170
+ * @param {number} topRadius
171
+ * @param {number} height
172
+ * @returns {TopoDS_Shape}
173
+ */
174
+ export function makeCone(bottomRadius, topRadius, height) {
175
+ const axis = ax2([0, -height / 2, 0], [0, 1, 0]);
176
+ const builder = new oc.BRepPrimAPI_MakeCone_2(axis, bottomRadius, topRadius, height);
177
+ const shape = builder.Shape();
178
+ builder.delete();
179
+ return shape;
180
+ }
181
+
182
+ // ============================================================================
183
+ // BOOLEAN OPERATIONS
184
+ // ============================================================================
185
+
186
+ /**
187
+ * Boolean cut: result = base - tool
188
+ * @param {TopoDS_Shape} base - Shape to cut from
189
+ * @param {TopoDS_Shape} tool - Shape to subtract
190
+ * @returns {TopoDS_Shape}
191
+ */
192
+ export function booleanCut(base, tool) {
193
+ const cutter = new oc.BRepAlgoAPI_Cut_3(base, tool, progress());
194
+ cutter.Build(progress());
195
+ if (!cutter.IsDone()) {
196
+ console.warn('[BRep] Boolean cut failed');
197
+ cutter.delete();
198
+ return base;
199
+ }
200
+ const result = cutter.Shape();
201
+ cutter.delete();
202
+ return result;
203
+ }
204
+
205
+ /**
206
+ * Boolean fuse (union): result = base + tool
207
+ * @param {TopoDS_Shape} base
208
+ * @param {TopoDS_Shape} tool
209
+ * @returns {TopoDS_Shape}
210
+ */
211
+ export function booleanFuse(base, tool) {
212
+ const fuser = new oc.BRepAlgoAPI_Fuse_3(base, tool, progress());
213
+ fuser.Build(progress());
214
+ if (!fuser.IsDone()) {
215
+ console.warn('[BRep] Boolean fuse failed');
216
+ fuser.delete();
217
+ return base;
218
+ }
219
+ const result = fuser.Shape();
220
+ fuser.delete();
221
+ return result;
222
+ }
223
+
224
+ /**
225
+ * Boolean intersect: result = base ∩ tool
226
+ * @param {TopoDS_Shape} base
227
+ * @param {TopoDS_Shape} tool
228
+ * @returns {TopoDS_Shape}
229
+ */
230
+ export function booleanIntersect(base, tool) {
231
+ const common = new oc.BRepAlgoAPI_Common_3(base, tool, progress());
232
+ common.Build(progress());
233
+ if (!common.IsDone()) {
234
+ console.warn('[BRep] Boolean intersect failed');
235
+ common.delete();
236
+ return base;
237
+ }
238
+ const result = common.Shape();
239
+ common.delete();
240
+ return result;
241
+ }
242
+
243
+ // ============================================================================
244
+ // FILLET & CHAMFER
245
+ // ============================================================================
246
+
247
+ /**
248
+ * Apply fillet (round) to ALL edges of a shape
249
+ * @param {TopoDS_Shape} shape - Input shape
250
+ * @param {number} radius - Fillet radius in mm
251
+ * @returns {TopoDS_Shape}
252
+ */
253
+ export function filletAll(shape, radius) {
254
+ const fillet = new oc.BRepFilletAPI_MakeFillet(shape, oc.ChFi3d_Rational);
255
+ const explorer = new oc.TopExp_Explorer_2(shape, oc.TopAbs_ShapeEnum.TopAbs_EDGE, oc.TopAbs_ShapeEnum.TopAbs_SHAPE);
256
+
257
+ let edgeCount = 0;
258
+ while (explorer.More()) {
259
+ const edge = oc.TopoDS.Edge_1(explorer.Current());
260
+ fillet.Add_2(radius, edge);
261
+ edgeCount++;
262
+ explorer.Next();
263
+ }
264
+ explorer.delete();
265
+
266
+ if (edgeCount === 0) {
267
+ console.warn('[BRep] No edges found for fillet');
268
+ fillet.delete();
269
+ return shape;
270
+ }
271
+
272
+ try {
273
+ const result = fillet.Shape();
274
+ console.log(`[BRep] Fillet applied to ${edgeCount} edges (r=${radius}mm)`);
275
+ fillet.delete();
276
+ return result;
277
+ } catch (e) {
278
+ console.warn('[BRep] Fillet failed (radius may be too large):', e.message);
279
+ fillet.delete();
280
+ return shape;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Apply chamfer to ALL edges of a shape
286
+ * @param {TopoDS_Shape} shape - Input shape
287
+ * @param {number} distance - Chamfer distance in mm
288
+ * @returns {TopoDS_Shape}
289
+ */
290
+ export function chamferAll(shape, distance) {
291
+ const chamfer = new oc.BRepFilletAPI_MakeChamfer(shape);
292
+ const explorer = new oc.TopExp_Explorer_2(shape, oc.TopAbs_ShapeEnum.TopAbs_EDGE, oc.TopAbs_ShapeEnum.TopAbs_SHAPE);
293
+
294
+ let edgeCount = 0;
295
+ while (explorer.More()) {
296
+ const edge = oc.TopoDS.Edge_1(explorer.Current());
297
+ chamfer.Add_2(distance, edge);
298
+ edgeCount++;
299
+ explorer.Next();
300
+ }
301
+ explorer.delete();
302
+
303
+ if (edgeCount === 0) {
304
+ chamfer.delete();
305
+ return shape;
306
+ }
307
+
308
+ try {
309
+ const result = chamfer.Shape();
310
+ console.log(`[BRep] Chamfer applied to ${edgeCount} edges (d=${distance}mm)`);
311
+ chamfer.delete();
312
+ return result;
313
+ } catch (e) {
314
+ console.warn('[BRep] Chamfer failed:', e.message);
315
+ chamfer.delete();
316
+ return shape;
317
+ }
318
+ }
319
+
320
+ // ============================================================================
321
+ // TRANSFORM
322
+ // ============================================================================
323
+
324
+ /**
325
+ * Translate a shape by (dx, dy, dz)
326
+ * @param {TopoDS_Shape} shape
327
+ * @param {number} dx
328
+ * @param {number} dy
329
+ * @param {number} dz
330
+ * @returns {TopoDS_Shape}
331
+ */
332
+ export function translate(shape, dx, dy, dz) {
333
+ const trsf = new oc.gp_Trsf_1();
334
+ trsf.SetTranslation_1(new oc.gp_Vec_4(dx, dy, dz));
335
+ const transform = new oc.BRepBuilderAPI_Transform_2(shape, trsf, true);
336
+ const result = transform.Shape();
337
+ transform.delete();
338
+ trsf.delete();
339
+ return result;
340
+ }
341
+
342
+ // ============================================================================
343
+ // SHAPE → THREE.JS CONVERSION
344
+ // ============================================================================
345
+
346
+ /**
347
+ * Convert a TopoDS_Shape to Three.js BufferGeometry
348
+ * Tessellates the B-rep surface and extracts vertex/normal/index arrays
349
+ *
350
+ * @param {TopoDS_Shape} shape - OCC shape to convert
351
+ * @param {number} deflection - Mesh quality (smaller = finer). Default 0.5mm
352
+ * @returns {THREE.BufferGeometry}
353
+ */
354
+ export function shapeToGeometry(shape, deflection = 0.5) {
355
+ // Tessellate the shape
356
+ new oc.BRepMesh_IncrementalMesh_2(shape, deflection, false, deflection * 5, false);
357
+
358
+ const positions = [];
359
+ const normals = [];
360
+ const indices = [];
361
+ let vertexOffset = 0;
362
+
363
+ // Iterate over all faces
364
+ const faceExplorer = new oc.TopExp_Explorer_2(
365
+ shape,
366
+ oc.TopAbs_ShapeEnum.TopAbs_FACE,
367
+ oc.TopAbs_ShapeEnum.TopAbs_SHAPE
368
+ );
369
+
370
+ while (faceExplorer.More()) {
371
+ const face = oc.TopoDS.Face_1(faceExplorer.Current());
372
+ const location = new oc.TopLoc_Location_1();
373
+ const triangulation = oc.BRep_Tool.Triangulation(face, location);
374
+
375
+ if (!triangulation.IsNull()) {
376
+ const tri = triangulation.get();
377
+ const nbNodes = tri.NbNodes();
378
+ const nbTriangles = tri.NbTriangles();
379
+
380
+ // Get the transformation from the face location
381
+ const trsf = location.Transformation();
382
+
383
+ // Extract vertices
384
+ for (let i = 1; i <= nbNodes; i++) {
385
+ const node = tri.Node(i);
386
+ const transformed = node.Transformed(trsf);
387
+ positions.push(transformed.X(), transformed.Y(), transformed.Z());
388
+ }
389
+
390
+ // Compute face normal orientation
391
+ const orientation = face.Orientation_1();
392
+ const reversed = (orientation === oc.TopAbs_Orientation.TopAbs_REVERSED);
393
+
394
+ // Extract triangles
395
+ for (let i = 1; i <= nbTriangles; i++) {
396
+ const triangle = tri.Triangle(i);
397
+ let n1 = triangle.Value(1);
398
+ let n2 = triangle.Value(2);
399
+ let n3 = triangle.Value(3);
400
+
401
+ // Flip winding if face is reversed
402
+ if (reversed) { [n2, n3] = [n3, n2]; }
403
+
404
+ indices.push(
405
+ vertexOffset + n1 - 1,
406
+ vertexOffset + n2 - 1,
407
+ vertexOffset + n3 - 1
408
+ );
409
+ }
410
+
411
+ vertexOffset += nbNodes;
412
+ }
413
+
414
+ location.delete();
415
+ faceExplorer.Next();
416
+ }
417
+ faceExplorer.delete();
418
+
419
+ // Build Three.js BufferGeometry
420
+ const geometry = new THREE.BufferGeometry();
421
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
422
+ geometry.setIndex(indices);
423
+ geometry.computeVertexNormals();
424
+
425
+ return geometry;
426
+ }
427
+
428
+ /**
429
+ * Convert a TopoDS_Shape to a full Three.js Mesh with material
430
+ *
431
+ * @param {TopoDS_Shape} shape - OCC shape
432
+ * @param {object} options - { color, metalness, roughness, opacity, wireframe }
433
+ * @param {number} deflection - Mesh quality
434
+ * @returns {{ mesh: THREE.Mesh, wireframe: THREE.LineSegments, shape: TopoDS_Shape }}
435
+ */
436
+ export function shapeToMesh(shape, options = {}, deflection = 0.5) {
437
+ const {
438
+ color = 0x4488cc,
439
+ metalness = 0.3,
440
+ roughness = 0.6,
441
+ opacity = 1.0,
442
+ wireframe = false,
443
+ } = options;
444
+
445
+ const geometry = shapeToGeometry(shape, deflection);
446
+
447
+ const material = new THREE.MeshStandardMaterial({
448
+ color,
449
+ metalness,
450
+ roughness,
451
+ transparent: opacity < 1,
452
+ opacity,
453
+ side: THREE.DoubleSide,
454
+ wireframe,
455
+ });
456
+
457
+ const mesh = new THREE.Mesh(geometry, material);
458
+ mesh.castShadow = true;
459
+ mesh.receiveShadow = true;
460
+
461
+ // Wireframe edges overlay
462
+ const edges = new THREE.EdgesGeometry(geometry, 15);
463
+ const lineMat = new THREE.LineBasicMaterial({ color: 0x000000, opacity: 0.3, transparent: true });
464
+ const wireframeLines = new THREE.LineSegments(edges, lineMat);
465
+
466
+ return { mesh, wireframe: wireframeLines, shape };
467
+ }
468
+
469
+ // ============================================================================
470
+ // HIGH-LEVEL COPILOT API
471
+ // ============================================================================
472
+
473
+ /**
474
+ * Execute a full copilot command sequence using real B-rep operations.
475
+ * Manages the current shape state and converts to Three.js.
476
+ *
477
+ * @param {Array} commands - Array of { type, params } objects
478
+ * @returns {Promise<{ mesh, wireframe, shape, description }>}
479
+ */
480
+ export async function executeCommands(commands) {
481
+ if (!_ready) {
482
+ await initBRep();
483
+ }
484
+
485
+ const SCALE = 0.1; // mm to scene units
486
+ let description = '';
487
+
488
+ for (const cmd of commands) {
489
+ const { type, params = {} } = cmd;
490
+
491
+ try {
492
+ switch (type) {
493
+ case 'box': {
494
+ const w = params.width || 100;
495
+ const h = params.height || 100;
496
+ const d = params.depth || 100;
497
+ _pushUndo();
498
+ _currentShape = makeBox(w, h, d);
499
+ description += `Box ${w}×${h}×${d}mm`;
500
+ break;
501
+ }
502
+
503
+ case 'cylinder': {
504
+ const r = params.radius || 25;
505
+ const h = params.height || 50;
506
+ _pushUndo();
507
+ _currentShape = makeCylinder(r, h);
508
+ description += `Cylinder r${r} h${h}mm`;
509
+ break;
510
+ }
511
+
512
+ case 'sphere': {
513
+ const r = params.radius || 25;
514
+ _pushUndo();
515
+ _currentShape = makeSphere(r);
516
+ description += `Sphere r${r}mm`;
517
+ break;
518
+ }
519
+
520
+ case 'cone': {
521
+ const br = params.bottomRadius || params.radius || 25;
522
+ const tr = params.topRadius || 0;
523
+ const h = params.height || 50;
524
+ _pushUndo();
525
+ _currentShape = makeCone(br, tr, h);
526
+ description += `Cone r${br}/${tr} h${h}mm`;
527
+ break;
528
+ }
529
+
530
+ case 'hole': {
531
+ if (!_currentShape) {
532
+ console.warn('[BRep] No shape to cut hole in — create a shape first');
533
+ break;
534
+ }
535
+ const r = params.radius || 5;
536
+ const depth = params.depth || params.height || 200;
537
+ const count = params.count || 1;
538
+
539
+ _pushUndo();
540
+
541
+ for (let i = 0; i < count; i++) {
542
+ // Position holes at corners for count=4, or center for count=1
543
+ let dx = 0, dz = 0;
544
+ if (count === 4) {
545
+ const spread = (params.spread || 30);
546
+ const corners = [[-spread, -spread], [spread, -spread], [spread, spread], [-spread, spread]];
547
+ [dx, dz] = corners[i % 4];
548
+ } else if (count > 1) {
549
+ const angle = (i / count) * Math.PI * 2;
550
+ const spread = (params.spread || 20);
551
+ dx = Math.cos(angle) * spread;
552
+ dz = Math.sin(angle) * spread;
553
+ }
554
+
555
+ // Create cylinder tool at position, axis along Y
556
+ let tool = makeCylinder(r, depth);
557
+ if (dx !== 0 || dz !== 0) {
558
+ tool = translate(tool, dx, 0, dz);
559
+ }
560
+
561
+ _currentShape = booleanCut(_currentShape, tool);
562
+ }
563
+
564
+ description += ` + ${count} hole${count > 1 ? 's' : ''} (r${r}mm)`;
565
+ break;
566
+ }
567
+
568
+ case 'fillet': {
569
+ if (!_currentShape) break;
570
+ const r = params.radius || 5;
571
+ _pushUndo();
572
+ _currentShape = filletAll(_currentShape, r);
573
+ description += ` + fillet r${r}mm`;
574
+ break;
575
+ }
576
+
577
+ case 'chamfer': {
578
+ if (!_currentShape) break;
579
+ const d = params.distance || 3;
580
+ _pushUndo();
581
+ _currentShape = chamferAll(_currentShape, d);
582
+ description += ` + chamfer ${d}mm`;
583
+ break;
584
+ }
585
+
586
+ case 'fuse': {
587
+ if (!_currentShape || !params._toolShape) break;
588
+ _pushUndo();
589
+ _currentShape = booleanFuse(_currentShape, params._toolShape);
590
+ description += ' + fuse';
591
+ break;
592
+ }
593
+
594
+ default:
595
+ console.warn(`[BRep] Unknown command type: ${type}`);
596
+ }
597
+ } catch (err) {
598
+ console.error(`[BRep] Command "${type}" failed:`, err);
599
+ }
600
+ }
601
+
602
+ // Convert final shape to Three.js
603
+ if (!_currentShape) {
604
+ return null;
605
+ }
606
+
607
+ const result = shapeToMesh(_currentShape, { color: 0x4488cc }, 0.5);
608
+ // Scale mesh to scene units
609
+ result.mesh.scale.set(SCALE, SCALE, SCALE);
610
+ result.wireframe.scale.set(SCALE, SCALE, SCALE);
611
+ result.description = description;
612
+
613
+ return result;
614
+ }
615
+
616
+ // ============================================================================
617
+ // UNDO SUPPORT
618
+ // ============================================================================
619
+
620
+ function _pushUndo() {
621
+ if (_currentShape) {
622
+ _shapeStack.push(_currentShape);
623
+ if (_shapeStack.length > 20) _shapeStack.shift(); // limit memory
624
+ }
625
+ }
626
+
627
+ export function undo() {
628
+ if (_shapeStack.length > 0) {
629
+ _currentShape = _shapeStack.pop();
630
+ return true;
631
+ }
632
+ return false;
633
+ }
634
+
635
+ export function getCurrentShape() { return _currentShape; }
636
+ export function clearShape() { _currentShape = null; _shapeStack.length = 0; }
637
+
638
+ // ============================================================================
639
+ // REGISTER ON WINDOW FOR COPILOT ACCESS
640
+ // ============================================================================
641
+
642
+ window.brepEngine = {
643
+ initBRep,
644
+ isReady,
645
+ makeBox,
646
+ makeCylinder,
647
+ makeSphere,
648
+ makeCone,
649
+ booleanCut,
650
+ booleanFuse,
651
+ booleanIntersect,
652
+ filletAll,
653
+ chamferAll,
654
+ translate,
655
+ shapeToGeometry,
656
+ shapeToMesh,
657
+ executeCommands,
658
+ undo,
659
+ getCurrentShape,
660
+ clearShape,
661
+ };