fluidcad 0.0.37 → 0.0.38
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/plane.d.ts +26 -6
- package/lib/dist/core/plane.js +21 -44
- package/lib/dist/features/2d/offset.js +2 -2
- package/lib/dist/features/plane-from-object.d.ts +16 -4
- package/lib/dist/features/plane-from-object.js +101 -8
- package/lib/dist/oc/sweep-ops.d.ts +7 -16
- package/lib/dist/oc/sweep-ops.js +67 -99
- package/lib/dist/oc/thin-face-maker.d.ts +0 -19
- package/lib/dist/oc/thin-face-maker.js +3 -68
- package/lib/dist/oc/wire-ops.d.ts +17 -2
- package/lib/dist/oc/wire-ops.js +55 -4
- package/lib/dist/tests/features/2d/offset.test.js +74 -1
- package/lib/dist/tests/features/plane.test.js +95 -0
- package/lib/dist/tests/features/sweep.test.js +46 -1
- package/lib/dist/tsconfig.tsbuildinfo +1 -1
- package/llm-docs/api/index.json +1 -1
- package/llm-docs/index.json +1 -1
- package/package.json +1 -1
- package/server/dist/fluidcad-server.d.ts +1 -1
- package/server/dist/fluidcad-server.js +11 -1
- package/server/dist/routes/params.js +1 -1
- package/ui/dist/assets/{index-no7mtr5s.js → index-D8zV21wB.js} +83 -83
- package/ui/dist/index.html +1 -1
|
@@ -4,7 +4,6 @@ import { WireOps } from "./wire-ops.js";
|
|
|
4
4
|
import { FaceOps } from "./face-ops.js";
|
|
5
5
|
import { EdgeOps } from "./edge-ops.js";
|
|
6
6
|
import { Explorer } from "./explorer.js";
|
|
7
|
-
import { Convert } from "./convert.js";
|
|
8
7
|
import { getOC } from "./init.js";
|
|
9
8
|
export class ThinFaceMaker {
|
|
10
9
|
static make(edges, plane, offset1, offset2) {
|
|
@@ -48,7 +47,7 @@ export class ThinFaceMaker {
|
|
|
48
47
|
return { faces, inwardEdges, outwardEdges };
|
|
49
48
|
}
|
|
50
49
|
static makeSingleOffsetFace(wire, isClosed, plane, offset) {
|
|
51
|
-
const offsetWire =
|
|
50
|
+
const offsetWire = WireOps.offsetWireOnPlane(wire, offset, isClosed, plane);
|
|
52
51
|
if (isClosed) {
|
|
53
52
|
// Determine which wire is outer (larger) vs inner based on offset sign
|
|
54
53
|
const [outer, inner] = offset >= 0
|
|
@@ -77,8 +76,8 @@ export class ThinFaceMaker {
|
|
|
77
76
|
if (Math.sign(offset1) === Math.sign(offset2)) {
|
|
78
77
|
offset2 = -offset2;
|
|
79
78
|
}
|
|
80
|
-
const wire1 =
|
|
81
|
-
const wire2 =
|
|
79
|
+
const wire1 = WireOps.offsetWireOnPlane(wire, offset1, isClosed, plane);
|
|
80
|
+
const wire2 = WireOps.offsetWireOnPlane(wire, offset2, isClosed, plane);
|
|
82
81
|
if (isClosed) {
|
|
83
82
|
// The wire with the larger offset is the outer boundary
|
|
84
83
|
const [outer, inner] = offset1 >= offset2
|
|
@@ -102,34 +101,6 @@ export class ThinFaceMaker {
|
|
|
102
101
|
const outwardEdges = this.matchFaceEdgesByMidpoint(face, outwardWireEdges);
|
|
103
102
|
return { face, inwardEdges, outwardEdges };
|
|
104
103
|
}
|
|
105
|
-
/**
|
|
106
|
-
* Offsets a wire by the given distance, handling both closed and open wires.
|
|
107
|
-
* For closed wires, WireOps.offsetWire handles negative distances natively.
|
|
108
|
-
* For open wires, negative distances are handled by reversing the wire,
|
|
109
|
-
* offsetting with the absolute value, then reversing back.
|
|
110
|
-
*
|
|
111
|
-
* If the wire-only offset throws (e.g. "Offset wire is not closed." on
|
|
112
|
-
* wires whose corners are GeomAbs_OffsetCurve segments from `offset()` over
|
|
113
|
-
* a drafted body's filleted bottom), retries with a planar face as the
|
|
114
|
-
* offset spine — that path supplies an explicit normal which keeps the
|
|
115
|
-
* algorithm stable on the same input.
|
|
116
|
-
*/
|
|
117
|
-
static doOffset(wire, plane, distance, isClosed) {
|
|
118
|
-
if (!isClosed) {
|
|
119
|
-
if (distance < 0) {
|
|
120
|
-
const reversed = WireOps.reverseWire(wire);
|
|
121
|
-
const offsetResult = this.offsetWireOnPlane(reversed, plane, -distance, true);
|
|
122
|
-
return WireOps.reverseWire(offsetResult);
|
|
123
|
-
}
|
|
124
|
-
return this.offsetWireOnPlane(wire, plane, distance, true);
|
|
125
|
-
}
|
|
126
|
-
try {
|
|
127
|
-
return WireOps.offsetWire(wire, distance, false);
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
return this.offsetWireOnPlane(wire, plane, distance, false);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
104
|
/**
|
|
134
105
|
* Merges adjacent edges that share the same underlying curve into a single
|
|
135
106
|
* edge (e.g. two conic-arc segments at a filleted corner produced by
|
|
@@ -154,42 +125,6 @@ export class ThinFaceMaker {
|
|
|
154
125
|
}
|
|
155
126
|
return Wire.fromTopoDSWire(oc.TopoDS.Wire(wires[0]));
|
|
156
127
|
}
|
|
157
|
-
/**
|
|
158
|
-
* Offsets an open wire on a given plane, using a planar face as reference
|
|
159
|
-
* so that BRepOffsetAPI_MakeOffset knows the offset direction.
|
|
160
|
-
* Only handles positive distances — use doOffset for sign handling.
|
|
161
|
-
*/
|
|
162
|
-
static offsetWireOnPlane(wire, plane, distance, isOpen) {
|
|
163
|
-
const oc = getOC();
|
|
164
|
-
const [pln, disposePlane] = Convert.toGpPln(plane);
|
|
165
|
-
const faceMaker = new oc.BRepBuilderAPI_MakeFace(pln);
|
|
166
|
-
if (!faceMaker.IsDone()) {
|
|
167
|
-
faceMaker.delete();
|
|
168
|
-
disposePlane();
|
|
169
|
-
throw new Error("Failed to create reference face for thin offset");
|
|
170
|
-
}
|
|
171
|
-
const face = faceMaker.Face();
|
|
172
|
-
faceMaker.delete();
|
|
173
|
-
disposePlane();
|
|
174
|
-
const maker = new oc.BRepOffsetAPI_MakeOffset();
|
|
175
|
-
maker.Init(face, oc.GeomAbs_JoinType.GeomAbs_Arc, isOpen);
|
|
176
|
-
maker.AddWire(wire.getShape());
|
|
177
|
-
maker.Perform(distance, 0);
|
|
178
|
-
if (!maker.IsDone()) {
|
|
179
|
-
maker.delete();
|
|
180
|
-
throw new Error("Failed to offset wire for thin extrude");
|
|
181
|
-
}
|
|
182
|
-
const result = maker.Shape();
|
|
183
|
-
maker.delete();
|
|
184
|
-
if (Explorer.isWire(result)) {
|
|
185
|
-
return Wire.fromTopoDSWire(oc.TopoDS.Wire(result));
|
|
186
|
-
}
|
|
187
|
-
const wires = Explorer.findShapes(result, oc.TopAbs_ShapeEnum.TopAbs_WIRE);
|
|
188
|
-
if (wires.length === 0) {
|
|
189
|
-
throw new Error("Thin offset produced no usable wire");
|
|
190
|
-
}
|
|
191
|
-
return Wire.fromTopoDSWire(oc.TopoDS.Wire(wires[0]));
|
|
192
|
-
}
|
|
193
128
|
/**
|
|
194
129
|
* Finds face edges that geometrically match the given wire edges by comparing midpoints.
|
|
195
130
|
* This is needed because wire reversal (ShapeExtend_WireData.Reverse) creates new TShapes,
|
|
@@ -9,7 +9,22 @@ export declare class WireOps {
|
|
|
9
9
|
static makeWireFromEdges(edges: Edge[]): Wire;
|
|
10
10
|
static reverseWire(wire: Wire): Wire;
|
|
11
11
|
static buildWire(edges: Edge[]): Wire;
|
|
12
|
-
static offsetWire(shape: Wire | Edge, distance: number, isOpen: boolean): Wire;
|
|
12
|
+
static offsetWire(shape: Wire | Edge, distance: number, isOpen: boolean, plane?: Plane): Wire;
|
|
13
|
+
/**
|
|
14
|
+
* Offsets a wire by `distance` on `plane`, handling open/closed wires and
|
|
15
|
+
* negative distances.
|
|
16
|
+
*
|
|
17
|
+
* The plane is passed as a reference face so BRepOffsetAPI_MakeOffset can
|
|
18
|
+
* resolve an offset direction even for wires with no curvature to imply a
|
|
19
|
+
* plane (e.g. a single straight segment, which otherwise fails outright).
|
|
20
|
+
*
|
|
21
|
+
* For open wires a negative distance offsets to the *opposite* side — the
|
|
22
|
+
* face-based offset does not flip sides on a sign change by itself, so the
|
|
23
|
+
* wire is reversed, offset by the magnitude, then reversed back. For closed
|
|
24
|
+
* wires the plane-less offset is tried first (the path circles rely on, where
|
|
25
|
+
* the sign selects inward/outward) and the face-based offset is the fallback.
|
|
26
|
+
*/
|
|
27
|
+
static offsetWireOnPlane(wire: Wire, distance: number, isClosed: boolean, plane: Plane): Wire;
|
|
13
28
|
static isCWRaw(wire: TopoDS_Wire, normal: Vector3d): boolean;
|
|
14
29
|
static makeWireFromEdgesRaw(edges: TopoDS_Edge[]): TopoDS_Wire;
|
|
15
30
|
static reverseWireRaw(wire: TopoDS_Wire): TopoDS_Wire;
|
|
@@ -31,5 +46,5 @@ export declare class WireOps {
|
|
|
31
46
|
} | null;
|
|
32
47
|
static groupConnectedEdges(edges: Edge[]): Edge[][];
|
|
33
48
|
private static verticesMatch;
|
|
34
|
-
static offsetWireRaw(wire: TopoDS_Wire, distance: number, isOpen: boolean): TopoDS_Wire;
|
|
49
|
+
static offsetWireRaw(wire: TopoDS_Wire, distance: number, isOpen: boolean, plane?: Plane): TopoDS_Wire;
|
|
35
50
|
}
|
package/lib/dist/oc/wire-ops.js
CHANGED
|
@@ -15,9 +15,39 @@ export class WireOps {
|
|
|
15
15
|
static buildWire(edges) {
|
|
16
16
|
return Wire.fromTopoDSWire(WireOps.buildWireRaw(edges.map(e => e.getShape())));
|
|
17
17
|
}
|
|
18
|
-
static offsetWire(shape, distance, isOpen) {
|
|
18
|
+
static offsetWire(shape, distance, isOpen, plane) {
|
|
19
19
|
const wire = shape instanceof Wire ? shape : WireOps.makeWireFromEdges([shape]);
|
|
20
|
-
return Wire.fromTopoDSWire(WireOps.offsetWireRaw(wire.getShape(), distance, isOpen));
|
|
20
|
+
return Wire.fromTopoDSWire(WireOps.offsetWireRaw(wire.getShape(), distance, isOpen, plane));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Offsets a wire by `distance` on `plane`, handling open/closed wires and
|
|
24
|
+
* negative distances.
|
|
25
|
+
*
|
|
26
|
+
* The plane is passed as a reference face so BRepOffsetAPI_MakeOffset can
|
|
27
|
+
* resolve an offset direction even for wires with no curvature to imply a
|
|
28
|
+
* plane (e.g. a single straight segment, which otherwise fails outright).
|
|
29
|
+
*
|
|
30
|
+
* For open wires a negative distance offsets to the *opposite* side — the
|
|
31
|
+
* face-based offset does not flip sides on a sign change by itself, so the
|
|
32
|
+
* wire is reversed, offset by the magnitude, then reversed back. For closed
|
|
33
|
+
* wires the plane-less offset is tried first (the path circles rely on, where
|
|
34
|
+
* the sign selects inward/outward) and the face-based offset is the fallback.
|
|
35
|
+
*/
|
|
36
|
+
static offsetWireOnPlane(wire, distance, isClosed, plane) {
|
|
37
|
+
if (!isClosed) {
|
|
38
|
+
if (distance < 0) {
|
|
39
|
+
const reversed = WireOps.reverseWire(wire);
|
|
40
|
+
const offsetResult = WireOps.offsetWire(reversed, -distance, true, plane);
|
|
41
|
+
return WireOps.reverseWire(offsetResult);
|
|
42
|
+
}
|
|
43
|
+
return WireOps.offsetWire(wire, distance, true, plane);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return WireOps.offsetWire(wire, distance, false);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return WireOps.offsetWire(wire, distance, false, plane);
|
|
50
|
+
}
|
|
21
51
|
}
|
|
22
52
|
static isCWRaw(wire, normal) {
|
|
23
53
|
const oc = getOC();
|
|
@@ -193,10 +223,31 @@ export class WireOps {
|
|
|
193
223
|
const dz = p1.z - p2.z;
|
|
194
224
|
return (dx * dx + dy * dy + dz * dz) < 1e-14;
|
|
195
225
|
}
|
|
196
|
-
static offsetWireRaw(wire, distance, isOpen) {
|
|
226
|
+
static offsetWireRaw(wire, distance, isOpen, plane) {
|
|
197
227
|
const oc = getOC();
|
|
198
228
|
const maker = new oc.BRepOffsetAPI_MakeOffset();
|
|
199
|
-
|
|
229
|
+
// BRepOffsetAPI_MakeOffset can't infer an offset direction from a wire with
|
|
230
|
+
// no curvature (e.g. a single straight segment), so the face-less Init
|
|
231
|
+
// fails with "Failed to offset wire". Initializing it with a planar
|
|
232
|
+
// reference face supplies the normal it needs — pass `plane` whenever the
|
|
233
|
+
// offset happens on a known sketch/target plane.
|
|
234
|
+
if (plane) {
|
|
235
|
+
const [pln, disposePlane] = Convert.toGpPln(plane);
|
|
236
|
+
const faceMaker = new oc.BRepBuilderAPI_MakeFace(pln);
|
|
237
|
+
if (!faceMaker.IsDone()) {
|
|
238
|
+
faceMaker.delete();
|
|
239
|
+
disposePlane();
|
|
240
|
+
maker.delete();
|
|
241
|
+
throw new Error("Failed to create reference face for wire offset");
|
|
242
|
+
}
|
|
243
|
+
const face = faceMaker.Face();
|
|
244
|
+
faceMaker.delete();
|
|
245
|
+
disposePlane();
|
|
246
|
+
maker.Init(face, oc.GeomAbs_JoinType.GeomAbs_Arc, isOpen);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
maker.Init(oc.GeomAbs_JoinType.GeomAbs_Arc, isOpen);
|
|
250
|
+
}
|
|
200
251
|
maker.AddWire(wire);
|
|
201
252
|
maker.Perform(distance, 0);
|
|
202
253
|
if (!maker.IsDone()) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { setupOC, render } from "../../setup.js";
|
|
3
3
|
import sketch from "../../../core/sketch.js";
|
|
4
|
-
import { circle, hLine, vLine, offset } from "../../../core/2d/index.js";
|
|
4
|
+
import { circle, hLine, vLine, aLine, line, offset } from "../../../core/2d/index.js";
|
|
5
5
|
import { ShapeOps } from "../../../oc/shape-ops.js";
|
|
6
6
|
describe("offset", () => {
|
|
7
7
|
setupOC();
|
|
@@ -42,6 +42,79 @@ describe("offset", () => {
|
|
|
42
42
|
expect(shapes.length).toBeGreaterThan(2);
|
|
43
43
|
});
|
|
44
44
|
});
|
|
45
|
+
// Regression: a single straight segment has no curvature to imply an offset
|
|
46
|
+
// plane, so BRepOffsetAPI_MakeOffset needs the sketch plane as a reference
|
|
47
|
+
// face. Without it, the offset failed with "Failed to offset wire" and no
|
|
48
|
+
// offset edge was produced.
|
|
49
|
+
describe("offset single open line", () => {
|
|
50
|
+
const endpoints = (edge) => [edge.getFirstVertex().toPoint(), edge.getLastVertex().toPoint()];
|
|
51
|
+
it("offsets a single hLine to a parallel line at the right distance", () => {
|
|
52
|
+
const s = sketch("xy", () => {
|
|
53
|
+
hLine(100);
|
|
54
|
+
offset(10);
|
|
55
|
+
});
|
|
56
|
+
render();
|
|
57
|
+
const shapes = s.getShapes();
|
|
58
|
+
expect(shapes.length).toBe(2);
|
|
59
|
+
const [a, b] = endpoints(shapes[1]);
|
|
60
|
+
expect(Math.abs(a.y)).toBeCloseTo(10, 6);
|
|
61
|
+
expect(Math.abs(b.y)).toBeCloseTo(10, 6);
|
|
62
|
+
expect(Math.hypot(b.x - a.x, b.y - a.y)).toBeCloseTo(100, 6);
|
|
63
|
+
});
|
|
64
|
+
it("offsets a single vLine to a parallel line at the right distance", () => {
|
|
65
|
+
const s = sketch("xy", () => {
|
|
66
|
+
vLine(100);
|
|
67
|
+
offset(10);
|
|
68
|
+
});
|
|
69
|
+
render();
|
|
70
|
+
const shapes = s.getShapes();
|
|
71
|
+
expect(shapes.length).toBe(2);
|
|
72
|
+
const [a, b] = endpoints(shapes[1]);
|
|
73
|
+
expect(Math.abs(a.x)).toBeCloseTo(10, 6);
|
|
74
|
+
expect(Math.abs(b.x)).toBeCloseTo(10, 6);
|
|
75
|
+
expect(Math.hypot(b.x - a.x, b.y - a.y)).toBeCloseTo(100, 6);
|
|
76
|
+
});
|
|
77
|
+
it("offsets a single aLine, preserving its length", () => {
|
|
78
|
+
const s = sketch("xy", () => {
|
|
79
|
+
aLine(30, 100);
|
|
80
|
+
offset(10);
|
|
81
|
+
});
|
|
82
|
+
render();
|
|
83
|
+
const shapes = s.getShapes();
|
|
84
|
+
expect(shapes.length).toBe(2);
|
|
85
|
+
const [a, b] = endpoints(shapes[1]);
|
|
86
|
+
expect(Math.hypot(b.x - a.x, b.y - a.y)).toBeCloseTo(100, 6);
|
|
87
|
+
});
|
|
88
|
+
it("offsets a single line, preserving its length", () => {
|
|
89
|
+
const s = sketch("xy", () => {
|
|
90
|
+
line([100, 50]);
|
|
91
|
+
offset(10);
|
|
92
|
+
});
|
|
93
|
+
render();
|
|
94
|
+
const shapes = s.getShapes();
|
|
95
|
+
expect(shapes.length).toBe(2);
|
|
96
|
+
const [a, b] = endpoints(shapes[1]);
|
|
97
|
+
expect(Math.hypot(b.x - a.x, b.y - a.y)).toBeCloseTo(Math.hypot(100, 50), 6);
|
|
98
|
+
});
|
|
99
|
+
it("offsets a single line to the opposite side with a negative distance", () => {
|
|
100
|
+
const sPos = sketch("xy", () => {
|
|
101
|
+
hLine(100);
|
|
102
|
+
offset(10);
|
|
103
|
+
});
|
|
104
|
+
render();
|
|
105
|
+
const posY = endpoints(sPos.getShapes()[1])[0].y;
|
|
106
|
+
const sNeg = sketch("xy", () => {
|
|
107
|
+
hLine(100);
|
|
108
|
+
offset(-10);
|
|
109
|
+
});
|
|
110
|
+
render();
|
|
111
|
+
const negShapes = sNeg.getShapes();
|
|
112
|
+
expect(negShapes.length).toBe(2);
|
|
113
|
+
const negY = endpoints(negShapes[1])[0].y;
|
|
114
|
+
expect(Math.abs(negY)).toBeCloseTo(10, 6);
|
|
115
|
+
expect(Math.sign(negY)).toBe(-Math.sign(posY));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
45
118
|
describe("offset direction", () => {
|
|
46
119
|
it("positive and negative offsets should produce different results", () => {
|
|
47
120
|
const s1 = sketch("xy", () => {
|
|
@@ -6,6 +6,7 @@ import plane from "../../core/plane.js";
|
|
|
6
6
|
import select from "../../core/select.js";
|
|
7
7
|
import { rect } from "../../core/2d/index.js";
|
|
8
8
|
import { face } from "../../filters/index.js";
|
|
9
|
+
import { Point } from "../../math/point.js";
|
|
9
10
|
describe("plane", () => {
|
|
10
11
|
setupOC();
|
|
11
12
|
describe("standard plane creation", () => {
|
|
@@ -111,6 +112,100 @@ describe("plane", () => {
|
|
|
111
112
|
expect(pl.origin.z).toBeCloseTo(50);
|
|
112
113
|
});
|
|
113
114
|
});
|
|
115
|
+
describe("plane from edge", () => {
|
|
116
|
+
const expectSamePoint = (a, b) => {
|
|
117
|
+
expect(a.x).toBeCloseTo(b.x);
|
|
118
|
+
expect(a.y).toBeCloseTo(b.y);
|
|
119
|
+
expect(a.z).toBeCloseTo(b.z);
|
|
120
|
+
};
|
|
121
|
+
it("should create a plane normal to an edge at its midpoint", () => {
|
|
122
|
+
sketch("xy", () => {
|
|
123
|
+
rect(100, 50);
|
|
124
|
+
});
|
|
125
|
+
const e = extrude(30);
|
|
126
|
+
const p = plane(e.startEdges(0), "middle");
|
|
127
|
+
render();
|
|
128
|
+
const pl = p.getPlane();
|
|
129
|
+
// A base-face edge lies in the z=0 plane, so its tangent — which becomes
|
|
130
|
+
// the plane normal — is horizontal.
|
|
131
|
+
expect(pl.origin.z).toBeCloseTo(0);
|
|
132
|
+
expect(pl.normal.z).toBeCloseTo(0);
|
|
133
|
+
// The normal is a unit vector.
|
|
134
|
+
expect(Math.hypot(pl.normal.x, pl.normal.y, pl.normal.z)).toBeCloseTo(1);
|
|
135
|
+
});
|
|
136
|
+
it("should default to the start when no position is given", () => {
|
|
137
|
+
sketch("xy", () => {
|
|
138
|
+
rect(100, 50);
|
|
139
|
+
});
|
|
140
|
+
const e = extrude(30);
|
|
141
|
+
const pStart = plane(e.startEdges(0), "start");
|
|
142
|
+
const pDefault = plane(e.startEdges(0));
|
|
143
|
+
render();
|
|
144
|
+
expectSamePoint(pDefault.getPlane().origin, pStart.getPlane().origin);
|
|
145
|
+
});
|
|
146
|
+
it("should place start/end at the endpoints with the midpoint between them", () => {
|
|
147
|
+
sketch("xy", () => {
|
|
148
|
+
rect(100, 50);
|
|
149
|
+
});
|
|
150
|
+
const e = extrude(30);
|
|
151
|
+
const pStart = plane(e.startEdges(0), "start");
|
|
152
|
+
const pEnd = plane(e.startEdges(0), "end");
|
|
153
|
+
const pMid = plane(e.startEdges(0), "middle");
|
|
154
|
+
render();
|
|
155
|
+
const s = pStart.getPlane().origin;
|
|
156
|
+
const en = pEnd.getPlane().origin;
|
|
157
|
+
const m = pMid.getPlane().origin;
|
|
158
|
+
// Distinct endpoints…
|
|
159
|
+
expect(s.distanceTo(en)).toBeGreaterThan(1);
|
|
160
|
+
// …with the midpoint halfway between them (the edge is straight).
|
|
161
|
+
expectSamePoint(m, new Point((s.x + en.x) / 2, (s.y + en.y) / 2, (s.z + en.z) / 2));
|
|
162
|
+
});
|
|
163
|
+
it("should treat the numeric position as a normalized 0–1 parameter", () => {
|
|
164
|
+
sketch("xy", () => {
|
|
165
|
+
rect(100, 50);
|
|
166
|
+
});
|
|
167
|
+
const e = extrude(30);
|
|
168
|
+
const p0 = plane(e.startEdges(0), 0);
|
|
169
|
+
const pHalf = plane(e.startEdges(0), 0.5);
|
|
170
|
+
const p1 = plane(e.startEdges(0), 1);
|
|
171
|
+
const pStart = plane(e.startEdges(0), "start");
|
|
172
|
+
const pMid = plane(e.startEdges(0), "middle");
|
|
173
|
+
const pEnd = plane(e.startEdges(0), "end");
|
|
174
|
+
render();
|
|
175
|
+
expectSamePoint(p0.getPlane().origin, pStart.getPlane().origin);
|
|
176
|
+
expectSamePoint(pHalf.getPlane().origin, pMid.getPlane().origin);
|
|
177
|
+
expectSamePoint(p1.getPlane().origin, pEnd.getPlane().origin);
|
|
178
|
+
});
|
|
179
|
+
it("should face outward at the start (cap convention)", () => {
|
|
180
|
+
sketch("xy", () => {
|
|
181
|
+
rect(100, 50);
|
|
182
|
+
});
|
|
183
|
+
const e = extrude(30);
|
|
184
|
+
const pStart = plane(e.startEdges(0), "start");
|
|
185
|
+
const pMid = plane(e.startEdges(0), "middle");
|
|
186
|
+
const pEnd = plane(e.startEdges(0), "end");
|
|
187
|
+
render();
|
|
188
|
+
const nStart = pStart.getPlane().normal;
|
|
189
|
+
const nMid = pMid.getPlane().normal;
|
|
190
|
+
const nEnd = pEnd.getPlane().normal;
|
|
191
|
+
// A straight edge has a constant forward tangent. The middle and end keep
|
|
192
|
+
// it; the start flips to face outward, so it's the negated tangent.
|
|
193
|
+
expectSamePoint(nMid, nEnd);
|
|
194
|
+
expectSamePoint(nStart, nEnd.negate());
|
|
195
|
+
});
|
|
196
|
+
it("should still treat a numeric argument on a face as a normal offset", () => {
|
|
197
|
+
sketch("xy", () => {
|
|
198
|
+
rect(100, 50);
|
|
199
|
+
});
|
|
200
|
+
const e = extrude(40);
|
|
201
|
+
const p = plane(e.endFaces(), 10);
|
|
202
|
+
render();
|
|
203
|
+
const pl = p.getPlane();
|
|
204
|
+
// Face path: the bare number is an offset along the normal (40 + 10).
|
|
205
|
+
expect(pl.origin.z).toBeCloseTo(50);
|
|
206
|
+
expect(Math.abs(pl.normal.z)).toBeCloseTo(1);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
114
209
|
describe("plane middle", () => {
|
|
115
210
|
it("should create a plane midway between two standard planes", () => {
|
|
116
211
|
const p1 = plane("xy");
|
|
@@ -5,7 +5,8 @@ import sweep from "../../core/sweep.js";
|
|
|
5
5
|
import extrude from "../../core/extrude.js";
|
|
6
6
|
import helix from "../../core/helix.js";
|
|
7
7
|
import cylinder from "../../core/cylinder.js";
|
|
8
|
-
import
|
|
8
|
+
import plane from "../../core/plane.js";
|
|
9
|
+
import { circle, rect, vLine, hLine, arc, line, move, hMove } from "../../core/2d/index.js";
|
|
9
10
|
import { countShapes } from "../utils.js";
|
|
10
11
|
import { ShapeOps } from "../../oc/shape-ops.js";
|
|
11
12
|
import { ShapeProps } from "../../oc/props.js";
|
|
@@ -433,4 +434,48 @@ describe("sweep", () => {
|
|
|
433
434
|
expect(vol).toBeLessThan(CYL_VOL - 100);
|
|
434
435
|
});
|
|
435
436
|
});
|
|
437
|
+
describe("helix thread sweep (asymmetric profile)", () => {
|
|
438
|
+
// An asymmetric profile swept along a helix is the case that exposes
|
|
439
|
+
// section twist — a rotationally symmetric circle (used by the tests above)
|
|
440
|
+
// looks identical no matter how the section spins, so it can't catch a
|
|
441
|
+
// wobbling trihedron. This sweeps a thread-like trapezoid drawn on a plane
|
|
442
|
+
// built off the helix (plane(h)), the orientation a user reaches for.
|
|
443
|
+
//
|
|
444
|
+
// The fixed binormal must track the helix axis. With the wrong binormal the
|
|
445
|
+
// section flips ~twice per turn, producing a self-intersecting ribbon whose
|
|
446
|
+
// mass piles up on one side (centroid leaves the axis) and whose volume
|
|
447
|
+
// collapses to a fraction of the real thread. For a whole number of turns a
|
|
448
|
+
// correct thread is axisymmetric: its centroid sits on the coil axis.
|
|
449
|
+
it("produces a clean axisymmetric coil, not a wobbling ribbon", () => {
|
|
450
|
+
const h = helix("z").height(80).radius(25).pitch(10); // 8 full turns
|
|
451
|
+
const p = plane(h);
|
|
452
|
+
const profile = sketch(p, () => {
|
|
453
|
+
line([3, 0], [-3, 0]);
|
|
454
|
+
line([-2, -6]);
|
|
455
|
+
line([2, -6]);
|
|
456
|
+
line([3, 0]);
|
|
457
|
+
});
|
|
458
|
+
const s = sweep(h, profile);
|
|
459
|
+
render();
|
|
460
|
+
expect(s.getError()).toBeNull();
|
|
461
|
+
const shapes = s.getShapes();
|
|
462
|
+
expect(shapes).toHaveLength(1);
|
|
463
|
+
const props = ShapeProps.getProperties(shapes[0].getShape());
|
|
464
|
+
// Centroid on the coil (Z) axis — a wobbling ribbon piled it out at the
|
|
465
|
+
// ~25mm coil radius instead.
|
|
466
|
+
const radialOffset = Math.hypot(props.centroid.x, props.centroid.y);
|
|
467
|
+
expect(radialOffset).toBeLessThan(1);
|
|
468
|
+
expect(props.centroid.z).toBeCloseTo(40, 0);
|
|
469
|
+
// Real thread volume ≈ profile area (30mm²) × coil length (~1260mm).
|
|
470
|
+
// The wobble collapsed this to ~1500mm³; a section that collapses toward
|
|
471
|
+
// the axis (wrong trihedron the other way) drops it to a few thousand.
|
|
472
|
+
expect(props.volumeMm3).toBeGreaterThan(25000);
|
|
473
|
+
expect(props.volumeMm3).toBeLessThan(40000);
|
|
474
|
+
// The coil sits at the helix radius (~25mm), not collapsed onto the axis.
|
|
475
|
+
const bbox = ShapeOps.getBoundingBox(shapes[0]);
|
|
476
|
+
const radialExtent = Math.max(bbox.maxX - bbox.minX, bbox.maxY - bbox.minY) / 2;
|
|
477
|
+
expect(radialExtent).toBeGreaterThan(23);
|
|
478
|
+
expect(radialExtent).toBeLessThan(30);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
436
481
|
});
|