fluidcad 0.0.38 → 0.0.39

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.
Files changed (98) hide show
  1. package/lib/dist/core/2d/aline.d.ts +0 -8
  2. package/lib/dist/core/2d/aline.js +3 -22
  3. package/lib/dist/core/2d/arc.d.ts +1 -23
  4. package/lib/dist/core/2d/arc.js +11 -28
  5. package/lib/dist/core/2d/circle.d.ts +1 -8
  6. package/lib/dist/core/2d/circle.js +7 -24
  7. package/lib/dist/core/2d/ellipse.d.ts +1 -17
  8. package/lib/dist/core/2d/ellipse.js +7 -45
  9. package/lib/dist/core/2d/hline.d.ts +0 -7
  10. package/lib/dist/core/2d/hline.js +5 -27
  11. package/lib/dist/core/2d/intersect.d.ts +0 -7
  12. package/lib/dist/core/2d/intersect.js +0 -13
  13. package/lib/dist/core/2d/line.d.ts +1 -8
  14. package/lib/dist/core/2d/line.js +6 -24
  15. package/lib/dist/core/2d/offset.d.ts +1 -11
  16. package/lib/dist/core/2d/offset.js +0 -16
  17. package/lib/dist/core/2d/polygon.d.ts +1 -17
  18. package/lib/dist/core/2d/polygon.js +18 -37
  19. package/lib/dist/core/2d/project.d.ts +0 -7
  20. package/lib/dist/core/2d/project.js +0 -13
  21. package/lib/dist/core/2d/rect.d.ts +1 -9
  22. package/lib/dist/core/2d/rect.js +0 -19
  23. package/lib/dist/core/2d/slot.d.ts +0 -23
  24. package/lib/dist/core/2d/slot.js +18 -37
  25. package/lib/dist/core/2d/text.d.ts +0 -7
  26. package/lib/dist/core/2d/text.js +0 -14
  27. package/lib/dist/core/2d/vline.d.ts +0 -7
  28. package/lib/dist/core/2d/vline.js +5 -23
  29. package/lib/dist/core/fillet.js +7 -0
  30. package/lib/dist/core/index.d.ts +1 -1
  31. package/lib/dist/core/interfaces.d.ts +43 -0
  32. package/lib/dist/features/2d/connect.d.ts +2 -0
  33. package/lib/dist/features/2d/connect.js +3 -0
  34. package/lib/dist/features/2d/intersect.js +4 -1
  35. package/lib/dist/features/frozen-geometry.d.ts +29 -0
  36. package/lib/dist/features/frozen-geometry.js +62 -0
  37. package/lib/dist/features/loft.d.ts +38 -1
  38. package/lib/dist/features/loft.js +133 -8
  39. package/lib/dist/features/rib.js +11 -1
  40. package/lib/dist/features/shell.d.ts +1 -1
  41. package/lib/dist/features/shell.js +9 -0
  42. package/lib/dist/features/sweep.d.ts +11 -1
  43. package/lib/dist/features/sweep.js +43 -7
  44. package/lib/dist/helpers/clone-transform.js +62 -5
  45. package/lib/dist/helpers/scene-helpers.d.ts +12 -0
  46. package/lib/dist/helpers/scene-helpers.js +26 -0
  47. package/lib/dist/math/bspline-interpolation.d.ts +33 -6
  48. package/lib/dist/math/bspline-interpolation.js +187 -84
  49. package/lib/dist/oc/loft/constrained-loft.d.ts +32 -0
  50. package/lib/dist/oc/loft/constrained-loft.js +61 -0
  51. package/lib/dist/oc/loft/curve-data.d.ts +18 -0
  52. package/lib/dist/oc/loft/curve-data.js +54 -0
  53. package/lib/dist/oc/loft/curve-eval.d.ts +23 -0
  54. package/lib/dist/oc/loft/curve-eval.js +84 -0
  55. package/lib/dist/oc/loft/guided-loft.d.ts +89 -0
  56. package/lib/dist/oc/loft/guided-loft.js +368 -0
  57. package/lib/dist/oc/loft/section-compatibility.d.ts +131 -0
  58. package/lib/dist/oc/loft/section-compatibility.js +481 -0
  59. package/lib/dist/oc/loft/section-curve.d.ts +53 -0
  60. package/lib/dist/oc/loft/section-curve.js +264 -0
  61. package/lib/dist/oc/loft/skinning.d.ts +104 -0
  62. package/lib/dist/oc/loft/skinning.js +299 -0
  63. package/lib/dist/oc/loft-ops.d.ts +28 -1
  64. package/lib/dist/oc/loft-ops.js +31 -1
  65. package/lib/dist/oc/ncollection.d.ts +19 -0
  66. package/lib/dist/oc/ncollection.js +69 -0
  67. package/lib/dist/oc/rib-ops.js +5 -17
  68. package/lib/dist/oc/wire-extend-ops.d.ts +12 -0
  69. package/lib/dist/oc/wire-extend-ops.js +42 -0
  70. package/lib/dist/oc/wire-ops.d.ts +11 -0
  71. package/lib/dist/oc/wire-ops.js +33 -0
  72. package/lib/dist/oc/wrap-ops.js +8 -15
  73. package/lib/dist/tests/features/2d/circle.test.js +0 -10
  74. package/lib/dist/tests/features/2d/ellipse.test.js +0 -30
  75. package/lib/dist/tests/features/2d/line.test.js +0 -5
  76. package/lib/dist/tests/features/2d/offset.test.js +0 -10
  77. package/lib/dist/tests/features/2d/polygon.test.js +0 -16
  78. package/lib/dist/tests/features/2d/rect.test.js +0 -11
  79. package/lib/dist/tests/features/2d/slot.test.js +0 -8
  80. package/lib/dist/tests/features/fillet2d.test.js +37 -1
  81. package/lib/dist/tests/features/loft-conditions.test.d.ts +1 -0
  82. package/lib/dist/tests/features/loft-conditions.test.js +246 -0
  83. package/lib/dist/tests/features/loft-guides.test.d.ts +1 -0
  84. package/lib/dist/tests/features/loft-guides.test.js +276 -0
  85. package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.d.ts +1 -0
  86. package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.js +35 -0
  87. package/lib/dist/tests/features/rib.test.js +50 -0
  88. package/lib/dist/tests/features/shell.test.js +35 -1
  89. package/lib/dist/tests/features/sweep.test.js +81 -0
  90. package/lib/dist/tests/features/text.test.js +33 -14
  91. package/lib/dist/tests/math/bspline-interpolation.test.js +97 -1
  92. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  93. package/llm-docs/api/index.json +1 -1
  94. package/llm-docs/api/types/loft.md +36 -1
  95. package/llm-docs/api/types/sweep.md +13 -1
  96. package/llm-docs/api/types/text.md +2 -2
  97. package/llm-docs/index.json +5 -5
  98. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
2
  import { setupOC, render, addToScene } from "../setup.js";
3
3
  import sketch from "../../core/sketch.js";
4
4
  import extrude from "../../core/extrude.js";
@@ -8,6 +8,7 @@ import cylinder from "../../core/cylinder.js";
8
8
  import { rect } from "../../core/2d/index.js";
9
9
  import { Shell } from "../../features/shell.js";
10
10
  import { countShapes } from "../utils.js";
11
+ import { ShellOps } from "../../oc/shell-ops.js";
11
12
  import { ShapeProps } from "../../oc/props.js";
12
13
  import { face } from "../../filters/index.js";
13
14
  describe("shell", () => {
@@ -263,4 +264,37 @@ describe("shell", () => {
263
264
  expect(sel2.getShapes()).toHaveLength(0);
264
265
  });
265
266
  });
267
+ describe("failure surfacing", () => {
268
+ it("flags an error instead of silently serving the original solid when the offset fails", () => {
269
+ // OCCT's MakeThickSolid throws on geometry it cannot hollow (a wall
270
+ // thicker than a nearby feature, a cut opening onto a removed face,
271
+ // etc.). Force that failure deterministically and assert the feature
272
+ // surfaces it rather than swallowing it into a silent no-op.
273
+ const spy = vi.spyOn(ShellOps, "makeThickSolid").mockImplementation(() => {
274
+ throw new Error("Failed to create thick solid.");
275
+ });
276
+ try {
277
+ sketch("xy", () => {
278
+ rect(100, 100);
279
+ });
280
+ extrude(50);
281
+ select(face().onPlane("xy", 50));
282
+ const s = shell(5);
283
+ const scene = render();
284
+ // The failure is now visible to the user instead of being swallowed.
285
+ expect(s.getError()).toBeTruthy();
286
+ expect(s.getError()).toContain("shell");
287
+ // The original solid is preserved (downstream features still have
288
+ // geometry) — the no-op fallback is intact, just no longer silent.
289
+ const solid = scene.getAllSceneObjects()
290
+ .flatMap(o => o.getShapes())
291
+ .find(sh => sh.getType() === "solid");
292
+ expect(solid).toBeDefined();
293
+ expect(ShapeProps.getProperties(solid.getShape()).volumeMm3).toBeCloseTo(100 * 100 * 50, 1);
294
+ }
295
+ finally {
296
+ spy.mockRestore();
297
+ }
298
+ });
299
+ });
266
300
  });
@@ -478,4 +478,85 @@ describe("sweep", () => {
478
478
  expect(radialExtent).toBeLessThan(30);
479
479
  });
480
480
  });
481
+ describe("extend", () => {
482
+ it("extends the run-out past the path end along the tangent", () => {
483
+ const profile = sketch("xy", () => {
484
+ circle(10);
485
+ });
486
+ const path = sketch("xz", () => {
487
+ vLine(50);
488
+ });
489
+ const s = sweep(path, profile).extend("end", 20);
490
+ render();
491
+ expect(s.getError()).toBeNull();
492
+ const shapes = s.getShapes();
493
+ expect(shapes).toHaveLength(1);
494
+ expect(shapes[0].getType()).toBe("solid");
495
+ // Straight Z sweep with flat caps: Z extent = path length + extension.
496
+ const bbox = ShapeOps.getBoundingBox(shapes[0]);
497
+ expect(bbox.maxZ - bbox.minZ).toBeCloseTo(70, 0);
498
+ });
499
+ it("extends the lead-in before the path start along the tangent", () => {
500
+ const profile = sketch("xy", () => {
501
+ circle(10);
502
+ });
503
+ const path = sketch("xz", () => {
504
+ vLine(50);
505
+ });
506
+ const s = sweep(path, profile).extend("start", 20);
507
+ render();
508
+ expect(s.getError()).toBeNull();
509
+ const bbox = ShapeOps.getBoundingBox(s.getShapes()[0]);
510
+ expect(bbox.maxZ - bbox.minZ).toBeCloseTo(70, 0);
511
+ });
512
+ it("extends both ends when chained", () => {
513
+ const profile = sketch("xy", () => {
514
+ circle(10);
515
+ });
516
+ const path = sketch("xz", () => {
517
+ vLine(50);
518
+ });
519
+ const s = sweep(path, profile).extend("start", 10).extend("end", 20);
520
+ render();
521
+ expect(s.getError()).toBeNull();
522
+ const bbox = ShapeOps.getBoundingBox(s.getShapes()[0]);
523
+ expect(bbox.maxZ - bbox.minZ).toBeCloseTo(80, 0);
524
+ });
525
+ it("adds volume proportional to the extension length", () => {
526
+ const profile = sketch("xy", () => {
527
+ circle(10); // diameter 10 ⇒ radius 5 ⇒ area 25π
528
+ });
529
+ const path = sketch("xz", () => {
530
+ vLine(50);
531
+ });
532
+ const s = sweep(path, profile).extend("end", 30);
533
+ render();
534
+ // Right cylinder: π·5²·(50 + 30).
535
+ const vol = ShapeProps.getProperties(s.getShapes()[0].getShape()).volumeMm3;
536
+ const expected = 25 * Math.PI * 80;
537
+ expect(vol).toBeGreaterThan(expected * 0.98);
538
+ expect(vol).toBeLessThan(expected * 1.02);
539
+ });
540
+ it("is a no-op for a non-positive amount", () => {
541
+ const profile = sketch("xy", () => {
542
+ circle(10);
543
+ });
544
+ const path = sketch("xz", () => {
545
+ vLine(50);
546
+ });
547
+ const s = sweep(path, profile).extend("end", 0);
548
+ render();
549
+ const bbox = ShapeOps.getBoundingBox(s.getShapes()[0]);
550
+ expect(bbox.maxZ - bbox.minZ).toBeCloseTo(50, 0);
551
+ });
552
+ it("throws on an invalid side", () => {
553
+ const profile = sketch("xy", () => {
554
+ circle(10);
555
+ });
556
+ const path = sketch("xz", () => {
557
+ vLine(50);
558
+ });
559
+ expect(() => sweep(path, profile).extend("middle", 10)).toThrow();
560
+ });
561
+ });
481
562
  });
@@ -20,8 +20,10 @@ function solidsOf(e) {
20
20
  }
21
21
  describe("text", () => {
22
22
  setupOC();
23
- it("extrudes standalone text on a plane into a solid", () => {
24
- text("xy", "A").size(20);
23
+ it("extrudes text on a plane into a solid", () => {
24
+ sketch("xy", () => {
25
+ text("A").size(20);
26
+ });
25
27
  const e = extrude(5);
26
28
  render();
27
29
  const solids = solidsOf(e);
@@ -53,19 +55,25 @@ describe("text", () => {
53
55
  it("builds a letter with a counter (hole) such as 'o'", () => {
54
56
  // Outer + inner contours must both build; FaceMaker2 drills the counter.
55
57
  // We don't assert exact topology (font-dependent) — only a valid solid.
56
- text("xy", "o").size(20);
58
+ sketch("xy", () => {
59
+ text("o").size(20);
60
+ });
57
61
  const e = extrude(4);
58
62
  render();
59
63
  expect(solidsOf(e).length).toBeGreaterThanOrEqual(1);
60
64
  });
61
65
  it("falls back to a default font when the named font is missing", () => {
62
- text("xy", "Z").size(20).font("NoSuchFont__XYZ");
66
+ sketch("xy", () => {
67
+ text("Z").size(20).font("NoSuchFont__XYZ");
68
+ });
63
69
  const e = extrude(4);
64
70
  render();
65
71
  expect(solidsOf(e).length).toBeGreaterThanOrEqual(1);
66
72
  });
67
73
  it("honours font weight without throwing", () => {
68
- text("xy", "B").size(20).weight("bold");
74
+ sketch("xy", () => {
75
+ text("B").size(20).weight("bold");
76
+ });
69
77
  const e = extrude(4);
70
78
  render();
71
79
  expect(solidsOf(e).length).toBeGreaterThanOrEqual(1);
@@ -88,7 +96,9 @@ describe("text", () => {
88
96
  const root = process.env.FLUIDCAD_WORKSPACE_PATH;
89
97
  fs.mkdirSync(join(root, "fonts"), { recursive: true });
90
98
  fs.copyFileSync(src, join(root, "fonts", "test.ttf"));
91
- text("xy", "A").size(20).font("fonts/test.ttf");
99
+ sketch("xy", () => {
100
+ text("A").size(20).font("fonts/test.ttf");
101
+ });
92
102
  const e = extrude(5);
93
103
  render();
94
104
  expect(solidsOf(e).length).toBeGreaterThanOrEqual(1);
@@ -187,9 +197,9 @@ describe("text along a path", () => {
187
197
  expect(bbox.maxX).toBeLessThan(101);
188
198
  expect(bbox.maxX).toBeGreaterThan(85);
189
199
  });
190
- it("follows a standalone planar primitive and wraps on a closed path", () => {
200
+ it("follows a closed circular path and wraps around", () => {
191
201
  // Text much longer than the circumference must wrap, not error.
192
- const ring = circle("xy", 30); // radius 15, circumference ~94
202
+ const ring = sketch("xy", () => circle(30)); // radius 15, circumference ~94
193
203
  const t = text("WRAPPING ALL THE WAY AROUND", ring).size(8);
194
204
  render();
195
205
  expect(t.getError()).toBeFalsy();
@@ -232,12 +242,15 @@ describe("text along a path", () => {
232
242
  expect(t.getError()).toMatch(/planar/i);
233
243
  });
234
244
  it("rejects path-only modifiers without a path", () => {
235
- const t = text("xy", "Hi").offset(3);
245
+ let t;
246
+ sketch("xy", () => {
247
+ t = text("Hi").offset(3);
248
+ });
236
249
  render();
237
250
  expect(t.getError()).toMatch(/offset/i);
238
251
  });
239
252
  it("sits on the outside of a closed circle by default", () => {
240
- const ring = circle("xy", 100); // radius 50
253
+ const ring = sketch("xy", () => circle(100)); // radius 50
241
254
  const t = text("OUTSIDE", ring).size(8);
242
255
  render();
243
256
  expect(t.getError()).toBeFalsy();
@@ -249,7 +262,7 @@ describe("text along a path", () => {
249
262
  expect(mean).toBeGreaterThan(50);
250
263
  });
251
264
  it("moves closed-path text inside with flip", () => {
252
- const ring = circle("xy", 100); // radius 50
265
+ const ring = sketch("xy", () => circle(100)); // radius 50
253
266
  const t = text("INSIDE", ring).size(8).flip();
254
267
  render();
255
268
  expect(t.getError()).toBeFalsy();
@@ -297,12 +310,18 @@ describe("text along a path", () => {
297
310
  expect(bbox.maxX).toBeGreaterThan(85);
298
311
  });
299
312
  it("rejects space-between alignment without a path", () => {
300
- const t = text("xy", "Hi").align("space-between");
313
+ let t;
314
+ sketch("xy", () => {
315
+ t = text("Hi").align("space-between");
316
+ });
301
317
  render();
302
318
  expect(t.getError()).toMatch(/space-between/i);
303
319
  });
304
320
  it("rejects space-around alignment without a path", () => {
305
- const t = text("xy", "Hi").align("space-around");
321
+ let t;
322
+ sketch("xy", () => {
323
+ t = text("Hi").align("space-around");
324
+ });
306
325
  render();
307
326
  expect(t.getError()).toMatch(/space-around/i);
308
327
  });
@@ -337,7 +356,7 @@ describe("text along a path", () => {
337
356
  expect(bbox.maxZ - bbox.minZ).toBeCloseTo(4, 0);
338
357
  });
339
358
  it("multiple texts can share one path", () => {
340
- const ring = circle("xy", 100);
359
+ const ring = sketch("xy", () => circle(100));
341
360
  const outside = text("OUTSIDE", ring).size(8);
342
361
  const inside = text("INSIDE", ring).size(8).flip();
343
362
  render();
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { interpolateBSpline2d } from "../../math/bspline-interpolation.js";
2
+ import { interpolateBSpline2d, interpolateWithDerivatives, } from "../../math/bspline-interpolation.js";
3
3
  /** Evaluates the B-spline at t via Cox–de Boor (independent of OCC). */
4
4
  function evaluate(data, t) {
5
5
  const flat = [];
@@ -117,3 +117,99 @@ describe("interpolateBSpline2d", () => {
117
117
  expect(data.poles.length).toBe(4);
118
118
  });
119
119
  });
120
+ /** Evaluates an N-dimensional B-spline at t via Cox–de Boor. */
121
+ function evaluateNd(data, t) {
122
+ const flat = [];
123
+ for (let i = 0; i < data.knots.length; i++) {
124
+ for (let m = 0; m < data.multiplicities[i]; m++) {
125
+ flat.push(data.knots[i]);
126
+ }
127
+ }
128
+ const asXY = {
129
+ poles: data.poles.map(p => ({ x: p[0], y: p.length > 1 ? p[1] : 0 })),
130
+ knots: data.knots,
131
+ multiplicities: data.multiplicities,
132
+ degree: data.degree,
133
+ };
134
+ // Reuse the 2D evaluator per dimension pair to keep this helper simple.
135
+ const result = [];
136
+ const dimension = data.poles[0].length;
137
+ for (let d = 0; d < dimension; d += 2) {
138
+ const pair = {
139
+ ...asXY,
140
+ poles: data.poles.map(p => ({ x: p[d], y: d + 1 < dimension ? p[d + 1] : 0 })),
141
+ };
142
+ const q = evaluate(pair, t);
143
+ result.push(q.x);
144
+ if (d + 1 < dimension) {
145
+ result.push(q.y);
146
+ }
147
+ }
148
+ return result;
149
+ }
150
+ /** Central-difference derivative of the curve at t. */
151
+ function numericDerivative(data, t) {
152
+ const h = 1e-7;
153
+ const t0 = Math.max(data.knots[0], t - h);
154
+ const t1 = Math.min(data.knots[data.knots.length - 1], t + h);
155
+ const a = evaluateNd(data, t0);
156
+ const b = evaluateNd(data, t1);
157
+ return a.map((v, d) => (b[d] - v) / (t1 - t0));
158
+ }
159
+ describe("interpolateWithDerivatives", () => {
160
+ const points = [
161
+ [0, 0, 0],
162
+ [1, 2, 1],
163
+ [3, 1, 2],
164
+ [4, 4, 3],
165
+ ];
166
+ const params = [0, 0.3, 0.7, 1];
167
+ it("passes through every point with both end derivatives constrained", () => {
168
+ const data = interpolateWithDerivatives(points, params, [0, 0, 5], [5, 0, 0]);
169
+ expect(data.degree).toBe(3);
170
+ expect(data.poles.length).toBe(points.length + 2);
171
+ for (let k = 0; k < points.length; k++) {
172
+ const q = evaluateNd(data, params[k]);
173
+ for (let d = 0; d < 3; d++) {
174
+ expect(q[d]).toBeCloseTo(points[k][d], 9);
175
+ }
176
+ }
177
+ });
178
+ it("matches the requested end derivatives", () => {
179
+ const startD = [0, 0, 5];
180
+ const endD = [5, 0, 0];
181
+ const data = interpolateWithDerivatives(points, params, startD, endD);
182
+ const atStart = numericDerivative(data, 0);
183
+ const atEnd = numericDerivative(data, 1);
184
+ for (let d = 0; d < 3; d++) {
185
+ expect(atStart[d]).toBeCloseTo(startD[d], 4);
186
+ expect(atEnd[d]).toBeCloseTo(endD[d], 4);
187
+ }
188
+ });
189
+ it("supports a single constrained end", () => {
190
+ const startD = [0, 0, 4];
191
+ const data = interpolateWithDerivatives(points, params, startD, undefined);
192
+ expect(data.poles.length).toBe(points.length + 1);
193
+ for (let k = 0; k < points.length; k++) {
194
+ const q = evaluateNd(data, params[k]);
195
+ for (let d = 0; d < 3; d++) {
196
+ expect(q[d]).toBeCloseTo(points[k][d], 9);
197
+ }
198
+ }
199
+ const atStart = numericDerivative(data, 0);
200
+ for (let d = 0; d < 3; d++) {
201
+ expect(atStart[d]).toBeCloseTo(startD[d], 4);
202
+ }
203
+ });
204
+ it("reproduces a cubic Hermite segment for two points with derivatives", () => {
205
+ const data = interpolateWithDerivatives([[0, 0, 0], [1, 0, 0]], [0, 1], [0, 1, 0], [0, -1, 0]);
206
+ expect(data.degree).toBe(3);
207
+ expect(data.poles.length).toBe(4);
208
+ // Bézier form of the Hermite segment: P1 = P0 + D0/3, P2 = P3 - D1/3.
209
+ expect(data.poles[1][1]).toBeCloseTo(1 / 3, 9);
210
+ expect(data.poles[2][1]).toBeCloseTo(1 / 3, 9);
211
+ });
212
+ it("rejects non-increasing parameters", () => {
213
+ expect(() => interpolateWithDerivatives([[0, 0], [1, 1], [2, 2]], [0, 0.5, 0.5])).toThrow(/strictly increasing/);
214
+ });
215
+ });