fluidcad 0.0.31 → 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 (37) hide show
  1. package/lib/dist/core/extrude.d.ts +17 -4
  2. package/lib/dist/core/extrude.js +8 -6
  3. package/lib/dist/core/interfaces.d.ts +15 -5
  4. package/lib/dist/core/mirror.d.ts +5 -5
  5. package/lib/dist/features/2d/line.d.ts +2 -0
  6. package/lib/dist/features/2d/line.js +4 -0
  7. package/lib/dist/features/extrude-to-face.d.ts +5 -1
  8. package/lib/dist/features/extrude-to-face.js +50 -8
  9. package/lib/dist/features/extrude.js +19 -28
  10. package/lib/dist/features/rotate.js +2 -2
  11. package/lib/dist/features/select.d.ts +1 -0
  12. package/lib/dist/features/select.js +40 -12
  13. package/lib/dist/features/simple-extruder.js +6 -3
  14. package/lib/dist/filters/face/above-below.d.ts +20 -0
  15. package/lib/dist/filters/face/above-below.js +57 -0
  16. package/lib/dist/filters/face/face-filter.d.ts +26 -0
  17. package/lib/dist/filters/face/face-filter.js +64 -0
  18. package/lib/dist/filters/face/planar-filter.d.ts +15 -0
  19. package/lib/dist/filters/face/planar-filter.js +30 -0
  20. package/lib/dist/filters/from-object.d.ts +1 -0
  21. package/lib/dist/filters/from-object.js +3 -0
  22. package/lib/dist/oc/boolean-ops.js +8 -3
  23. package/lib/dist/oc/face-maker2.d.ts +8 -0
  24. package/lib/dist/oc/face-maker2.js +42 -1
  25. package/lib/dist/oc/face-ops.d.ts +6 -1
  26. package/lib/dist/oc/face-ops.js +3 -2
  27. package/lib/dist/oc/face-query.js +2 -2
  28. package/lib/dist/tests/features/cut.test.js +40 -0
  29. package/lib/dist/tests/features/extrude-to-face.test.js +52 -0
  30. package/lib/dist/tests/features/extrude.test.js +46 -8
  31. package/lib/dist/tests/features/select.test.js +141 -0
  32. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +1 -1
  34. package/ui/dist/assets/{index-B15vMQZ2.js → index-DMw0OYCF.js} +97 -97
  35. package/ui/dist/index.html +1 -1
  36. package/lib/dist/features/infinite-extrude.d.ts +0 -13
  37. package/lib/dist/features/infinite-extrude.js +0 -79
@@ -5,6 +5,7 @@ import { ConeFilter, NotConeFilter } from "./cone-filter.js";
5
5
  import { CylinderCurveFilter, NotCylinderCurveFilter } from "./cylinder-curve.js";
6
6
  import { CylinderFilter, NotCylinderFilter } from "./cylinder.js";
7
7
  import { TorusFilter, NotTorusFilter } from "./torus-filter.js";
8
+ import { PlanarFilter, NotPlanarFilter } from "./planar-filter.js";
8
9
  import { NotOnPlaneFilter, OnPlaneFilter } from "./on-plane.js";
9
10
  import { NotParallelFilter, ParallelFilter } from "./parallel.js";
10
11
  import { PlaneObject } from "../../features/plane.js";
@@ -15,6 +16,7 @@ import { HasEdgeFromSceneObjectFilter, NotHasEdgeFromSceneObjectFilter } from ".
15
16
  import { FromSceneObjectFilter } from "../from-object.js";
16
17
  import { EdgeCountFilter, NotEdgeCountFilter } from "./edge-count.js";
17
18
  import { IntersectsWithFilter, NotIntersectsWithFilter } from "./intersects-with.js";
19
+ import { AboveFacePlaneFilter, BelowFacePlaneFilter } from "./above-below.js";
18
20
  import { SceneObject } from "../../common/scene-object.js";
19
21
  export class FaceFilterBuilder extends FilterBuilderBase {
20
22
  constructor() {
@@ -204,6 +206,22 @@ export class FaceFilterBuilder extends FilterBuilderBase {
204
206
  this.filters.push(filter);
205
207
  return this;
206
208
  }
209
+ /**
210
+ * Selects planar (flat) faces.
211
+ */
212
+ planar() {
213
+ const filter = new PlanarFilter();
214
+ this.filters.push(filter);
215
+ return this;
216
+ }
217
+ /**
218
+ * Excludes planar (flat) faces.
219
+ */
220
+ notPlanar() {
221
+ const filter = new NotPlanarFilter();
222
+ this.filters.push(filter);
223
+ return this;
224
+ }
207
225
  /**
208
226
  * Selects conical faces.
209
227
  */
@@ -306,6 +324,52 @@ export class FaceFilterBuilder extends FilterBuilderBase {
306
324
  this.filters.push(filter);
307
325
  return this;
308
326
  }
327
+ /**
328
+ * Selects faces that are entirely above the given plane (in the direction of its normal).
329
+ * @param plane - The reference plane.
330
+ * @param offsetOrOptions - Offset distance, or an options object with `offset` and `partial`.
331
+ */
332
+ above(plane, offsetOrOptions) {
333
+ if (!plane) {
334
+ throw new Error('Plane is required');
335
+ }
336
+ const opts = typeof offsetOrOptions === 'number' ? { offset: offsetOrOptions } : (offsetOrOptions ?? {});
337
+ const { offset = 0, partial = false } = opts;
338
+ let planeObj;
339
+ if (plane instanceof PlaneObjectBase) {
340
+ planeObj = plane;
341
+ }
342
+ else {
343
+ let normalized = normalizePlane(plane);
344
+ planeObj = offset ? new PlaneObject(normalized.offset(offset)) : new PlaneObject(normalized);
345
+ }
346
+ const filter = new AboveFacePlaneFilter(planeObj, partial);
347
+ this.filters.push(filter);
348
+ return this;
349
+ }
350
+ /**
351
+ * Selects faces that are entirely below the given plane (opposite to its normal direction).
352
+ * @param plane - The reference plane.
353
+ * @param offsetOrOptions - Offset distance, or an options object with `offset` and `partial`.
354
+ */
355
+ below(plane, offsetOrOptions) {
356
+ if (!plane) {
357
+ throw new Error('Plane is required');
358
+ }
359
+ const opts = typeof offsetOrOptions === 'number' ? { offset: offsetOrOptions } : (offsetOrOptions ?? {});
360
+ const { offset = 0, partial = false } = opts;
361
+ let planeObj;
362
+ if (plane instanceof PlaneObjectBase) {
363
+ planeObj = plane;
364
+ }
365
+ else {
366
+ let normalized = normalizePlane(plane);
367
+ planeObj = offset ? new PlaneObject(normalized.offset(offset)) : new PlaneObject(normalized);
368
+ }
369
+ const filter = new BelowFacePlaneFilter(planeObj, partial);
370
+ this.filters.push(filter);
371
+ return this;
372
+ }
309
373
  /**
310
374
  * Restricts the selection to faces originating from the given scene objects.
311
375
  * Recursive: passing a container picks up faces from its descendants.
@@ -0,0 +1,15 @@
1
+ import { Matrix4 } from "../../math/matrix4.js";
2
+ import { Face } from "../../common/shapes.js";
3
+ import { FilterBase } from "../filter-base.js";
4
+ export declare class PlanarFilter extends FilterBase<Face> {
5
+ constructor();
6
+ match(shape: Face): boolean;
7
+ compareTo(other: PlanarFilter): boolean;
8
+ transform(matrix: Matrix4): PlanarFilter;
9
+ }
10
+ export declare class NotPlanarFilter extends FilterBase<Face> {
11
+ constructor();
12
+ match(shape: Face): boolean;
13
+ compareTo(other: NotPlanarFilter): boolean;
14
+ transform(matrix: Matrix4): NotPlanarFilter;
15
+ }
@@ -0,0 +1,30 @@
1
+ import { FilterBase } from "../filter-base.js";
2
+ import { FaceQuery } from "../../oc/face-query.js";
3
+ export class PlanarFilter extends FilterBase {
4
+ constructor() {
5
+ super();
6
+ }
7
+ match(shape) {
8
+ return FaceQuery.isPlanarFace(shape);
9
+ }
10
+ compareTo(other) {
11
+ return true;
12
+ }
13
+ transform(matrix) {
14
+ return new PlanarFilter();
15
+ }
16
+ }
17
+ export class NotPlanarFilter extends FilterBase {
18
+ constructor() {
19
+ super();
20
+ }
21
+ match(shape) {
22
+ return !FaceQuery.isPlanarFace(shape);
23
+ }
24
+ compareTo(other) {
25
+ return true;
26
+ }
27
+ transform(matrix) {
28
+ return new NotPlanarFilter();
29
+ }
30
+ }
@@ -7,6 +7,7 @@ export declare class FromSceneObjectFilter<TShape extends Shape> extends FilterB
7
7
  private sceneObjects;
8
8
  private shapeType;
9
9
  constructor(sceneObjects: SceneObject[], shapeType: ShapeType);
10
+ getSceneObjects(): SceneObject[];
10
11
  match(shape: TShape): boolean;
11
12
  compareTo(other: FromSceneObjectFilter<TShape>): boolean;
12
13
  transform(_matrix: Matrix4): FromSceneObjectFilter<TShape>;
@@ -7,6 +7,9 @@ export class FromSceneObjectFilter extends FilterBase {
7
7
  this.sceneObjects = sceneObjects;
8
8
  this.shapeType = shapeType;
9
9
  }
10
+ getSceneObjects() {
11
+ return this.sceneObjects;
12
+ }
10
13
  match(shape) {
11
14
  for (const obj of this.sceneObjects) {
12
15
  const subShapes = obj.getShapes().flatMap(s => s.getSubShapes(this.shapeType));
@@ -168,10 +168,15 @@ export class BooleanOps {
168
168
  else if (opts?.glue === 'shift') {
169
169
  builder.SetGlue(oc.BOPAlgo_GlueEnum.BOPAlgo_GlueShift);
170
170
  }
171
+ // Wrap all stocks in a single compound argument. OCC's pave-filling step
172
+ // computes intersections between distinct arguments — so two touching
173
+ // stocks passed as separate args end up merged with each other even when
174
+ // the tool doesn't touch them. Bundling them under one TopoDS_Compound
175
+ // keeps stock-to-stock relationships out of the result; only stock↔tool
176
+ // interactions are computed.
177
+ const stockCompound = ShapeOps.makeCompoundRaw(stock.map(s => s.getShape()));
171
178
  const stockList = new oc.TopTools_ListOfShape();
172
- for (const s of stock) {
173
- stockList.Append(s.getShape());
174
- }
179
+ stockList.Append(stockCompound);
175
180
  const toolList = new oc.TopTools_ListOfShape();
176
181
  for (const t of tools) {
177
182
  toolList.Append(t.getShape());
@@ -6,5 +6,13 @@ export declare class FaceMaker2 {
6
6
  static getRegions(shapes: Array<Wire | Edge>, plane: Plane, drill?: boolean): Face[];
7
7
  private static getDrilledFaces;
8
8
  private static getFaces;
9
+ /**
10
+ * Sizes the bounded plane face used by `getFaces`'s splitter so it always
11
+ * encloses the input edges with margin. The default ±1000 face used to
12
+ * silently swallow sketches placed far from origin: edges that crossed the
13
+ * boundary produced regions touching `boundaryEdges`, which the filter at
14
+ * the end of `getFaces` then dropped — leaving an extrude with zero faces.
15
+ */
16
+ private static computePlaneFaceBounds;
9
17
  private static getSplitEdges;
10
18
  }
@@ -1,4 +1,5 @@
1
1
  import { Edge } from "../common/edge.js";
2
+ import { Point } from "../math/point.js";
2
3
  import { Explorer } from "./explorer.js";
3
4
  import { getOC } from "./init.js";
4
5
  import { Convert } from "./convert.js";
@@ -67,7 +68,7 @@ export class FaceMaker2 {
67
68
  static getFaces(edges, plane) {
68
69
  const [gpPln, dispose] = Convert.toGpPln(plane);
69
70
  const oc = getOC();
70
- const planeFace = FaceOps.makeFaceFromPlane2(gpPln);
71
+ const planeFace = FaceOps.makeFaceFromPlane2(gpPln, this.computePlaneFaceBounds(edges, plane));
71
72
  // Collect boundary edges of the big face before splitting
72
73
  const boundaryEdges = Explorer.findShapes(planeFace, oc.TopAbs_ShapeEnum.TopAbs_EDGE);
73
74
  const splitter = new oc.BRepAlgoAPI_Splitter();
@@ -108,6 +109,46 @@ export class FaceMaker2 {
108
109
  dispose();
109
110
  return filtered.map(f => Face.fromTopoDSFace(oc.TopoDS.Face(f)));
110
111
  }
112
+ /**
113
+ * Sizes the bounded plane face used by `getFaces`'s splitter so it always
114
+ * encloses the input edges with margin. The default ±1000 face used to
115
+ * silently swallow sketches placed far from origin: edges that crossed the
116
+ * boundary produced regions touching `boundaryEdges`, which the filter at
117
+ * the end of `getFaces` then dropped — leaving an extrude with zero faces.
118
+ */
119
+ static computePlaneFaceBounds(edges, plane) {
120
+ let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity;
121
+ for (const edge of edges) {
122
+ const bbox = ShapeOps.getBoundingBox(edge);
123
+ const corners = [
124
+ new Point(bbox.minX, bbox.minY, bbox.minZ),
125
+ new Point(bbox.maxX, bbox.minY, bbox.minZ),
126
+ new Point(bbox.minX, bbox.maxY, bbox.minZ),
127
+ new Point(bbox.maxX, bbox.maxY, bbox.minZ),
128
+ new Point(bbox.minX, bbox.minY, bbox.maxZ),
129
+ new Point(bbox.maxX, bbox.minY, bbox.maxZ),
130
+ new Point(bbox.minX, bbox.maxY, bbox.maxZ),
131
+ new Point(bbox.maxX, bbox.maxY, bbox.maxZ),
132
+ ];
133
+ for (const c of corners) {
134
+ const uv = plane.worldToLocal(c);
135
+ if (uv.x < uMin) {
136
+ uMin = uv.x;
137
+ }
138
+ if (uv.x > uMax) {
139
+ uMax = uv.x;
140
+ }
141
+ if (uv.y < vMin) {
142
+ vMin = uv.y;
143
+ }
144
+ if (uv.y > vMax) {
145
+ vMax = uv.y;
146
+ }
147
+ }
148
+ }
149
+ const half = Math.max(Math.abs(uMin), Math.abs(uMax), Math.abs(vMin), Math.abs(vMax), 1000) * 1.5;
150
+ return { uMin: -half, uMax: half, vMin: -half, vMax: half };
151
+ }
111
152
  static getSplitEdges(shapes) {
112
153
  const oc = getOC();
113
154
  console.log('Getting split edges for shapes:', shapes.length);
@@ -20,7 +20,12 @@ export declare class FaceOps {
20
20
  static fixFaceOrientation(face: Face | TopoDS_Face): Face;
21
21
  static makeFaceWithHoles(outerWire: Wire, holes: Wire[]): Face;
22
22
  static isPointInsideFace(point: Point, face: Face | TopoDS_Face): boolean;
23
- static makeFaceFromPlane2(plane: gp_Pln): TopoDS_Face;
23
+ static makeFaceFromPlane2(plane: gp_Pln, bounds?: {
24
+ uMin: number;
25
+ uMax: number;
26
+ vMin: number;
27
+ vMax: number;
28
+ }): TopoDS_Face;
24
29
  static makeFaceFromPlane(plane: gp_Pln): TopoDS_Face;
25
30
  static makeFaceFromCylinder(cylinder: gp_Cylinder): TopoDS_Face;
26
31
  static planeToFace(plane: Plane, center?: Point): Face;
@@ -197,9 +197,10 @@ export class FaceOps {
197
197
  disposePnt();
198
198
  return isInside;
199
199
  }
200
- static makeFaceFromPlane2(plane) {
200
+ static makeFaceFromPlane2(plane, bounds) {
201
201
  const oc = getOC();
202
- const faceMaker = new oc.BRepBuilderAPI_MakeFace(plane, -1000, 1000, -1000, 1000);
202
+ const b = bounds ?? { uMin: -1000, uMax: 1000, vMin: -1000, vMax: 1000 };
203
+ const faceMaker = new oc.BRepBuilderAPI_MakeFace(plane, b.uMin, b.uMax, b.vMin, b.vMax);
203
204
  const face = faceMaker.Face();
204
205
  faceMaker.delete();
205
206
  return face;
@@ -346,8 +346,8 @@ export class FaceQuery {
346
346
  let bestFace = null;
347
347
  let bestDistance = mode === 'first' ? Infinity : -Infinity;
348
348
  for (const face of faces) {
349
- const distance = plane.distanceToPoint(face.center());
350
- if (distance < tolerance) {
349
+ const distance = plane.signedDistanceToPoint(face.center());
350
+ if (distance <= tolerance) {
351
351
  continue;
352
352
  }
353
353
  if (mode === 'first' ? distance < bestDistance : distance > bestDistance) {
@@ -78,6 +78,46 @@ describe("cut", () => {
78
78
  // Circle edges at top and bottom of the hole
79
79
  expect(getEdgesByType(solid, "circle").length).toBeGreaterThanOrEqual(2);
80
80
  });
81
+ it("should apply draft to a through-all cut", () => {
82
+ sketch("xy", () => {
83
+ rect(100, 100);
84
+ });
85
+ const e = extrude(50);
86
+ sketch(e.endFaces(), () => {
87
+ move([50, 50]);
88
+ circle(40);
89
+ });
90
+ cut().draft(-5);
91
+ const scene = render();
92
+ expect(countShapes(scene)).toBe(1);
93
+ const solid = scene.getAllSceneObjects()
94
+ .flatMap(o => o.getShapes())
95
+ .find(s => s.getType() === "solid");
96
+ // Drafted through-all cut: hole wall is a cone, not a cylinder
97
+ expect(getFacesByType(solid, "cone").length).toBeGreaterThanOrEqual(1);
98
+ expect(getFacesByType(solid, "cylinder")).toHaveLength(0);
99
+ });
100
+ it("should apply draft to a through-all cut on a small profile", () => {
101
+ // Mirrors the user's repro: small radius (1.5) and steep draft (-8°).
102
+ // With THROUGH_ALL_LENGTH=100000, lateral draft = 100000 * tan(8°) ≈ 14054
103
+ // would invert a 1.5-radius profile if applied over the full prism length.
104
+ sketch("xy", () => {
105
+ rect(7, 5).centered();
106
+ });
107
+ const e = extrude(1.5);
108
+ sketch(e.endFaces(), () => {
109
+ circle(1.5);
110
+ });
111
+ cut().draft(-8);
112
+ const scene = render();
113
+ expect(countShapes(scene)).toBe(1);
114
+ const solid = scene.getAllSceneObjects()
115
+ .flatMap(o => o.getShapes())
116
+ .find(s => s.getType() === "solid");
117
+ // Drafted through-all cut: hole wall is a cone, not a cylinder
118
+ expect(getFacesByType(solid, "cone").length).toBeGreaterThanOrEqual(1);
119
+ expect(getFacesByType(solid, "cylinder")).toHaveLength(0);
120
+ });
81
121
  });
82
122
  describe("section edges", () => {
83
123
  it("should expose section edges", () => {
@@ -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", () => {
@@ -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
  });