fluidcad 0.0.30 → 0.0.32

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 (135) hide show
  1. package/lib/dist/common/build-error.d.ts +13 -0
  2. package/lib/dist/common/build-error.js +18 -0
  3. package/lib/dist/common/describe-error.d.ts +6 -0
  4. package/lib/dist/common/describe-error.js +26 -0
  5. package/lib/dist/common/operand-check.d.ts +19 -0
  6. package/lib/dist/common/operand-check.js +38 -0
  7. package/lib/dist/common/scene-object.d.ts +8 -0
  8. package/lib/dist/common/scene-object.js +10 -0
  9. package/lib/dist/common/shape-factory.d.ts +1 -1
  10. package/lib/dist/core/2d/arc.d.ts +4 -2
  11. package/lib/dist/core/2d/hmove.d.ts +8 -1
  12. package/lib/dist/core/2d/hmove.js +6 -2
  13. package/lib/dist/core/2d/pmove.d.ts +13 -3
  14. package/lib/dist/core/2d/pmove.js +6 -2
  15. package/lib/dist/core/2d/vmove.d.ts +8 -1
  16. package/lib/dist/core/2d/vmove.js +8 -4
  17. package/lib/dist/core/extrude.d.ts +17 -4
  18. package/lib/dist/core/extrude.js +8 -6
  19. package/lib/dist/core/interfaces.d.ts +16 -6
  20. package/lib/dist/core/mirror.d.ts +5 -5
  21. package/lib/dist/features/2d/aline.js +6 -2
  22. package/lib/dist/features/2d/arc.d.ts +3 -0
  23. package/lib/dist/features/2d/arc.js +28 -1
  24. package/lib/dist/features/2d/hline.js +5 -1
  25. package/lib/dist/features/2d/hmove.d.ts +2 -2
  26. package/lib/dist/features/2d/hmove.js +32 -7
  27. package/lib/dist/features/2d/intersect.js +17 -10
  28. package/lib/dist/features/2d/line.d.ts +2 -0
  29. package/lib/dist/features/2d/line.js +4 -0
  30. package/lib/dist/features/2d/pmove.d.ts +2 -2
  31. package/lib/dist/features/2d/pmove.js +47 -7
  32. package/lib/dist/features/2d/projection.d.ts +1 -1
  33. package/lib/dist/features/2d/projection.js +25 -15
  34. package/lib/dist/features/2d/sketch.d.ts +2 -2
  35. package/lib/dist/features/2d/sketch.js +10 -4
  36. package/lib/dist/features/2d/tarc-to-point.js +0 -3
  37. package/lib/dist/features/2d/tarc.js +0 -3
  38. package/lib/dist/features/2d/tline.js +0 -3
  39. package/lib/dist/features/2d/vline.js +5 -1
  40. package/lib/dist/features/2d/vmove.d.ts +2 -2
  41. package/lib/dist/features/2d/vmove.js +32 -7
  42. package/lib/dist/features/axis-from-edge.d.ts +1 -0
  43. package/lib/dist/features/axis-from-edge.js +8 -0
  44. package/lib/dist/features/chamfer.d.ts +1 -0
  45. package/lib/dist/features/chamfer.js +6 -0
  46. package/lib/dist/features/color.d.ts +1 -0
  47. package/lib/dist/features/color.js +6 -0
  48. package/lib/dist/features/common.d.ts +1 -0
  49. package/lib/dist/features/common.js +9 -0
  50. package/lib/dist/features/common2d.d.ts +1 -0
  51. package/lib/dist/features/common2d.js +9 -0
  52. package/lib/dist/features/draft.d.ts +1 -0
  53. package/lib/dist/features/draft.js +6 -0
  54. package/lib/dist/features/extrude-to-face.d.ts +5 -1
  55. package/lib/dist/features/extrude-to-face.js +50 -8
  56. package/lib/dist/features/extrude.js +19 -28
  57. package/lib/dist/features/fillet.d.ts +1 -0
  58. package/lib/dist/features/fillet.js +6 -0
  59. package/lib/dist/features/fillet2d.d.ts +1 -0
  60. package/lib/dist/features/fillet2d.js +9 -0
  61. package/lib/dist/features/fuse.d.ts +1 -0
  62. package/lib/dist/features/fuse.js +6 -0
  63. package/lib/dist/features/fuse2d.d.ts +1 -0
  64. package/lib/dist/features/fuse2d.js +9 -0
  65. package/lib/dist/features/loft.d.ts +1 -0
  66. package/lib/dist/features/loft.js +6 -0
  67. package/lib/dist/features/mirror-shape.d.ts +1 -0
  68. package/lib/dist/features/mirror-shape.js +28 -8
  69. package/lib/dist/features/plane-from-object.d.ts +1 -0
  70. package/lib/dist/features/plane-from-object.js +8 -0
  71. package/lib/dist/features/rotate.d.ts +1 -0
  72. package/lib/dist/features/rotate.js +11 -2
  73. package/lib/dist/features/select.d.ts +1 -0
  74. package/lib/dist/features/select.js +40 -12
  75. package/lib/dist/features/shell.d.ts +1 -0
  76. package/lib/dist/features/shell.js +6 -0
  77. package/lib/dist/features/simple-extruder.js +6 -3
  78. package/lib/dist/features/subtract.d.ts +1 -0
  79. package/lib/dist/features/subtract.js +5 -0
  80. package/lib/dist/features/subtract2d.d.ts +1 -0
  81. package/lib/dist/features/subtract2d.js +5 -0
  82. package/lib/dist/features/sweep.d.ts +1 -0
  83. package/lib/dist/features/sweep.js +4 -0
  84. package/lib/dist/features/translate.d.ts +1 -0
  85. package/lib/dist/features/translate.js +9 -0
  86. package/lib/dist/filters/face/above-below.d.ts +20 -0
  87. package/lib/dist/filters/face/above-below.js +57 -0
  88. package/lib/dist/filters/face/face-filter.d.ts +26 -0
  89. package/lib/dist/filters/face/face-filter.js +64 -0
  90. package/lib/dist/filters/face/planar-filter.d.ts +15 -0
  91. package/lib/dist/filters/face/planar-filter.js +30 -0
  92. package/lib/dist/filters/from-object.d.ts +1 -0
  93. package/lib/dist/filters/from-object.js +3 -0
  94. package/lib/dist/oc/boolean-ops.d.ts +2 -2
  95. package/lib/dist/oc/boolean-ops.js +8 -3
  96. package/lib/dist/oc/edge-ops.d.ts +17 -0
  97. package/lib/dist/oc/edge-ops.js +60 -0
  98. package/lib/dist/oc/face-maker2.d.ts +8 -0
  99. package/lib/dist/oc/face-maker2.js +42 -1
  100. package/lib/dist/oc/face-ops.d.ts +6 -1
  101. package/lib/dist/oc/face-ops.js +3 -2
  102. package/lib/dist/oc/face-query.js +19 -15
  103. package/lib/dist/oc/ray-intersect.d.ts +3 -2
  104. package/lib/dist/oc/ray-intersect.js +2 -4
  105. package/lib/dist/oc/shell-ops.js +15 -2
  106. package/lib/dist/oc/thin-face-maker.d.ts +15 -0
  107. package/lib/dist/oc/thin-face-maker.js +48 -7
  108. package/lib/dist/oc/wire-ops.d.ts +14 -0
  109. package/lib/dist/oc/wire-ops.js +38 -0
  110. package/lib/dist/rendering/render.js +6 -4
  111. package/lib/dist/tests/common/describe-error.test.d.ts +1 -0
  112. package/lib/dist/tests/common/describe-error.test.js +36 -0
  113. package/lib/dist/tests/features/2d/intersect.test.js +43 -0
  114. package/lib/dist/tests/features/2d/move.test.js +72 -1
  115. package/lib/dist/tests/features/2d/project-regression.test.js +35 -0
  116. package/lib/dist/tests/features/color-lineage.test.js +24 -0
  117. package/lib/dist/tests/features/cut.test.js +40 -0
  118. package/lib/dist/tests/features/cylinder-curve-filter.test.d.ts +1 -0
  119. package/lib/dist/tests/features/cylinder-curve-filter.test.js +99 -0
  120. package/lib/dist/tests/features/extrude-to-face.test.js +52 -0
  121. package/lib/dist/tests/features/extrude.test.js +46 -8
  122. package/lib/dist/tests/features/mirror.test.js +74 -0
  123. package/lib/dist/tests/features/select.test.js +141 -0
  124. package/lib/dist/tests/features/subtract-consumed-input.test.d.ts +1 -0
  125. package/lib/dist/tests/features/subtract-consumed-input.test.js +28 -0
  126. package/lib/dist/tests/features/thin-extrude-offset-fix.test.d.ts +1 -0
  127. package/lib/dist/tests/features/thin-extrude-offset-fix.test.js +34 -0
  128. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  129. package/package.json +2 -3
  130. package/ui/dist/assets/{index-6Ep4GPxf.js → index-DMw0OYCF.js} +70 -70
  131. package/ui/dist/assets/index-DR7c2Qk9.css +2 -0
  132. package/ui/dist/index.html +2 -2
  133. package/lib/dist/features/infinite-extrude.d.ts +0 -13
  134. package/lib/dist/features/infinite-extrude.js +0 -79
  135. package/ui/dist/assets/index-DRKfe6N9.css +0 -2
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { setupOC, render } from "../setup.js";
3
+ import sketch from "../../core/sketch.js";
4
+ import extrude from "../../core/extrude.js";
5
+ import fillet from "../../core/fillet.js";
6
+ import select from "../../core/select.js";
7
+ import { rect } from "../../core/2d/index.js";
8
+ import { face, edge } from "../../filters/index.js";
9
+ import cylinder from "../../core/cylinder.js";
10
+ import { getFacesByType } from "../utils.js";
11
+ import { FaceQuery } from "../../oc/face-query.js";
12
+ import { Explorer } from "../../oc/explorer.js";
13
+ import { getOC } from "../../oc/init.js";
14
+ function getSolid() {
15
+ return render().getAllSceneObjects()
16
+ .flatMap(o => o.getShapes())
17
+ .find(s => s.getType() === "solid");
18
+ }
19
+ describe("cylinderCurve filter on fillet faces", () => {
20
+ setupOC();
21
+ it("recognizes fillet faces produced from straight vertical edges (no draft)", () => {
22
+ sketch("xy", () => {
23
+ rect(100, 50);
24
+ });
25
+ extrude(30);
26
+ select(edge().verticalTo("xy"));
27
+ fillet(5);
28
+ const solid = getSolid();
29
+ const cylFaces = getFacesByType(solid, "cylinder");
30
+ expect(cylFaces).toHaveLength(4);
31
+ const cylinderCurveFaces = cylFaces.filter(f => FaceQuery.isCylinderCurveFace(f));
32
+ expect(cylinderCurveFaces).toHaveLength(4);
33
+ });
34
+ it("recognizes fillet faces produced from drafted vertical edges (matches user repro)", () => {
35
+ sketch("xy", () => {
36
+ rect(205, 133).centered();
37
+ });
38
+ const body = extrude(100).draft(10);
39
+ fillet(32, body.sideEdges());
40
+ const solid = getSolid();
41
+ const cylFaces = getFacesByType(solid, "cylinder");
42
+ expect(cylFaces.length).toBeGreaterThan(0);
43
+ const cylinderCurveFaces = cylFaces.filter(f => FaceQuery.isCylinderCurveFace(f));
44
+ if (cylinderCurveFaces.length !== cylFaces.length) {
45
+ const oc = getOC();
46
+ const missed = cylFaces.filter(f => !FaceQuery.isCylinderCurveFace(f));
47
+ for (const face of missed) {
48
+ const ocFace = oc.TopoDS.Face(face.getShape());
49
+ const edges = Explorer.findShapes(ocFace, oc.TopAbs_ShapeEnum.TopAbs_EDGE);
50
+ const curveTypeNames = {};
51
+ for (const k of Object.keys(oc.GeomAbs_CurveType)) {
52
+ const v = oc.GeomAbs_CurveType[k];
53
+ if (v && typeof v.value === "number") {
54
+ curveTypeNames[v.value] = k;
55
+ }
56
+ }
57
+ const edgeInfo = edges.map(e => {
58
+ const adaptor = new oc.BRepAdaptor_Curve(oc.TopoDS.Edge(e));
59
+ const t = adaptor.GetType();
60
+ const closed = adaptor.IsClosed();
61
+ adaptor.delete();
62
+ return { type: curveTypeNames[t.value] ?? t.value, closed };
63
+ });
64
+ console.log("Cylinder face missed by cylinderCurve:", JSON.stringify(edgeInfo));
65
+ }
66
+ }
67
+ expect(cylinderCurveFaces).toHaveLength(cylFaces.length);
68
+ });
69
+ it("face().cylinderCurve() returns fillet faces (drafted body)", () => {
70
+ sketch("xy", () => {
71
+ rect(205, 133).centered();
72
+ });
73
+ const body = extrude(100).draft(10);
74
+ fillet(32, body.sideEdges());
75
+ const sel = select(face().cylinderCurve());
76
+ render();
77
+ const selectedFaces = sel.getShapes();
78
+ expect(selectedFaces.length).toBeGreaterThan(0);
79
+ });
80
+ it("cylinderCurve(diameter) matches the cylinder surface radius, not bounding-edge radius", () => {
81
+ sketch("xy", () => {
82
+ rect(100, 50);
83
+ });
84
+ extrude(30);
85
+ select(edge().verticalTo("xy"));
86
+ fillet(5);
87
+ const matchingSel = select(face().cylinderCurve(10));
88
+ const nonMatchingSel = select(face().cylinderCurve(20));
89
+ render();
90
+ expect(matchingSel.getShapes()).toHaveLength(4);
91
+ expect(nonMatchingSel.getShapes()).toHaveLength(0);
92
+ });
93
+ it("cylinderCurve does NOT match a full cylinder primitive", () => {
94
+ cylinder(20, 30);
95
+ const sel = select(face().cylinderCurve());
96
+ render();
97
+ expect(sel.getShapes()).toHaveLength(0);
98
+ });
99
+ });
@@ -121,6 +121,58 @@ describe("extrude to face", () => {
121
121
  expect(lastBBox.maxZ).toBeGreaterThan(firstBBox.maxZ);
122
122
  });
123
123
  });
124
+ describe("first-face / last-face with filters", () => {
125
+ it("should narrow the candidate set with a face filter", () => {
126
+ // Cylinder at origin and a planar slab nearby. Without a filter,
127
+ // 'first-face' would pick the slab's top (at z=20). The cylinder
128
+ // filter forces selection of the cylindrical side face (z=40).
129
+ cylinder(50, 80);
130
+ sketch("xy", () => {
131
+ move([200, 0]);
132
+ rect(50, 50);
133
+ });
134
+ extrude(20).new();
135
+ sketch("xy", () => {
136
+ move([200, 100]);
137
+ rect(30, 30);
138
+ });
139
+ const e = extrude("first-face", face().cylinder());
140
+ render();
141
+ const shapes = e.getShapes();
142
+ expect(shapes).toHaveLength(1);
143
+ expect(shapes[0].getType()).toBe("solid");
144
+ });
145
+ it("should record an error when the filter eliminates all candidate faces", () => {
146
+ // Scene contains only planar geometry — cylinder filter matches nothing
147
+ sketch("xy", () => {
148
+ move([200, 0]);
149
+ rect(50, 50);
150
+ });
151
+ extrude(30).new();
152
+ sketch("xy", () => {
153
+ rect(20, 20);
154
+ });
155
+ const e = extrude("first-face", face().cylinder());
156
+ render();
157
+ expect(e.getError()).toMatch(/No face found for 'first-face' extrusion/);
158
+ });
159
+ it("should accept a filter together with an explicit target", () => {
160
+ cylinder(50, 80);
161
+ const target = sketch("xy", () => {
162
+ move([200, 100]);
163
+ rect(20, 20);
164
+ });
165
+ // Some other sketch in scope so the sketch context is non-trivial.
166
+ sketch("xy", () => {
167
+ rect(10, 10);
168
+ });
169
+ const e = extrude("first-face", face().cylinder(), target);
170
+ render();
171
+ const shapes = e.getShapes();
172
+ expect(shapes).toHaveLength(1);
173
+ expect(shapes[0].getType()).toBe("solid");
174
+ });
175
+ });
124
176
  describe("non-parallel planar face", () => {
125
177
  it("should extrude up to a drafted side face", () => {
126
178
  // Create a box with drafted sides — side faces are inclined planes
@@ -84,6 +84,27 @@ describe("extrude", () => {
84
84
  const scene = render();
85
85
  expect(countShapes(scene)).toBe(2);
86
86
  });
87
+ it("should only fuse with scene objects the new extrusion actually touches", () => {
88
+ // C1: cylinder at origin, z = 0..50
89
+ sketch("top", () => {
90
+ circle(50);
91
+ });
92
+ const e = extrude(50);
93
+ // C2: cylinder stacked on C1, z = 50..100, kept as a separate scene object
94
+ sketch(e.endFaces(), () => {
95
+ circle(50);
96
+ });
97
+ extrude(50).new();
98
+ // C3: cylinder at [100, 0] radius 50, externally tangent to C1, doesn't touch C2 at all
99
+ sketch("top", () => {
100
+ circle([100, 0], 50);
101
+ });
102
+ extrude();
103
+ const scene = render();
104
+ // C1 and C2 must remain separate solids — the third extrude should not
105
+ // pull them into a fusion with each other.
106
+ expect(countShapes(scene)).toBe(3);
107
+ });
87
108
  });
88
109
  describe("startFaces / endFaces", () => {
89
110
  it("should expose start face", () => {
@@ -259,6 +280,25 @@ describe("extrude", () => {
259
280
  expect(bbox.minZ).toBeCloseTo(0, 0);
260
281
  expect(bbox.maxZ).toBeCloseTo(30, 0);
261
282
  });
283
+ it("reverse-direction extrude classifies side/internal faces correctly", () => {
284
+ sketch("xy", () => {
285
+ rect(7, 5).centered();
286
+ });
287
+ const e = extrude(-1.5);
288
+ const sf = e.sideFaces();
289
+ const inf = e.internalFaces();
290
+ const se = e.sideEdges();
291
+ const ine = e.internalEdges();
292
+ addToScene(sf);
293
+ addToScene(inf);
294
+ addToScene(se);
295
+ addToScene(ine);
296
+ render();
297
+ expect(sf.getShapes()).toHaveLength(4);
298
+ expect(inf.getShapes()).toHaveLength(0);
299
+ expect(se.getShapes()).toHaveLength(4);
300
+ expect(ine.getShapes()).toHaveLength(0);
301
+ });
262
302
  });
263
303
  describe("startEdges / endEdges", () => {
264
304
  it("should expose start edges", () => {
@@ -446,17 +486,15 @@ describe("extrude", () => {
446
486
  it("should not include guide shapes in getShapes()", () => {
447
487
  sketch("xy", () => {
448
488
  rect(100, 50);
489
+ circle([50, 25], 20).guide();
449
490
  });
450
- const e = extrude(30).guide();
491
+ const e = extrude(30);
451
492
  render();
493
+ // The guide circle is excluded from the extrusion — the result is a
494
+ // plain box (6 faces), not a box with a circular hole (8 faces).
452
495
  const shapes = e.getShapes();
453
- expect(shapes).toHaveLength(0);
454
- // All added shapes should be marked as guide
455
- const allShapes = e.getAddedShapes();
456
- expect(allShapes.length).toBeGreaterThan(0);
457
- for (const shape of allShapes) {
458
- expect(shape.isGuideShape()).toBe(true);
459
- }
496
+ expect(shapes).toHaveLength(1);
497
+ expect(shapes[0].getFaces()).toHaveLength(6);
460
498
  });
461
499
  it("should include meta shapes when filter is disabled", () => {
462
500
  sketch("xy", () => {
@@ -109,6 +109,80 @@ describe("mirror (3D)", () => {
109
109
  expect(countShapes(scene)).toBe(1);
110
110
  });
111
111
  });
112
+ describe("mirror with .new()", () => {
113
+ it("should not fuse the mirrored copy with overlapping objects", () => {
114
+ // Centered cube — mirror across YZ produces a copy that overlaps the original.
115
+ // Default .add() would fuse to a single solid; .new() must keep them separate.
116
+ sketch("xy", () => {
117
+ rect(30, 30);
118
+ });
119
+ extrude(20).new();
120
+ mirror("yz").new();
121
+ const scene = render();
122
+ expect(countShapes(scene)).toBe(2);
123
+ });
124
+ });
125
+ describe("mirror with .remove()", () => {
126
+ it("should cut the mirrored copy out of the scene", () => {
127
+ // Solid spans y=-5..25 — mirroring across XZ overlaps in the y=-5..5 strip.
128
+ // .remove() uses the mirrored copy as a cut tool, leaving y=5..25.
129
+ sketch("xy", () => {
130
+ move([0, -5]);
131
+ rect(30, 30);
132
+ });
133
+ extrude(20).new();
134
+ mirror("xz").remove();
135
+ const scene = render();
136
+ expect(countShapes(scene)).toBe(1);
137
+ const solid = scene.getAllSceneObjects()
138
+ .flatMap(o => o.getShapes())
139
+ .find(s => s.getType() === "solid");
140
+ const bbox = ShapeOps.getBoundingBox(solid);
141
+ expect(bbox.minY).toBeCloseTo(5, 0);
142
+ expect(bbox.maxY).toBeCloseTo(25, 0);
143
+ });
144
+ });
145
+ describe("mirror with .scope()", () => {
146
+ it("should narrow .add() fusion to scoped objects only", () => {
147
+ // e1 is centered (mirror would overlap it); e2 is far away.
148
+ sketch("xy", () => {
149
+ rect(30, 30);
150
+ });
151
+ const e1 = extrude(20).new();
152
+ sketch("xy", () => {
153
+ move([60, 0]);
154
+ rect(30, 30);
155
+ });
156
+ const e2 = extrude(20).new();
157
+ // Mirror only e1 across YZ, but scope fusion to e2 (which doesn't intersect).
158
+ // The mirrored copy of e1 stays standalone; e1 and e2 are untouched.
159
+ mirror("yz", e1).add().scope(e2);
160
+ const scene = render();
161
+ // e1 + e2 + standalone mirrored-e1 = 3
162
+ expect(countShapes(scene)).toBe(3);
163
+ });
164
+ it("should narrow .remove() cut to scoped objects only", () => {
165
+ // Both solids cross the XZ plane — mirror across XZ would cut both by default.
166
+ sketch("xy", () => {
167
+ move([0, -5]);
168
+ rect(30, 30);
169
+ });
170
+ const e1 = extrude(20).new();
171
+ sketch("xy", () => {
172
+ move([60, -5]);
173
+ rect(30, 30);
174
+ });
175
+ const e2 = extrude(20).new();
176
+ // Cut only e1, leave e2 untouched.
177
+ mirror("xz", e1).remove().scope(e1);
178
+ const scene = render();
179
+ expect(countShapes(scene)).toBe(2);
180
+ const e2Shapes = e2.getShapes();
181
+ const e2Bbox = ShapeOps.getBoundingBox(e2Shapes[0]);
182
+ expect(e2Bbox.minY).toBeCloseTo(-5, 0);
183
+ expect(e2Bbox.maxY).toBeCloseTo(25, 0);
184
+ });
185
+ });
112
186
  describe("mirror with .exclude()", () => {
113
187
  it("should skip excluded objects when mirroring everything", () => {
114
188
  sketch("xy", () => {
@@ -7,6 +7,7 @@ import cylinder from "../../core/cylinder.js";
7
7
  import fillet from "../../core/fillet.js";
8
8
  import { circle, move, rect } from "../../core/2d/index.js";
9
9
  import { face, edge } from "../../filters/index.js";
10
+ import part from "../../core/part.js";
10
11
  describe("select", () => {
11
12
  setupOC();
12
13
  describe("face filters", () => {
@@ -227,6 +228,30 @@ describe("select", () => {
227
228
  expect(sel.getShapes()).toHaveLength(3);
228
229
  });
229
230
  });
231
+ describe("planar / notPlanar", () => {
232
+ it("should select planar faces from a drafted extrusion", () => {
233
+ sketch("xy", () => {
234
+ circle(60);
235
+ });
236
+ extrude(50).draft(10);
237
+ const sel = select(face().planar());
238
+ render();
239
+ // Drafted cylinder: top + bottom flat circles are planar (1 cone side is not)
240
+ const shapes = sel.getShapes();
241
+ expect(shapes).toHaveLength(2);
242
+ });
243
+ it("should exclude planar faces", () => {
244
+ sketch("xy", () => {
245
+ circle(60);
246
+ });
247
+ extrude(50).draft(10);
248
+ const sel = select(face().notPlanar());
249
+ render();
250
+ // Only the conical side face is non-planar
251
+ const shapes = sel.getShapes();
252
+ expect(shapes).toHaveLength(1);
253
+ });
254
+ });
230
255
  describe("cone / notCone", () => {
231
256
  it("should select conical faces from a drafted extrusion", () => {
232
257
  sketch("xy", () => {
@@ -251,6 +276,72 @@ describe("select", () => {
251
276
  expect(shapes).toHaveLength(2);
252
277
  });
253
278
  });
279
+ describe("above / below", () => {
280
+ it("should select faces entirely above a plane", () => {
281
+ sketch("xy", () => {
282
+ rect(100, 50);
283
+ });
284
+ extrude(30);
285
+ // Faces above z=15: only the top face (all verts at z=30) qualifies.
286
+ // Side faces have 2 verts at z=0 (not above), so they don't match.
287
+ const sel = select(face().above("xy", { offset: 15 }));
288
+ render();
289
+ expect(sel.getShapes()).toHaveLength(1);
290
+ });
291
+ it("should select faces entirely below a plane", () => {
292
+ sketch("xy", () => {
293
+ rect(100, 50);
294
+ });
295
+ extrude(30);
296
+ // Faces below z=15: only the bottom face (all verts at z=0) qualifies.
297
+ const sel = select(face().below("xy", { offset: 15 }));
298
+ render();
299
+ expect(sel.getShapes()).toHaveLength(1);
300
+ });
301
+ it("should not match faces on the plane itself", () => {
302
+ sketch("xy", () => {
303
+ rect(100, 50);
304
+ });
305
+ extrude(30);
306
+ // Faces above z=0: bottom face is ON the plane (dist=0), not above.
307
+ // Side faces straddle (2 verts on plane). Only top face qualifies.
308
+ const sel = select(face().above("xy"));
309
+ render();
310
+ expect(sel.getShapes()).toHaveLength(1);
311
+ });
312
+ it("should select partially above faces", () => {
313
+ sketch("xy", () => {
314
+ rect(100, 50);
315
+ });
316
+ extrude(30);
317
+ // partial: true — match faces with at least one vertex above z=0.
318
+ // Top face + 4 side faces (each has 2 verts at z=30) = 5.
319
+ const sel = select(face().above("xy", { partial: true }));
320
+ render();
321
+ expect(sel.getShapes()).toHaveLength(5);
322
+ });
323
+ it("should select partially below faces", () => {
324
+ sketch("xy", () => {
325
+ rect(100, 50);
326
+ });
327
+ extrude(30);
328
+ // partial: true — match faces with at least one vertex below z=30.
329
+ // Bottom face + 4 side faces (each has 2 verts at z=0) = 5.
330
+ const sel = select(face().below("xy", { offset: 30, partial: true }));
331
+ render();
332
+ expect(sel.getShapes()).toHaveLength(5);
333
+ });
334
+ it("should return empty when no faces match", () => {
335
+ sketch("xy", () => {
336
+ rect(100, 50);
337
+ });
338
+ extrude(30);
339
+ // Nothing is below z=0 on a box sitting on XY.
340
+ const sel = select(face().below("xy"));
341
+ render();
342
+ expect(sel.getShapes()).toHaveLength(0);
343
+ });
344
+ });
254
345
  });
255
346
  describe("edge filters", () => {
256
347
  describe("onPlane / notOnPlane", () => {
@@ -834,4 +925,54 @@ describe("select", () => {
834
925
  });
835
926
  });
836
927
  });
928
+ describe("cross-part selection", () => {
929
+ it("should select faces from another part via from()", () => {
930
+ const p1 = part("p1", () => {
931
+ cylinder(50, 100);
932
+ });
933
+ let sel;
934
+ part("p2", () => {
935
+ sel = select(face().from(p1));
936
+ });
937
+ render();
938
+ // A cylinder has 3 faces (top, bottom, lateral)
939
+ expect(sel.getShapes()).toHaveLength(3);
940
+ });
941
+ it("should narrow cross-part selection by additional filters", () => {
942
+ const p1 = part("p1", () => {
943
+ cylinder(50, 100);
944
+ });
945
+ let sel;
946
+ part("p2", () => {
947
+ sel = select(face().cylinder().from(p1));
948
+ });
949
+ render();
950
+ // Only the lateral cylindrical face
951
+ expect(sel.getShapes()).toHaveLength(1);
952
+ });
953
+ it("should select edges from another part via edge().from()", () => {
954
+ const p1 = part("p1", () => {
955
+ cylinder(50, 100);
956
+ });
957
+ let sel;
958
+ part("p2", () => {
959
+ sel = select(edge().from(p1));
960
+ });
961
+ render();
962
+ // A cylinder has 3 edges (top circle, bottom circle, seam)
963
+ expect(sel.getShapes()).toHaveLength(3);
964
+ });
965
+ it("should narrow cross-part edge selection by additional filters", () => {
966
+ const p1 = part("p1", () => {
967
+ cylinder(50, 100);
968
+ });
969
+ let sel;
970
+ part("p2", () => {
971
+ sel = select(edge().circle().from(p1));
972
+ });
973
+ render();
974
+ // The two circular edges (top + bottom) — seam is excluded
975
+ expect(sel.getShapes()).toHaveLength(2);
976
+ });
977
+ });
837
978
  });
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { setupOC, render } from "../setup.js";
3
+ import sketch from "../../core/sketch.js";
4
+ import revolve from "../../core/revolve.js";
5
+ import subtract from "../../core/subtract.js";
6
+ import axis from "../../core/axis.js";
7
+ import translate from "../../core/translate.js";
8
+ import { circle } from "../../core/2d/index.js";
9
+ describe("subtract: consumed-input diagnostic (issue #50)", () => {
10
+ setupOC();
11
+ it("explains which earlier op consumed an empty operand", () => {
12
+ const torusAxis = axis('y', { offsetZ: 20 });
13
+ sketch('yz', () => {
14
+ circle(8);
15
+ });
16
+ const torus1 = revolve(torusAxis).name(' torus1');
17
+ translate([0, 5, 0], torus1);
18
+ sketch('yz', () => {
19
+ circle(8);
20
+ });
21
+ const torus2 = revolve(torusAxis).new().name(' torus2');
22
+ const sub = subtract(torus1, torus2);
23
+ render();
24
+ const err = sub.getError();
25
+ expect(err).toMatch(/subtract:.*first operand.*has no shapes.*translate/);
26
+ expect(err).toMatch(/Hint:/);
27
+ });
28
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { setupOC, render } from "../setup.js";
3
+ import sketch from "../../core/sketch.js";
4
+ import extrude from "../../core/extrude.js";
5
+ import select from "../../core/select.js";
6
+ import fillet from "../../core/fillet.js";
7
+ import { rect, intersect, offset } from "../../core/2d/index.js";
8
+ import { edge } from "../../filters/index.js";
9
+ describe("thin extrude offset auto-fix", () => {
10
+ setupOC();
11
+ it("falls back to face-spine offset when wire-spine offset fails", () => {
12
+ // The drafted body's filleted corners produce GeomAbs_OffsetCurve edges
13
+ // when sectioned at z=0 and offset by -6. BRepOffsetAPI_MakeOffset's
14
+ // wire-spine path can't re-offset that wire (it throws "Offset wire is
15
+ // not closed."), but the face-spine path can — ThinFaceMaker.doOffset
16
+ // should retry on failure.
17
+ sketch("top", () => {
18
+ rect(205, 133).centered();
19
+ });
20
+ const body = extrude(100).draft(10);
21
+ fillet(32, body.sideEdges());
22
+ const s = select(edge().onPlane("top"));
23
+ sketch("bottom", () => {
24
+ intersect(s);
25
+ offset(-6, true);
26
+ });
27
+ const thin = extrude(5).thin(1.25, 1.25);
28
+ render();
29
+ expect(thin.getError()).toBeNull();
30
+ const shapes = thin.getShapes();
31
+ expect(shapes.length).toBeGreaterThan(0);
32
+ expect(shapes[0].getType()).toBe('solid');
33
+ });
34
+ });