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.
- package/lib/dist/core/extrude.d.ts +17 -4
- package/lib/dist/core/extrude.js +8 -6
- package/lib/dist/core/interfaces.d.ts +15 -5
- package/lib/dist/core/mirror.d.ts +5 -5
- package/lib/dist/features/2d/line.d.ts +2 -0
- package/lib/dist/features/2d/line.js +4 -0
- package/lib/dist/features/extrude-to-face.d.ts +5 -1
- package/lib/dist/features/extrude-to-face.js +50 -8
- package/lib/dist/features/extrude.js +19 -28
- package/lib/dist/features/rotate.js +2 -2
- package/lib/dist/features/select.d.ts +1 -0
- package/lib/dist/features/select.js +40 -12
- package/lib/dist/features/simple-extruder.js +6 -3
- package/lib/dist/filters/face/above-below.d.ts +20 -0
- package/lib/dist/filters/face/above-below.js +57 -0
- package/lib/dist/filters/face/face-filter.d.ts +26 -0
- package/lib/dist/filters/face/face-filter.js +64 -0
- package/lib/dist/filters/face/planar-filter.d.ts +15 -0
- package/lib/dist/filters/face/planar-filter.js +30 -0
- package/lib/dist/filters/from-object.d.ts +1 -0
- package/lib/dist/filters/from-object.js +3 -0
- package/lib/dist/oc/boolean-ops.js +8 -3
- package/lib/dist/oc/face-maker2.d.ts +8 -0
- package/lib/dist/oc/face-maker2.js +42 -1
- package/lib/dist/oc/face-ops.d.ts +6 -1
- package/lib/dist/oc/face-ops.js +3 -2
- package/lib/dist/oc/face-query.js +2 -2
- package/lib/dist/tests/features/cut.test.js +40 -0
- package/lib/dist/tests/features/extrude-to-face.test.js +52 -0
- package/lib/dist/tests/features/extrude.test.js +46 -8
- package/lib/dist/tests/features/select.test.js +141 -0
- package/lib/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/ui/dist/assets/{index-B15vMQZ2.js → index-DMw0OYCF.js} +97 -97
- package/ui/dist/index.html +1 -1
- package/lib/dist/features/infinite-extrude.d.ts +0 -13
- 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
|
-
|
|
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
|
|
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;
|
package/lib/dist/oc/face-ops.js
CHANGED
|
@@ -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
|
|
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.
|
|
350
|
-
if (distance
|
|
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)
|
|
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(
|
|
454
|
-
|
|
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
|
});
|