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.
@@ -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 = this.doOffset(wire, plane, offset, isClosed);
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 = this.doOffset(wire, plane, offset1, isClosed);
81
- const wire2 = this.doOffset(wire, plane, offset2, isClosed);
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
  }
@@ -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
- maker.Init(oc.GeomAbs_JoinType.GeomAbs_Arc, isOpen);
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 { circle, rect, vLine, hLine, arc, move, hMove } from "../../core/2d/index.js";
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
  });