fluidcad 0.0.15 → 0.0.16

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 (38) hide show
  1. package/bin/fluidcad.js +2 -2
  2. package/lib/dist/common/scene-object.d.ts +4 -1
  3. package/lib/dist/common/scene-object.js +14 -3
  4. package/lib/dist/core/interfaces.d.ts +18 -0
  5. package/lib/dist/core/part.js +1 -1
  6. package/lib/dist/core/repeat.js +2 -1
  7. package/lib/dist/features/2d/aline.d.ts +2 -0
  8. package/lib/dist/features/2d/aline.js +4 -0
  9. package/lib/dist/features/axis.d.ts +2 -0
  10. package/lib/dist/features/axis.js +3 -0
  11. package/lib/dist/features/color.d.ts +1 -0
  12. package/lib/dist/features/color.js +4 -0
  13. package/lib/dist/features/extrude-base.d.ts +13 -0
  14. package/lib/dist/features/extrude-base.js +64 -0
  15. package/lib/dist/features/extrude-to-face.js +28 -7
  16. package/lib/dist/features/extrude-two-distances.js +72 -17
  17. package/lib/dist/features/extrude.js +90 -21
  18. package/lib/dist/features/mirror-shape2d.d.ts +1 -0
  19. package/lib/dist/features/mirror-shape2d.js +7 -0
  20. package/lib/dist/features/remove.js +1 -1
  21. package/lib/dist/filters/filter-builder-base.d.ts +7 -0
  22. package/lib/dist/filters/filter-builder-base.js +16 -0
  23. package/lib/dist/filters/filter.js +6 -0
  24. package/lib/dist/filters/tangent-expander.d.ts +47 -0
  25. package/lib/dist/filters/tangent-expander.js +223 -0
  26. package/lib/dist/index.js +6 -1
  27. package/lib/dist/oc/thin-face-maker.d.ts +12 -1
  28. package/lib/dist/oc/thin-face-maker.js +54 -13
  29. package/lib/dist/rendering/scene.js +5 -4
  30. package/lib/dist/tests/features/extrude-two-distances.test.js +16 -0
  31. package/lib/dist/tests/features/repeat-circular.test.js +12 -0
  32. package/lib/dist/tests/features/repeat-linear.test.js +22 -0
  33. package/lib/dist/tests/features/select.test.js +55 -0
  34. package/lib/dist/tests/features/thin-extrude.test.js +61 -0
  35. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  36. package/package.json +3 -3
  37. package/ui/dist/assets/{index-BJG141m7.js → index-CLQhpG6A.js} +1 -1
  38. package/ui/dist/index.html +1 -1
@@ -3,6 +3,7 @@ import { Face } from "../common/face.js";
3
3
  import { Wire } from "../common/wire.js";
4
4
  import { WireOps } from "./wire-ops.js";
5
5
  import { FaceOps } from "./face-ops.js";
6
+ import { EdgeOps } from "./edge-ops.js";
6
7
  import { Explorer } from "./explorer.js";
7
8
  import { Convert } from "./convert.js";
8
9
  import { getOC } from "./init.js";
@@ -21,17 +22,25 @@ export class ThinFaceMaker {
21
22
  }
22
23
  const groups = WireOps.groupConnectedEdges(edgesOnly);
23
24
  const faces = [];
25
+ const inwardEdges = [];
26
+ const outwardEdges = [];
24
27
  for (const group of groups) {
25
28
  const wire = WireOps.makeWireFromEdges(group);
26
29
  const isClosed = wire.isClosed();
27
30
  if (offset2 !== undefined) {
28
- faces.push(this.makeDualOffsetFace(wire, isClosed, plane, offset1, offset2));
31
+ const result = this.makeDualOffsetFace(wire, isClosed, plane, offset1, offset2);
32
+ faces.push(result.face);
33
+ inwardEdges.push(...result.inwardEdges);
34
+ outwardEdges.push(...result.outwardEdges);
29
35
  }
30
36
  else {
31
- faces.push(this.makeSingleOffsetFace(wire, isClosed, plane, offset1));
37
+ const result = this.makeSingleOffsetFace(wire, isClosed, plane, offset1);
38
+ faces.push(result.face);
39
+ inwardEdges.push(...result.inwardEdges);
40
+ outwardEdges.push(...result.outwardEdges);
32
41
  }
33
42
  }
34
- return faces;
43
+ return { faces, inwardEdges, outwardEdges };
35
44
  }
36
45
  static makeSingleOffsetFace(wire, isClosed, plane, offset) {
37
46
  const offsetWire = this.doOffset(wire, plane, offset, isClosed);
@@ -41,12 +50,22 @@ export class ThinFaceMaker {
41
50
  ? [offsetWire, wire]
42
51
  : [wire, offsetWire];
43
52
  const reversedInner = WireOps.reverseWire(inner);
44
- return Face.fromTopoDSFace(FaceOps.makeFaceFromWires([
45
- outer.getShape(),
46
- reversedInner.getShape(),
47
- ]));
53
+ return {
54
+ face: Face.fromTopoDSFace(FaceOps.makeFaceFromWires([
55
+ outer.getShape(),
56
+ reversedInner.getShape(),
57
+ ])),
58
+ inwardEdges: [],
59
+ outwardEdges: [],
60
+ };
48
61
  }
49
- return this.makeOpenFaceWithCaps(wire, offsetWire);
62
+ // For open profiles: positive offset goes inward (toward profile interior)
63
+ const inwardWireEdges = offset >= 0 ? offsetWire.getEdges() : wire.getEdges();
64
+ const outwardWireEdges = offset >= 0 ? wire.getEdges() : offsetWire.getEdges();
65
+ const face = this.makeOpenFaceWithCaps(wire, offsetWire);
66
+ const inwardEdges = this.matchFaceEdgesByMidpoint(face, inwardWireEdges);
67
+ const outwardEdges = this.matchFaceEdgesByMidpoint(face, outwardWireEdges);
68
+ return { face, inwardEdges, outwardEdges };
50
69
  }
51
70
  static makeDualOffsetFace(wire, isClosed, plane, offset1, offset2) {
52
71
  // Ensure offset2 goes in the opposite direction of offset1
@@ -61,12 +80,22 @@ export class ThinFaceMaker {
61
80
  ? [wire1, wire2]
62
81
  : [wire2, wire1];
63
82
  const reversedInner = WireOps.reverseWire(inner);
64
- return Face.fromTopoDSFace(FaceOps.makeFaceFromWires([
65
- outer.getShape(),
66
- reversedInner.getShape(),
67
- ]));
83
+ return {
84
+ face: Face.fromTopoDSFace(FaceOps.makeFaceFromWires([
85
+ outer.getShape(),
86
+ reversedInner.getShape(),
87
+ ])),
88
+ inwardEdges: [],
89
+ outwardEdges: [],
90
+ };
68
91
  }
69
- return this.makeOpenFaceWithCaps(wire1, wire2);
92
+ // For open profiles: positive offset goes inward (toward profile interior)
93
+ const inwardWireEdges = offset1 > 0 ? wire1.getEdges() : wire2.getEdges();
94
+ const outwardWireEdges = offset1 > 0 ? wire2.getEdges() : wire1.getEdges();
95
+ const face = this.makeOpenFaceWithCaps(wire1, wire2);
96
+ const inwardEdges = this.matchFaceEdgesByMidpoint(face, inwardWireEdges);
97
+ const outwardEdges = this.matchFaceEdgesByMidpoint(face, outwardWireEdges);
98
+ return { face, inwardEdges, outwardEdges };
70
99
  }
71
100
  /**
72
101
  * Offsets a wire by the given distance, handling both closed and open wires.
@@ -121,6 +150,18 @@ export class ThinFaceMaker {
121
150
  }
122
151
  return Wire.fromTopoDSWire(oc.TopoDS.Wire(wires[0]));
123
152
  }
153
+ /**
154
+ * Finds face edges that geometrically match the given wire edges by comparing midpoints.
155
+ * This is needed because wire reversal (ShapeExtend_WireData.Reverse) creates new TShapes,
156
+ * breaking IsPartner identity between original wire edges and face edges.
157
+ */
158
+ static matchFaceEdgesByMidpoint(face, wireEdges) {
159
+ const wireMidpoints = wireEdges.map(we => EdgeOps.getEdgeMidPointRaw(we.getShape()));
160
+ return face.getEdges().filter(fe => {
161
+ const feMid = EdgeOps.getEdgeMidPointRaw(fe.getShape());
162
+ return wireMidpoints.some(mp => feMid.distanceTo(mp) < 1e-4);
163
+ });
164
+ }
124
165
  static makeLineEdge(p1, p2) {
125
166
  const oc = getOC();
126
167
  const [gp1, dispose1] = Convert.toGpPnt(p1);
@@ -10,15 +10,16 @@ export class Scene {
10
10
  constructor() {
11
11
  }
12
12
  addSceneObject(obj) {
13
+ if (this.sceneObjects.includes(obj)) {
14
+ return;
15
+ }
13
16
  obj.setOrder(this.sceneObjects.length);
14
17
  const activeObj = this.getActiveContainer();
15
18
  if (activeObj && !obj.getParent()) {
16
19
  activeObj.addChildObject(obj);
17
20
  }
18
- if (!this.sceneObjects.includes(obj)) {
19
- this.sceneObjects.push(obj);
20
- this.idMap.set(obj.id, obj);
21
- }
21
+ this.sceneObjects.push(obj);
22
+ this.idMap.set(obj.id, obj);
22
23
  }
23
24
  startProgressiveContainer(obj) {
24
25
  this.addSceneObject(obj);
@@ -213,6 +213,22 @@ describe("extrude two distances", () => {
213
213
  expect(bbox.maxX - bbox.minX).toBeGreaterThan(100);
214
214
  expect(bbox.maxY - bbox.minY).toBeGreaterThan(50);
215
215
  });
216
+ it("should apply different draft angles per direction", () => {
217
+ sketch("xy", () => {
218
+ rect(100, 50);
219
+ });
220
+ const e = extrude(20, 20).draft([8, 2]);
221
+ render();
222
+ const startFaces = e.getState('start-faces');
223
+ const endFaces = e.getState('end-faces');
224
+ const startBbox = ShapeOps.getBoundingBox(startFaces[0].getShape());
225
+ const endBbox = ShapeOps.getBoundingBox(endFaces[0].getShape());
226
+ const startWidth = startBbox.maxX - startBbox.minX;
227
+ const endWidth = endBbox.maxX - endBbox.minX;
228
+ // Start face (distance1=20, draft=8°) should be wider than
229
+ // end face (distance2=20, draft=2°) due to greater taper
230
+ expect(startWidth).toBeGreaterThan(endWidth);
231
+ });
216
232
  });
217
233
  describe("drill", () => {
218
234
  it("should drill hole when inner shape is nested (default)", () => {
@@ -40,6 +40,18 @@ describe("repeat circular", () => {
40
40
  // Original (1) + 2 repeated = 3
41
41
  expect(countShapes(scene)).toBe(3);
42
42
  });
43
+ it("should evenly space 3 instances around full circle", () => {
44
+ sketch("xy", () => {
45
+ move([50, 0]);
46
+ rect(20, 20);
47
+ });
48
+ const e = extrude(10).new();
49
+ // count: 3, angle: 360 → 120° apart
50
+ repeat("circular", "z", { count: 3, angle: 360 }, e);
51
+ const scene = render();
52
+ // Original (1) + 2 repeated = 3
53
+ expect(countShapes(scene)).toBe(3);
54
+ });
43
55
  it("should use explicit offset between instances", () => {
44
56
  sketch("xy", () => {
45
57
  move([50, 0]);
@@ -89,6 +89,28 @@ describe("repeat linear", () => {
89
89
  // Original (1) + 4 repeated = 5
90
90
  expect(countShapes(scene)).toBe(5);
91
91
  });
92
+ it("should center correctly with count 3", () => {
93
+ sketch("xy", () => {
94
+ rect(20, 20);
95
+ });
96
+ const e = extrude(10).new();
97
+ // count: 3, centered → one clone on each side of the original
98
+ repeat("linear", "x", { count: 3, offset: 25, centered: true }, e);
99
+ const scene = render();
100
+ // Original (1) + 2 repeated = 3
101
+ expect(countShapes(scene)).toBe(3);
102
+ });
103
+ it("should center correctly in a multi-axis grid", () => {
104
+ sketch("xy", () => {
105
+ rect(10, 10);
106
+ });
107
+ const e = extrude(10).new();
108
+ // 3×3 centered grid → 9 positions, 1 is the center (original) = 8 clones
109
+ repeat("linear", ["x", "y"], { count: [3, 3], offset: 30, centered: true }, e);
110
+ const scene = render();
111
+ // Original (1) + 8 repeated = 9
112
+ expect(countShapes(scene)).toBe(9);
113
+ });
92
114
  it("should skip specified index", () => {
93
115
  sketch("xy", () => {
94
116
  rect(20, 20);
@@ -631,4 +631,59 @@ describe("select", () => {
631
631
  expect(sel.getShapes()).toHaveLength(1);
632
632
  });
633
633
  });
634
+ describe("withTangents", () => {
635
+ describe("face withTangents", () => {
636
+ it("should expand to tangent side faces but not perpendicular top/bottom", () => {
637
+ sketch("xy", () => {
638
+ rect(200, 100).radius(10);
639
+ });
640
+ extrude(50);
641
+ // "front" = XZ plane (y=0). Matches one flat side face.
642
+ // withTangents should follow cylindrical fillets to adjacent side faces,
643
+ // but NOT cross to top/bottom faces (90° angle = not tangent).
644
+ const sel = select(face().onPlane("front").withTangents());
645
+ render();
646
+ const shapes = sel.getShapes();
647
+ // Rounded rect extrusion has:
648
+ // - 2 cap faces (top/bottom on XY planes)
649
+ // - 4 flat side faces
650
+ // - 4 cylindrical fillet faces connecting adjacent sides
651
+ // Total = 10 faces
652
+ //
653
+ // Starting from front face, tangent expansion should include:
654
+ // 4 flat side faces + 4 cylindrical fillet faces = 8
655
+ // (NOT the 2 cap faces which meet at 90°)
656
+ expect(shapes.length).toBe(8);
657
+ });
658
+ it("should not expand when no tangent neighbors exist (sharp box)", () => {
659
+ sketch("xy", () => {
660
+ rect(100, 50);
661
+ });
662
+ extrude(30);
663
+ // Sharp box: all edges are 90° → no tangent expansion
664
+ const sel = select(face().onPlane("front").withTangents());
665
+ render();
666
+ // Should still be just the 1 front face
667
+ expect(sel.getShapes()).toHaveLength(1);
668
+ });
669
+ });
670
+ describe("edge withTangents", () => {
671
+ it("should expand to tangent edges along a filleted box bottom", () => {
672
+ sketch("xy", () => {
673
+ rect(200, 100).radius(10);
674
+ });
675
+ extrude(50);
676
+ // Bottom edges on XY plane: 4 line edges + 4 arc edges = 8 edges
677
+ // All are tangent-connected (line→arc→line→arc→...)
678
+ // Selecting one line edge on "front" plane + withTangents should get all 8
679
+ const sel = select(edge().onPlane("xy").onPlane("front").withTangents());
680
+ render();
681
+ const shapes = sel.getShapes();
682
+ // The front-bottom edge is tangent to the 2 corner arcs,
683
+ // which are tangent to the left/right bottom edges, etc.
684
+ // All 8 bottom edges form a tangent chain.
685
+ expect(shapes.length).toBe(8);
686
+ });
687
+ });
688
+ });
634
689
  });
@@ -19,6 +19,15 @@ describe("thin extrude", () => {
19
19
  const bbox = ShapeOps.getBoundingBox(shapes[0]);
20
20
  expect(bbox.maxZ - bbox.minZ).toBeCloseTo(30, 0);
21
21
  });
22
+ it("should still classify internal faces for closed profile", () => {
23
+ sketch("xy", () => {
24
+ rect(100, 100);
25
+ });
26
+ const e = extrude(30).thin(5);
27
+ render();
28
+ const internalFaces = e.getState('internal-faces');
29
+ expect(internalFaces.length).toBeGreaterThan(0);
30
+ });
22
31
  it("should create a thin-walled solid with dual offset", () => {
23
32
  sketch("xy", () => {
24
33
  rect(100, 100);
@@ -103,6 +112,58 @@ describe("thin extrude", () => {
103
112
  expect(shapes).toHaveLength(1);
104
113
  expect(shapes[0].getType()).toBe('solid');
105
114
  });
115
+ it("should classify side, internal, and cap faces for positive offset", () => {
116
+ sketch("xy", () => {
117
+ line([0, 0], [100, 0]);
118
+ });
119
+ const e = extrude(20).thin(10).new();
120
+ render();
121
+ const sideFaces = e.getState('side-faces');
122
+ const internalFaces = e.getState('internal-faces');
123
+ const capFaces = e.getState('cap-faces');
124
+ expect(sideFaces.length).toBeGreaterThan(0);
125
+ expect(internalFaces.length).toBeGreaterThan(0);
126
+ expect(capFaces.length).toBe(2);
127
+ });
128
+ it("should classify side, internal, and cap faces for negative offset", () => {
129
+ sketch("xy", () => {
130
+ line([0, 0], [100, 0]);
131
+ });
132
+ const e = extrude(20).thin(-10).new();
133
+ render();
134
+ const sideFaces = e.getState('side-faces');
135
+ const internalFaces = e.getState('internal-faces');
136
+ const capFaces = e.getState('cap-faces');
137
+ expect(sideFaces.length).toBeGreaterThan(0);
138
+ expect(internalFaces.length).toBeGreaterThan(0);
139
+ expect(capFaces.length).toBe(2);
140
+ });
141
+ it("should classify side, internal, and cap faces for dual offset", () => {
142
+ sketch("xy", () => {
143
+ line([0, 0], [100, 0]);
144
+ });
145
+ const e = extrude(20).thin(5, -10).new();
146
+ render();
147
+ const sideFaces = e.getState('side-faces');
148
+ const internalFaces = e.getState('internal-faces');
149
+ const capFaces = e.getState('cap-faces');
150
+ expect(sideFaces.length).toBeGreaterThan(0);
151
+ expect(internalFaces.length).toBeGreaterThan(0);
152
+ expect(capFaces.length).toBe(2);
153
+ });
154
+ it("should classify faces correctly with symmetric thin extrude", () => {
155
+ sketch("xy", () => {
156
+ line([0, 0], [100, 0]);
157
+ });
158
+ const e = extrude(20).thin(10).symmetric().new();
159
+ render();
160
+ const sideFaces = e.getState('side-faces');
161
+ const internalFaces = e.getState('internal-faces');
162
+ const capFaces = e.getState('cap-faces');
163
+ expect(sideFaces.length).toBeGreaterThan(0);
164
+ expect(internalFaces.length).toBeGreaterThan(0);
165
+ expect(capFaces.length).toBe(2);
166
+ });
106
167
  });
107
168
  describe("two-distance extrude", () => {
108
169
  it("should create a thin-walled solid with two distances", () => {